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]; 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("notice", async (nick: string, to: string, text: string) => { 118 if (nick !== "NickServ") return; 119 120 console.log(`NickServ: ${text}`); 121 122 // Check for successful authentication 123 if (text.includes("You are now identified") || text.includes("Password accepted")) { 124 console.log("✓ Successfully authenticated with NickServ"); 125 isAuthenticated = true; 126 127 // Join channels after successful auth 128 const mappings = channelMappings.getAll(); 129 for (const mapping of mappings) { 130 ircClient.join(mapping.irc_channel); 131 } 132 } 133 // Check if nick is not registered 134 else if (text.includes("isn't registered") || text.includes("not registered")) { 135 console.log("Nick not registered, registering with NickServ..."); 136 if (process.env.NICKSERV_PASSWORD && process.env.NICKSERV_EMAIL) { 137 ircClient.say("NickServ", `REGISTER ${process.env.NICKSERV_PASSWORD} ${process.env.NICKSERV_EMAIL}`); 138 } else { 139 console.error("Cannot register: NICKSERV_EMAIL not configured"); 140 } 141 } 142 // Check for failed authentication 143 else if (text.includes("Invalid password") || text.includes("Access denied")) { 144 console.error("✗ NickServ authentication failed: Invalid password"); 145 } 146}); 147 148ircClient.addListener( 149 "message", 150 async (nick: string, to: string, text: string) => { 151 // Ignore messages from our own bot (with or without numbers suffix) 152 const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 153 if (botNickPattern.test(nick)) return; 154 if (nick === "****") return; 155 156 // Find Slack channel mapping for this IRC channel 157 const mapping = channelMappings.getByIrcChannel(to); 158 if (!mapping) return; 159 160 // Check if this IRC nick is mapped to a Slack user 161 const userMapping = userMappings.getByIrcNick(nick); 162 163 const displayName = `${nick} <irc>`; 164 let iconUrl: string; 165 166 if (userMapping) { 167 iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 168 } else { 169 // Use stable random avatar for unmapped users 170 iconUrl = getAvatarForNick(nick); 171 } 172 173 // Parse IRC mentions and convert to Slack mentions 174 let messageText = parseIRCFormatting(text); 175 176 // Extract image URLs from the message 177 const imagePattern = 178 /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; 179 const imageUrls = Array.from(messageText.matchAll(imagePattern)); 180 181 // Find all @mentions and nick: mentions in the IRC message 182 const atMentionPattern = /@(\w+)/g; 183 const nickMentionPattern = /(\w+):/g; 184 185 const atMentions = Array.from(messageText.matchAll(atMentionPattern)); 186 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern)); 187 188 for (const match of atMentions) { 189 const mentionedNick = match[1] as string; 190 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 191 if (mentionedUserMapping) { 192 messageText = messageText.replace( 193 match[0], 194 `<@${mentionedUserMapping.slack_user_id}>`, 195 ); 196 } 197 } 198 199 for (const match of nickMentions) { 200 const mentionedNick = match[1] as string; 201 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 202 if (mentionedUserMapping) { 203 messageText = messageText.replace( 204 match[0], 205 `<@${mentionedUserMapping.slack_user_id}>:`, 206 ); 207 } 208 } 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 }); 228 } else { 229 await slackClient.chat.postMessage({ 230 token: process.env.SLACK_BOT_TOKEN, 231 channel: mapping.slack_channel_id, 232 text: messageText, 233 username: displayName, 234 icon_url: iconUrl, 235 unfurl_links: false, 236 unfurl_media: false, 237 }); 238 } 239 console.log(`IRC → Slack: <${nick}> ${text}`); 240 } catch (error) { 241 console.error("Error posting to Slack:", error); 242 } 243 }, 244); 245 246ircClient.addListener("error", (error: string) => { 247 console.error("IRC error:", error); 248}); 249 250// Handle IRC /me actions 251ircClient.addListener("action", async (nick: string, to: string, text: string) => { 252 // Ignore messages from our own bot 253 const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 254 if (botNickPattern.test(nick)) return; 255 if (nick === "****") return; 256 257 // Find Slack channel mapping for this IRC channel 258 const mapping = channelMappings.getByIrcChannel(to); 259 if (!mapping) return; 260 261 // Check if this IRC nick is mapped to a Slack user 262 const userMapping = userMappings.getByIrcNick(nick); 263 264 let iconUrl: string; 265 if (userMapping) { 266 iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 267 } else { 268 iconUrl = getAvatarForNick(nick); 269 } 270 271 // Parse IRC formatting and mentions 272 let messageText = parseIRCFormatting(text); 273 274 // Find all @mentions and nick: mentions in the IRC message 275 const atMentionPattern = /@(\w+)/g; 276 const nickMentionPattern = /(\w+):/g; 277 278 const atMentions = Array.from(messageText.matchAll(atMentionPattern)); 279 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern)); 280 281 for (const match of atMentions) { 282 const mentionedNick = match[1] as string; 283 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 284 if (mentionedUserMapping) { 285 messageText = messageText.replace( 286 match[0], 287 `<@${mentionedUserMapping.slack_user_id}>`, 288 ); 289 } 290 } 291 292 for (const match of nickMentions) { 293 const mentionedNick = match[1] as string; 294 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 295 if (mentionedUserMapping) { 296 messageText = messageText.replace( 297 match[0], 298 `<@${mentionedUserMapping.slack_user_id}>:`, 299 ); 300 } 301 } 302 303 // Format as action message with context block 304 const actionText = `${nick} ${messageText}`; 305 306 await slackClient.chat.postMessage({ 307 token: process.env.SLACK_BOT_TOKEN, 308 channel: mapping.slack_channel_id, 309 text: actionText, 310 blocks: [ 311 { 312 type: "context", 313 elements: [ 314 { 315 type: "image", 316 image_url: iconUrl, 317 alt_text: nick, 318 }, 319 { 320 type: "mrkdwn", 321 text: actionText, 322 }, 323 ], 324 }, 325 ], 326 }); 327 328 console.log(`IRC → Slack (action): ${actionText}`); 329}); 330 331// Slack event handlers 332slackApp.event("message", async ({ payload, context }) => { 333 // Ignore bot messages and threaded messages 334 if (payload.subtype && payload.subtype !== "file_share") return; 335 if (payload.bot_id) return; 336 if (payload.user === botUserId) return; 337 if (payload.thread_ts) return; 338 339 // Find IRC channel mapping for this Slack channel 340 const mapping = channelMappings.getBySlackChannel(payload.channel); 341 if (!mapping) { 342 console.log( 343 `No IRC channel mapping found for Slack channel ${payload.channel}`, 344 ); 345 slackClient.conversations.leave({ 346 channel: payload.channel, 347 }); 348 return; 349 } 350 351 try { 352 const userInfo = await slackClient.users.info({ 353 token: process.env.SLACK_BOT_TOKEN, 354 user: payload.user, 355 }); 356 357 // Check for user mapping, otherwise use Slack name 358 const userMapping = userMappings.getBySlackUser(payload.user); 359 const username = 360 userMapping?.irc_nick || 361 userInfo.user?.real_name || 362 userInfo.user?.name || 363 "Unknown"; 364 365 // Parse Slack mentions and replace with IRC nicks or display names 366 let messageText = payload.text; 367 const mentionRegex = /<@(U[A-Z0-9]+)(\|([^>]+))?>/g; 368 const mentions = Array.from(messageText.matchAll(mentionRegex)); 369 370 for (const match of mentions) { 371 const userId = match[1]; 372 const displayName = match[3]; // The name part after | 373 374 // Check if user has a mapped IRC nick 375 const mentionedUserMapping = userMappings.getBySlackUser(userId); 376 if (mentionedUserMapping) { 377 messageText = messageText.replace(match[0], `@${mentionedUserMapping.irc_nick}`); 378 } else if (displayName) { 379 // Use the display name from the mention format <@U123|name> 380 messageText = messageText.replace(match[0], `@${displayName}`); 381 } else { 382 // Fallback to Cachet lookup 383 try { 384 const response = await fetch( 385 `https://cachet.dunkirk.sh/users/${userId}`, 386 { 387 // @ts-ignore - Bun specific option 388 tls: { rejectUnauthorized: false }, 389 }, 390 ); 391 if (response.ok) { 392 const data = (await response.json()) as CachetUser; 393 messageText = messageText.replace(match[0], `@${data.displayName}`); 394 } 395 } catch (error) { 396 console.error(`Error fetching user ${userId} from cachet:`, error); 397 } 398 } 399 } 400 401 // Parse Slack markdown formatting 402 messageText = parseSlackMarkdown(messageText); 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 // Extract private file URLs 415 const fileUrls = payload.files.map((file) => file.url_private); 416 417 // Upload to Hack Club CDN 418 const response = await fetch("https://cdn.hackclub.com/api/v3/new", { 419 method: "POST", 420 headers: { 421 Authorization: `Bearer ${process.env.CDN_TOKEN}`, 422 "X-Download-Authorization": `Bearer ${process.env.SLACK_BOT_TOKEN}`, 423 "Content-Type": "application/json", 424 }, 425 body: JSON.stringify(fileUrls), 426 }); 427 428 if (response.ok) { 429 const data = await response.json(); 430 431 // Send each uploaded file URL to IRC 432 for (const file of data.files) { 433 const fileMessage = `<${username}> ${file.deployedUrl}`; 434 ircClient.say(mapping.irc_channel, fileMessage); 435 console.log(`Slack → IRC (file): ${fileMessage}`); 436 } 437 } else { 438 console.error("Failed to upload files to CDN:", response.statusText); 439 } 440 } catch (error) { 441 console.error("Error uploading files to CDN:", error); 442 } 443 } 444 } catch (error) { 445 console.error("Error handling Slack message:", error); 446 } 447}); 448 449export default { 450 port: process.env.PORT || 3000, 451 async fetch(request: Request) { 452 const url = new URL(request.url); 453 const path = url.pathname; 454 455 switch (path) { 456 case "/": 457 return new Response(`Hello World from irc-slack-bridge@${version}`); 458 case "/health": 459 return new Response("OK"); 460 case "/slack": 461 return slackApp.run(request); 462 default: 463 return new Response("404 Not Found", { status: 404 }); 464 } 465 }, 466}; 467 468console.log( 469 `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 470); 471console.log( 472 `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 473); 474console.log(`Channel mappings: ${channelMappings.getAll().length}`); 475console.log(`User mappings: ${userMappings.getAll().length}`); 476 477export { slackApp, slackClient, ircClient };