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// Slack event handlers 251slackApp.event("message", async ({ payload, context }) => { 252 // Ignore bot messages and threaded messages 253 if (payload.subtype && payload.subtype !== "file_share") return; 254 if (payload.bot_id) return; 255 if (payload.user === botUserId) return; 256 if (payload.thread_ts) return; 257 258 // Find IRC channel mapping for this Slack channel 259 const mapping = channelMappings.getBySlackChannel(payload.channel); 260 if (!mapping) { 261 console.log( 262 `No IRC channel mapping found for Slack channel ${payload.channel}`, 263 ); 264 slackClient.conversations.leave({ 265 channel: payload.channel, 266 }); 267 return; 268 } 269 270 try { 271 const userInfo = await slackClient.users.info({ 272 token: process.env.SLACK_BOT_TOKEN, 273 user: payload.user, 274 }); 275 276 // Check for user mapping, otherwise use Slack name 277 const userMapping = userMappings.getBySlackUser(payload.user); 278 const username = 279 userMapping?.irc_nick || 280 userInfo.user?.real_name || 281 userInfo.user?.name || 282 "Unknown"; 283 284 // Parse Slack mentions and replace with IRC nicks or display names 285 let messageText = payload.text; 286 const mentionRegex = /<@(U[A-Z0-9]+)(\|([^>]+))?>/g; 287 const mentions = Array.from(messageText.matchAll(mentionRegex)); 288 289 for (const match of mentions) { 290 const userId = match[1]; 291 const displayName = match[3]; // The name part after | 292 293 // Check if user has a mapped IRC nick 294 const mentionedUserMapping = userMappings.getBySlackUser(userId); 295 if (mentionedUserMapping) { 296 messageText = messageText.replace(match[0], `@${mentionedUserMapping.irc_nick}`); 297 } else if (displayName) { 298 // Use the display name from the mention format <@U123|name> 299 messageText = messageText.replace(match[0], `@${displayName}`); 300 } else { 301 // Fallback to Cachet lookup 302 try { 303 const response = await fetch( 304 `https://cachet.dunkirk.sh/users/${userId}`, 305 { 306 // @ts-ignore - Bun specific option 307 tls: { rejectUnauthorized: false }, 308 }, 309 ); 310 if (response.ok) { 311 const data = (await response.json()) as CachetUser; 312 messageText = messageText.replace(match[0], `@${data.displayName}`); 313 } 314 } catch (error) { 315 console.error(`Error fetching user ${userId} from cachet:`, error); 316 } 317 } 318 } 319 320 // Parse Slack markdown formatting 321 messageText = parseSlackMarkdown(messageText); 322 323 const message = `<${username}> ${messageText}`; 324 325 ircClient.say(mapping.irc_channel, message); 326 console.log(`Slack → IRC: ${message}`); 327 328 // Handle file uploads 329 if (payload.files && payload.files.length > 0) { 330 try { 331 // Extract private file URLs 332 const fileUrls = payload.files.map((file) => file.url_private); 333 334 // Upload to Hack Club CDN 335 const response = await fetch("https://cdn.hackclub.com/api/v3/new", { 336 method: "POST", 337 headers: { 338 Authorization: `Bearer ${process.env.CDN_TOKEN}`, 339 "X-Download-Authorization": `Bearer ${process.env.SLACK_BOT_TOKEN}`, 340 "Content-Type": "application/json", 341 }, 342 body: JSON.stringify(fileUrls), 343 }); 344 345 if (response.ok) { 346 const data = await response.json(); 347 348 // Send each uploaded file URL to IRC 349 for (const file of data.files) { 350 const fileMessage = `<${username}> ${file.deployedUrl}`; 351 ircClient.say(mapping.irc_channel, fileMessage); 352 console.log(`Slack → IRC (file): ${fileMessage}`); 353 } 354 } else { 355 console.error("Failed to upload files to CDN:", response.statusText); 356 } 357 } catch (error) { 358 console.error("Error uploading files to CDN:", error); 359 } 360 } 361 } catch (error) { 362 console.error("Error handling Slack message:", error); 363 } 364}); 365 366export default { 367 port: process.env.PORT || 3000, 368 async fetch(request: Request) { 369 const url = new URL(request.url); 370 const path = url.pathname; 371 372 switch (path) { 373 case "/": 374 return new Response(`Hello World from irc-slack-bridge@${version}`); 375 case "/health": 376 return new Response("OK"); 377 case "/slack": 378 return slackApp.run(request); 379 default: 380 return new Response("404 Not Found", { status: 404 }); 381 } 382 }, 383}; 384 385console.log( 386 `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 387); 388console.log( 389 `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 390); 391console.log(`Channel mappings: ${channelMappings.getAll().length}`); 392console.log(`User mappings: ${userMappings.getAll().length}`); 393 394export { slackApp, slackClient, ircClient };