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