this repo has no description

feat: format both sides

dunkirk.sh 29902a8c b0be7a6e

verified
+255 -138
+1 -1
package.json
··· 5 5 "type": "module", 6 6 "private": true, 7 7 "scripts": { 8 - "dev": "bun --hot src/index.ts", 8 + "dev": "bun src/index.ts", 9 9 "start": "bun src/index.ts", 10 10 "ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app" 11 11 },
+156 -137
src/index.ts
··· 1 1 import * as irc from "irc"; 2 - import { SlackAPIClient, SlackApp } from "slack-edge"; 2 + import { SlackApp } from "slack-edge"; 3 3 import { version } from "../package.json"; 4 4 import { channelMappings, userMappings } from "./db"; 5 + import { parseSlackMarkdown, parseIRCFormatting } from "./parser"; 6 + import type { CachetUser } from "./types"; 5 7 6 8 const missingEnvVars = []; 7 9 if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN"); 8 10 if (!process.env.SLACK_SIGNING_SECRET) 9 - missingEnvVars.push("SLACK_SIGNING_SECRET"); 11 + missingEnvVars.push("SLACK_SIGNING_SECRET"); 10 12 if (!process.env.ADMINS) missingEnvVars.push("ADMINS"); 11 13 if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK"); 12 14 13 15 if (missingEnvVars.length > 0) { 14 - throw new Error( 15 - `Missing required environment variables: ${missingEnvVars.join(", ")}`, 16 - ); 16 + throw new Error( 17 + `Missing required environment variables: ${missingEnvVars.join(", ")}`, 18 + ); 17 19 } 18 20 19 21 const slackApp = new SlackApp({ 20 - env: { 21 - SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string, 22 - SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string, 23 - SLACK_LOGGING_LEVEL: "INFO", 24 - }, 25 - startLazyListenerAfterAck: true, 22 + env: { 23 + SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string, 24 + SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string, 25 + SLACK_LOGGING_LEVEL: "INFO", 26 + }, 27 + startLazyListenerAfterAck: true, 26 28 }); 27 29 const slackClient = slackApp.client; 28 30 29 31 // Get bot user ID 30 32 let botUserId: string | undefined; 31 - slackClient.auth.test({ 32 - token: process.env.SLACK_BOT_TOKEN, 33 - }).then((result) => { 34 - botUserId = result.user_id; 35 - console.log(`Bot user ID: ${botUserId}`); 36 - }); 33 + slackClient.auth 34 + .test({ 35 + token: process.env.SLACK_BOT_TOKEN, 36 + }) 37 + .then((result) => { 38 + botUserId = result.user_id; 39 + console.log(`Bot user ID: ${botUserId}`); 40 + }); 37 41 38 42 // IRC client setup 39 43 const ircClient = new irc.Client( 40 - "irc.hackclub.com", 41 - process.env.IRC_NICK || "slackbridge", 42 - { 43 - port: 6667, 44 - autoRejoin: true, 45 - autoConnect: true, 46 - channels: [], 47 - secure: false, 48 - userName: process.env.IRC_NICK, 49 - realName: "Slack IRC Bridge", 50 - }, 44 + "irc.hackclub.com", 45 + process.env.IRC_NICK || "slackbridge", 46 + { 47 + port: 6667, 48 + autoRejoin: true, 49 + autoConnect: true, 50 + channels: [], 51 + secure: false, 52 + userName: process.env.IRC_NICK, 53 + realName: "Slack IRC Bridge", 54 + }, 51 55 ); 52 56 57 + // Clean up IRC connection on hot reload or exit 58 + process.on("beforeExit", () => { 59 + ircClient.disconnect("Reloading", () => { 60 + console.log("IRC client disconnected"); 61 + }); 62 + }); 63 + 53 64 // Join all mapped IRC channels on connect 54 65 ircClient.addListener("registered", async () => { 55 - console.log("Connected to IRC server"); 56 - const mappings = channelMappings.getAll(); 57 - for (const mapping of mappings) { 58 - ircClient.join(mapping.irc_channel); 59 - } 66 + console.log("Connected to IRC server"); 67 + const mappings = channelMappings.getAll(); 68 + for (const mapping of mappings) { 69 + ircClient.join(mapping.irc_channel); 70 + } 60 71 }); 61 72 62 73 ircClient.addListener("join", (channel: string, nick: string) => { 63 - if (nick === process.env.IRC_NICK) { 64 - console.log(`Joined IRC channel: ${channel}`); 65 - } 74 + if (nick === process.env.IRC_NICK) { 75 + console.log(`Joined IRC channel: ${channel}`); 76 + } 66 77 }); 67 78 68 79 ircClient.addListener( 69 - "message", 70 - async (nick: string, to: string, text: string) => { 71 - if (nick === process.env.IRC_NICK) return; 72 - if (nick === "****") return; 80 + "message", 81 + async (nick: string, to: string, text: string) => { 82 + // Ignore messages from our own bot (with or without numbers suffix) 83 + const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 84 + if (botNickPattern.test(nick)) return; 85 + if (nick === "****") return; 73 86 74 - // Find Slack channel mapping for this IRC channel 75 - const mapping = channelMappings.getByIrcChannel(to); 76 - if (!mapping) return; 87 + // Find Slack channel mapping for this IRC channel 88 + const mapping = channelMappings.getByIrcChannel(to); 89 + if (!mapping) return; 77 90 78 - // Check if this IRC nick is mapped to a Slack user 79 - const userMapping = userMappings.getByIrcNick(nick); 91 + // Check if this IRC nick is mapped to a Slack user 92 + const userMapping = userMappings.getByIrcNick(nick); 80 93 81 - const displayName = `${nick} <irc>`; 82 - let iconUrl: string | undefined; 94 + const displayName = `${nick} <irc>`; 95 + let iconUrl: string | undefined; 83 96 84 - if (userMapping) { 85 - try { 86 - iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 87 - } catch (error) { 88 - console.error("Error fetching user info:", error); 89 - } 90 - } 97 + if (userMapping) { 98 + try { 99 + iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 100 + } catch (error) { 101 + console.error("Error fetching user info:", error); 102 + } 103 + } 91 104 92 - try { 93 - await slackClient.chat.postMessage({ 94 - token: process.env.SLACK_BOT_TOKEN, 95 - channel: mapping.slack_channel_id, 96 - text: text, 97 - username: displayName, 98 - icon_url: iconUrl, 99 - unfurl_links: false, 100 - unfurl_media: false, 101 - }); 102 - } catch (error) { 103 - console.error("Error posting to Slack:", error); 104 - } 105 - }, 105 + try { 106 + await slackClient.chat.postMessage({ 107 + token: process.env.SLACK_BOT_TOKEN, 108 + channel: mapping.slack_channel_id, 109 + text: parseIRCFormatting(text), 110 + username: displayName, 111 + icon_url: iconUrl, 112 + unfurl_links: false, 113 + unfurl_media: false, 114 + }); 115 + console.log(`IRC → Slack: <${nick}> ${text}`); 116 + } catch (error) { 117 + console.error("Error posting to Slack:", error); 118 + } 119 + }, 106 120 ); 107 121 108 122 ircClient.addListener("error", (error: string) => { 109 - console.error("IRC error:", error); 123 + console.error("IRC error:", error); 110 124 }); 111 125 112 126 // Slack event handlers 113 127 slackApp.event("message", async ({ payload }) => { 114 - if (payload.subtype) return; 115 - if (payload.bot_id) return; 116 - if (payload.user === botUserId) return; 128 + if (payload.subtype) return; 129 + if (payload.bot_id) return; 130 + if (payload.user === botUserId) return; 117 131 118 - // Find IRC channel mapping for this Slack channel 119 - const mapping = channelMappings.getBySlackChannel(payload.channel); 120 - if (!mapping) { 121 - console.log( 122 - `No IRC channel mapping found for Slack channel ${payload.channel}`, 123 - ); 124 - slackClient.conversations.leave({ 125 - channel: payload.channel, 126 - }); 127 - return; 128 - } 132 + // Find IRC channel mapping for this Slack channel 133 + const mapping = channelMappings.getBySlackChannel(payload.channel); 134 + if (!mapping) { 135 + console.log( 136 + `No IRC channel mapping found for Slack channel ${payload.channel}`, 137 + ); 138 + slackClient.conversations.leave({ 139 + channel: payload.channel, 140 + }); 141 + return; 142 + } 129 143 130 - try { 131 - const userInfo = await slackClient.users.info({ 132 - token: process.env.SLACK_BOT_TOKEN, 133 - user: payload.user, 134 - }); 144 + try { 145 + const userInfo = await slackClient.users.info({ 146 + token: process.env.SLACK_BOT_TOKEN, 147 + user: payload.user, 148 + }); 135 149 136 - // Check for user mapping, otherwise use Slack name 137 - const userMapping = userMappings.getBySlackUser(payload.user); 138 - const username = 139 - userMapping?.irc_nick || 140 - userInfo.user?.real_name || 141 - userInfo.user?.name || 142 - "Unknown"; 143 - 144 - // Parse Slack mentions and replace with display names 145 - let messageText = payload.text; 146 - const mentionRegex = /<@(U[A-Z0-9]+)>/g; 147 - const mentions = Array.from(messageText.matchAll(mentionRegex)); 148 - 149 - for (const match of mentions) { 150 - const userId = match[1]; 151 - try { 152 - const response = await fetch(`https://cachet.dunkirk.sh/users/${userId}`); 153 - if (response.ok) { 154 - const data = await response.json(); 155 - messageText = messageText.replace(match[0], `@${data.displayName}`); 156 - } 157 - } catch (error) { 158 - console.error(`Error fetching user ${userId} from cachet:`, error); 159 - } 160 - } 161 - 162 - const message = `<${username}> ${messageText}`; 150 + // Check for user mapping, otherwise use Slack name 151 + const userMapping = userMappings.getBySlackUser(payload.user); 152 + const username = 153 + userMapping?.irc_nick || 154 + userInfo.user?.real_name || 155 + userInfo.user?.name || 156 + "Unknown"; 163 157 164 - console.log(`Sending to IRC ${mapping.irc_channel}: ${message}`); 165 - ircClient.say(mapping.irc_channel, message); 166 - } catch (error) { 167 - console.error("Error handling Slack message:", error); 168 - } 158 + // Parse Slack mentions and replace with display names 159 + let messageText = payload.text; 160 + const mentionRegex = /<@(U[A-Z0-9]+)>/g; 161 + const mentions = Array.from(messageText.matchAll(mentionRegex)); 162 + 163 + for (const match of mentions) { 164 + const userId = match[1]; 165 + try { 166 + const response = await fetch( 167 + `https://cachet.dunkirk.sh/users/${userId}`, 168 + ); 169 + if (response.ok) { 170 + const data = await response.json() as CachetUser; 171 + messageText = messageText.replace(match[0], `@${data.displayName}`); 172 + } 173 + } catch (error) { 174 + console.error(`Error fetching user ${userId} from cachet:`, error); 175 + } 176 + } 177 + 178 + // Parse Slack markdown formatting 179 + messageText = parseSlackMarkdown(messageText); 180 + 181 + const message = `<${username}> ${messageText}`; 182 + 183 + ircClient.say(mapping.irc_channel, message); 184 + console.log(`Slack → IRC: ${message}`); 185 + } catch (error) { 186 + console.error("Error handling Slack message:", error); 187 + } 169 188 }); 170 189 171 190 export default { 172 - port: process.env.PORT || 3000, 173 - async fetch(request: Request) { 174 - const url = new URL(request.url); 175 - const path = url.pathname; 191 + port: process.env.PORT || 3000, 192 + async fetch(request: Request) { 193 + const url = new URL(request.url); 194 + const path = url.pathname; 176 195 177 - switch (path) { 178 - case "/": 179 - return new Response(`Hello World from irc-slack-bridge@${version}`); 180 - case "/health": 181 - return new Response("OK"); 182 - case "/slack": 183 - return slackApp.run(request); 184 - default: 185 - return new Response("404 Not Found", { status: 404 }); 186 - } 187 - }, 196 + switch (path) { 197 + case "/": 198 + return new Response(`Hello World from irc-slack-bridge@${version}`); 199 + case "/health": 200 + return new Response("OK"); 201 + case "/slack": 202 + return slackApp.run(request); 203 + default: 204 + return new Response("404 Not Found", { status: 404 }); 205 + } 206 + }, 188 207 }; 189 208 190 209 console.log( 191 - `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 210 + `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 192 211 ); 193 212 console.log( 194 - `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 213 + `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 195 214 ); 196 215 console.log(`Channel mappings: ${channelMappings.getAll().length}`); 197 216 console.log(`User mappings: ${userMappings.getAll().length}`);
+89
src/parser.ts
··· 1 + /** 2 + * Parse Slack mrkdwn formatting and convert to IRC-friendly plain text 3 + */ 4 + export function parseSlackMarkdown(text: string): string { 5 + let parsed = text; 6 + 7 + // Replace channel mentions <#C123ABC|channel-name> or <#C123ABC> 8 + parsed = parsed.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1"); 9 + parsed = parsed.replace(/<#[A-Z0-9]+>/g, "#channel"); 10 + 11 + // Replace links <http://example.com|text> or <http://example.com> 12 + parsed = parsed.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "$2 ($1)"); 13 + parsed = parsed.replace(/<(https?:\/\/[^>]+)>/g, "$1"); 14 + 15 + // Replace mailto links <mailto:email|text> 16 + parsed = parsed.replace(/<mailto:([^|>]+)\|([^>]+)>/g, "$2 <$1>"); 17 + parsed = parsed.replace(/<mailto:([^>]+)>/g, "$1"); 18 + 19 + // Replace special mentions 20 + parsed = parsed.replace(/<!here>/g, "@here"); 21 + parsed = parsed.replace(/<!channel>/g, "@channel"); 22 + parsed = parsed.replace(/<!everyone>/g, "@everyone"); 23 + 24 + // Replace user group mentions <!subteam^GROUP_ID|handle> 25 + parsed = parsed.replace(/<!subteam\^[A-Z0-9]+\|([^>]+)>/g, "@$1"); 26 + parsed = parsed.replace(/<!subteam\^[A-Z0-9]+>/g, "@group"); 27 + 28 + // Date formatting - just use fallback text 29 + parsed = parsed.replace(/<!date\^[0-9]+\^[^|]+\|([^>]+)>/g, "$1"); 30 + 31 + // Replace Slack bold *text* with IRC bold \x02text\x02 32 + parsed = parsed.replace(/\*((?:[^\*]|\\\*)+)\*/g, "\x02$1\x02"); 33 + 34 + // Replace Slack italic _text_ with IRC italic \x1Dtext\x1D 35 + parsed = parsed.replace(/_((?:[^_]|\\_)+)_/g, "\x1D$1\x1D"); 36 + 37 + // Replace Slack strikethrough ~text~ with plain text (IRC doesn't support strikethrough well) 38 + parsed = parsed.replace(/~((?:[^~]|\\~)+)~/g, "$1"); 39 + 40 + // Replace code blocks ```code``` with plain text 41 + parsed = parsed.replace(/```([^`]+)```/g, "$1"); 42 + 43 + // Replace inline code `code` with plain text 44 + parsed = parsed.replace(/`([^`]+)`/g, "$1"); 45 + 46 + // Handle block quotes - prefix with > 47 + parsed = parsed.replace(/^>/gm, ">"); 48 + 49 + // Unescape HTML entities 50 + parsed = parsed.replace(/&amp;/g, "&"); 51 + parsed = parsed.replace(/&lt;/g, "<"); 52 + parsed = parsed.replace(/&gt;/g, ">"); 53 + 54 + return parsed; 55 + } 56 + 57 + /** 58 + * Parse IRC formatting codes and convert to Slack mrkdwn 59 + */ 60 + export function parseIRCFormatting(text: string): string { 61 + let parsed = text; 62 + 63 + // IRC color codes - strip them (Slack doesn't support colors in the same way) 64 + // \x03 followed by optional color codes 65 + parsed = parsed.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, ""); 66 + 67 + // IRC bold \x02text\x02 -> Slack bold *text* 68 + parsed = parsed.replace(/\x02([^\x02]*)\x02/g, "*$1*"); 69 + 70 + // IRC italic \x1D text\x1D -> Slack italic _text_ 71 + parsed = parsed.replace(/\x1D([^\x1D]*)\x1D/g, "_$1_"); 72 + 73 + // IRC underline \x1F text\x1F -> Slack doesn't have underline, use italic instead 74 + parsed = parsed.replace(/\x1F([^\x1F]*)\x1F/g, "_$1_"); 75 + 76 + // IRC reverse/inverse \x16 - strip it (Slack doesn't support) 77 + parsed = parsed.replace(/\x16/g, ""); 78 + 79 + // IRC reset \x0F - strip it 80 + parsed = parsed.replace(/\x0F/g, ""); 81 + 82 + // Escape special Slack characters that would be interpreted as formatting 83 + parsed = parsed.replace(/&/g, "&amp;"); 84 + parsed = parsed.replace(/</g, "&lt;"); 85 + parsed = parsed.replace(/>/g, "&gt;"); 86 + 87 + return parsed; 88 + } 89 +
+9
src/types.ts
··· 1 + export interface CachetUser { 2 + type: "user"; 3 + id: string; 4 + userId: string; 5 + displayName: string; 6 + pronouns: string; 7 + imageUrl: string; 8 + expiration: string; 9 + }