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