this repo has no description
1import * as irc from "irc"; 2import { SlackApp } from "slack-edge"; 3import { version } from "../package.json"; 4import { registerCommands } from "./commands"; 5import { channelMappings, userMappings } from "./lib/db"; 6import { getAvatarForNick } from "./lib/avatars"; 7import { uploadToCDN } from "./lib/cdn"; 8import { 9 convertIrcMentionsToSlack, 10 convertSlackMentionsToIrc, 11} from "./lib/mentions"; 12import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser"; 13import { 14 cleanupOldThreads, 15 getThreadByThreadId, 16 isFirstThreadMessage, 17 updateThreadTimestamp, 18} from "./lib/threads"; 19 20const missingEnvVars = []; 21if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN"); 22if (!process.env.SLACK_SIGNING_SECRET) 23 missingEnvVars.push("SLACK_SIGNING_SECRET"); 24if (!process.env.ADMINS) missingEnvVars.push("ADMINS"); 25if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK"); 26 27if (missingEnvVars.length > 0) { 28 throw new Error( 29 `Missing required environment variables: ${missingEnvVars.join(", ")}`, 30 ); 31} 32 33const slackApp = new SlackApp({ 34 env: { 35 SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string, 36 SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string, 37 SLACK_LOGGING_LEVEL: "INFO", 38 }, 39 startLazyListenerAfterAck: true, 40}); 41const slackClient = slackApp.client; 42 43// Get bot user ID 44let botUserId: string | undefined; 45slackClient.auth 46 .test({ 47 token: process.env.SLACK_BOT_TOKEN, 48 }) 49 .then((result) => { 50 botUserId = result.user_id; 51 console.log(`Bot user ID: ${botUserId}`); 52 }); 53 54// IRC client setup 55const ircClient = new irc.Client( 56 "irc.hackclub.com", 57 process.env.IRC_NICK || "slackbridge", 58 { 59 port: 6667, 60 autoRejoin: true, 61 autoConnect: true, 62 channels: [], 63 secure: false, 64 userName: process.env.IRC_NICK, 65 realName: "Slack IRC Bridge", 66 }, 67); 68 69// Clean up IRC connection on hot reload or exit 70process.on("beforeExit", () => { 71 ircClient.disconnect("Reloading", () => { 72 console.log("IRC client disconnected"); 73 }); 74}); 75 76// Register slash commands 77registerCommands(); 78 79// Periodic cleanup of old thread timestamps (every hour) 80setInterval(() => { 81 cleanupOldThreads(); 82}, 60 * 60 * 1000); 83 84// Track NickServ authentication state 85let nickServAuthAttempted = false; 86let isAuthenticated = false; 87 88// Join all mapped IRC channels on connect 89ircClient.addListener("registered", async () => { 90 console.log("Connected to IRC server"); 91 92 // Authenticate with NickServ if password is provided 93 if (process.env.NICKSERV_PASSWORD && !nickServAuthAttempted) { 94 nickServAuthAttempted = true; 95 console.log("Authenticating with NickServ..."); 96 ircClient.say("NickServ", `IDENTIFY ${process.env.NICKSERV_PASSWORD}`); 97 // Don't join channels yet - wait for NickServ response 98 } else if (!process.env.NICKSERV_PASSWORD) { 99 // No auth needed, join immediately 100 const mappings = channelMappings.getAll(); 101 for (const mapping of mappings) { 102 ircClient.join(mapping.irc_channel); 103 } 104 } 105}); 106 107ircClient.addListener("join", (channel: string, nick: string) => { 108 if (nick === process.env.IRC_NICK) { 109 console.log(`Joined IRC channel: ${channel}`); 110 } 111}); 112 113// Handle NickServ notices 114ircClient.addListener( 115 "notice", 116 async (nick: string, to: string, text: string) => { 117 if (nick !== "NickServ") return; 118 119 console.log(`NickServ: ${text}`); 120 121 // Check for successful authentication 122 if ( 123 text.includes("You are now identified") || 124 text.includes("Password accepted") 125 ) { 126 console.log("✓ Successfully authenticated with NickServ"); 127 isAuthenticated = true; 128 129 // Join channels after successful auth 130 const mappings = channelMappings.getAll(); 131 for (const mapping of mappings) { 132 ircClient.join(mapping.irc_channel); 133 } 134 } 135 // Check if nick is not registered 136 else if ( 137 text.includes("isn't registered") || 138 text.includes("not registered") 139 ) { 140 console.log("Nick not registered, registering with NickServ..."); 141 if (process.env.NICKSERV_PASSWORD && process.env.NICKSERV_EMAIL) { 142 ircClient.say( 143 "NickServ", 144 `REGISTER ${process.env.NICKSERV_PASSWORD} ${process.env.NICKSERV_EMAIL}`, 145 ); 146 } else { 147 console.error("Cannot register: NICKSERV_EMAIL not configured"); 148 } 149 } 150 // Check for failed authentication 151 else if ( 152 text.includes("Invalid password") || 153 text.includes("Access denied") 154 ) { 155 console.error("✗ NickServ authentication failed: Invalid password"); 156 } 157 }, 158); 159 160ircClient.addListener( 161 "message", 162 async (nick: string, to: string, text: string) => { 163 // Ignore messages from our own bot (with or without numbers suffix) 164 const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 165 if (botNickPattern.test(nick)) return; 166 if (nick === "****") return; 167 168 // Find Slack channel mapping for this IRC channel 169 const mapping = channelMappings.getByIrcChannel(to); 170 if (!mapping) return; 171 172 // Check if this IRC nick is mapped to a Slack user 173 const userMapping = userMappings.getByIrcNick(nick); 174 175 const displayName = `${nick} <irc>`; 176 let iconUrl: string; 177 178 if (userMapping) { 179 iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 180 } else { 181 // Use stable random avatar for unmapped users 182 iconUrl = getAvatarForNick(nick); 183 } 184 185 // Parse IRC mentions and convert to Slack mentions 186 let messageText = parseIRCFormatting(text); 187 188 // Check for @xxxxx mentions to reply to threads 189 const threadMentionPattern = /@([a-z0-9]{5})\b/i; 190 const threadMatch = messageText.match(threadMentionPattern); 191 let threadTs: string | undefined; 192 193 if (threadMatch) { 194 const threadId = threadMatch[1]; 195 const threadInfo = getThreadByThreadId(threadId); 196 if (threadInfo && threadInfo.slack_channel_id === mapping.slack_channel_id) { 197 threadTs = threadInfo.thread_ts; 198 // Remove the @xxxxx from the message 199 messageText = messageText.replace(threadMentionPattern, "").trim(); 200 } 201 } 202 203 // Extract image URLs from the message 204 const imagePattern = 205 /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; 206 const imageUrls = Array.from(messageText.matchAll(imagePattern)); 207 208 messageText = convertIrcMentionsToSlack(messageText); 209 210 try { 211 // If there are image URLs, send them as attachments 212 if (imageUrls.length > 0) { 213 const attachments = imageUrls.map((match) => ({ 214 image_url: match[0], 215 fallback: match[0], 216 })); 217 218 await slackClient.chat.postMessage({ 219 token: process.env.SLACK_BOT_TOKEN, 220 channel: mapping.slack_channel_id, 221 text: messageText, 222 username: displayName, 223 icon_url: iconUrl, 224 attachments: attachments, 225 unfurl_links: false, 226 unfurl_media: false, 227 thread_ts: threadTs, 228 }); 229 } else { 230 await slackClient.chat.postMessage({ 231 token: process.env.SLACK_BOT_TOKEN, 232 channel: mapping.slack_channel_id, 233 text: messageText, 234 username: displayName, 235 icon_url: iconUrl, 236 unfurl_links: true, 237 unfurl_media: true, 238 thread_ts: threadTs, 239 }); 240 } 241 console.log(`IRC (${to}) → Slack: <${nick}> ${text}`); 242 } catch (error) { 243 console.error("Error posting to Slack:", error); 244 } 245 }, 246); 247 248ircClient.addListener("error", (error: string) => { 249 console.error("IRC error:", error); 250}); 251 252// Handle IRC /me actions 253ircClient.addListener( 254 "action", 255 async (nick: string, to: string, text: string) => { 256 // Ignore messages from our own bot 257 const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 258 if (botNickPattern.test(nick)) return; 259 if (nick === "****") return; 260 261 // Find Slack channel mapping for this IRC channel 262 const mapping = channelMappings.getByIrcChannel(to); 263 if (!mapping) return; 264 265 // Check if this IRC nick is mapped to a Slack user 266 const userMapping = userMappings.getByIrcNick(nick); 267 268 let iconUrl: string; 269 if (userMapping) { 270 iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 271 } else { 272 iconUrl = getAvatarForNick(nick); 273 } 274 275 // Parse IRC formatting and mentions 276 let messageText = parseIRCFormatting(text); 277 messageText = convertIrcMentionsToSlack(messageText); 278 279 // Format as action message with context block 280 const actionText = `${nick} ${messageText}`; 281 282 await slackClient.chat.postMessage({ 283 token: process.env.SLACK_BOT_TOKEN, 284 channel: mapping.slack_channel_id, 285 text: actionText, 286 blocks: [ 287 { 288 type: "context", 289 elements: [ 290 { 291 type: "image", 292 image_url: iconUrl, 293 alt_text: nick, 294 }, 295 { 296 type: "mrkdwn", 297 text: actionText, 298 }, 299 ], 300 }, 301 ], 302 }); 303 304 console.log(`IRC (${to}) → Slack (action): ${actionText}`); 305 }, 306); 307 308// Slack event handlers 309slackApp.event("message", async ({ payload }) => { 310 // Ignore bot messages 311 if (payload.subtype && payload.subtype !== "file_share") return; 312 if (payload.bot_id) return; 313 if (payload.user === botUserId) return; 314 315 // Find IRC channel mapping for this Slack channel 316 const mapping = channelMappings.getBySlackChannel(payload.channel); 317 if (!mapping) { 318 console.log( 319 `No IRC channel mapping found for Slack channel ${payload.channel}`, 320 ); 321 slackClient.conversations.leave({ 322 channel: payload.channel, 323 }); 324 return; 325 } 326 327 try { 328 const userInfo = await slackClient.users.info({ 329 token: process.env.SLACK_BOT_TOKEN, 330 user: payload.user, 331 }); 332 333 // Check for user mapping, otherwise use Slack name 334 const userMapping = userMappings.getBySlackUser(payload.user); 335 const username = 336 userMapping?.irc_nick || 337 userInfo.user?.real_name || 338 userInfo.user?.name || 339 "Unknown"; 340 341 // Parse Slack mentions and replace with IRC nicks or display names 342 let messageText = await convertSlackMentionsToIrc(payload.text); 343 344 // Parse Slack markdown formatting 345 messageText = parseSlackMarkdown(messageText); 346 347 let threadId: string | undefined; 348 349 // Handle thread messages 350 if (payload.thread_ts) { 351 const threadTs = payload.thread_ts; 352 const isFirstReply = isFirstThreadMessage(threadTs); 353 threadId = updateThreadTimestamp(threadTs, payload.channel); 354 355 if (isFirstReply) { 356 // First reply to thread, fetch and quote the parent message 357 try { 358 const parentResult = await slackClient.conversations.history({ 359 token: process.env.SLACK_BOT_TOKEN, 360 channel: payload.channel, 361 latest: threadTs, 362 inclusive: true, 363 limit: 1, 364 }); 365 366 if (parentResult.messages && parentResult.messages.length > 0) { 367 const parentMessage = parentResult.messages[0]; 368 let parentText = await convertSlackMentionsToIrc( 369 parentMessage.text || "", 370 ); 371 parentText = parseSlackMarkdown(parentText); 372 373 // Send the quoted parent message with thread ID 374 const quotedMessage = `<${username}> @${threadId} > ${parentText}`; 375 ircClient.say(mapping.irc_channel, quotedMessage); 376 console.log(`Slack → IRC (thread quote): ${quotedMessage}`); 377 } 378 } catch (error) { 379 console.error("Error fetching parent message:", error); 380 } 381 } 382 383 // Add thread ID to message 384 if (messageText.trim()) { 385 messageText = `@${threadId} ${messageText}`; 386 } 387 } 388 389 // Send message only if there's text content 390 if (messageText.trim()) { 391 const message = `<${username}> ${messageText}`; 392 ircClient.say(mapping.irc_channel, message); 393 console.log(`Slack → IRC: ${message}`); 394 } 395 396 // Handle file uploads 397 if (payload.files && payload.files.length > 0) { 398 try { 399 const fileUrls = payload.files.map((file) => file.url_private); 400 const data = await uploadToCDN(fileUrls); 401 402 for (const file of data.files) { 403 const threadPrefix = threadId ? `@${threadId} ` : ""; 404 const fileMessage = `<${username}> ${threadPrefix}${file.deployedUrl}`; 405 ircClient.say(mapping.irc_channel, fileMessage); 406 console.log(`Slack → IRC (file): ${fileMessage}`); 407 } 408 } catch (error) { 409 console.error("Error uploading files to CDN:", error); 410 } 411 } 412 } catch (error) { 413 console.error("Error handling Slack message:", error); 414 } 415}); 416 417export default { 418 port: process.env.PORT || 3000, 419 async fetch(request: Request) { 420 const url = new URL(request.url); 421 const path = url.pathname; 422 423 switch (path) { 424 case "/": 425 return new Response(`Hello World from irc-slack-bridge@${version}`); 426 case "/health": 427 return new Response("OK"); 428 case "/slack": 429 return slackApp.run(request); 430 default: 431 return new Response("404 Not Found", { status: 404 }); 432 } 433 }, 434}; 435 436console.log( 437 `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 438); 439console.log( 440 `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 441); 442console.log(`Channel mappings: ${channelMappings.getAll().length}`); 443console.log(`User mappings: ${userMappings.getAll().length}`); 444 445export { slackApp, slackClient, ircClient };