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// Join all mapped IRC channels on connect 88ircClient.addListener("registered", async () => { 89 console.log("Connected to IRC server"); 90 const mappings = channelMappings.getAll(); 91 for (const mapping of mappings) { 92 ircClient.join(mapping.irc_channel); 93 } 94}); 95 96ircClient.addListener("join", (channel: string, nick: string) => { 97 if (nick === process.env.IRC_NICK) { 98 console.log(`Joined IRC channel: ${channel}`); 99 } 100}); 101 102ircClient.addListener( 103 "message", 104 async (nick: string, to: string, text: string) => { 105 // Ignore messages from our own bot (with or without numbers suffix) 106 const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 107 if (botNickPattern.test(nick)) return; 108 if (nick === "****") return; 109 110 // Find Slack channel mapping for this IRC channel 111 const mapping = channelMappings.getByIrcChannel(to); 112 if (!mapping) return; 113 114 // Check if this IRC nick is mapped to a Slack user 115 const userMapping = userMappings.getByIrcNick(nick); 116 117 const displayName = `${nick} <irc>`; 118 let iconUrl: string; 119 120 if (userMapping) { 121 iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 122 } else { 123 // Use stable random avatar for unmapped users 124 iconUrl = getAvatarForNick(nick); 125 } 126 127 // Parse IRC mentions and convert to Slack mentions 128 let messageText = parseIRCFormatting(text); 129 130 // Extract image URLs from the message 131 const imagePattern = 132 /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; 133 const imageUrls = Array.from(messageText.matchAll(imagePattern)); 134 135 // Find all @mentions and nick: mentions in the IRC message 136 const atMentionPattern = /@(\w+)/g; 137 const nickMentionPattern = /(\w+):/g; 138 139 const atMentions = Array.from(messageText.matchAll(atMentionPattern)); 140 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern)); 141 142 for (const match of atMentions) { 143 const mentionedNick = match[1] as string; 144 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 145 if (mentionedUserMapping) { 146 messageText = messageText.replace( 147 match[0], 148 `<@${mentionedUserMapping.slack_user_id}>`, 149 ); 150 } 151 } 152 153 for (const match of nickMentions) { 154 const mentionedNick = match[1] as string; 155 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick); 156 if (mentionedUserMapping) { 157 messageText = messageText.replace( 158 match[0], 159 `<@${mentionedUserMapping.slack_user_id}>:`, 160 ); 161 } 162 } 163 164 try { 165 // If there are image URLs, send them as attachments 166 if (imageUrls.length > 0) { 167 const attachments = imageUrls.map((match) => ({ 168 image_url: match[0], 169 fallback: match[0], 170 })); 171 172 await slackClient.chat.postMessage({ 173 token: process.env.SLACK_BOT_TOKEN, 174 channel: mapping.slack_channel_id, 175 text: messageText, 176 username: displayName, 177 icon_url: iconUrl, 178 attachments: attachments, 179 unfurl_links: false, 180 unfurl_media: false, 181 }); 182 } else { 183 await slackClient.chat.postMessage({ 184 token: process.env.SLACK_BOT_TOKEN, 185 channel: mapping.slack_channel_id, 186 text: messageText, 187 username: displayName, 188 icon_url: iconUrl, 189 unfurl_links: false, 190 unfurl_media: false, 191 }); 192 } 193 console.log(`IRC → Slack: <${nick}> ${text}`); 194 } catch (error) { 195 console.error("Error posting to Slack:", error); 196 } 197 }, 198); 199 200ircClient.addListener("error", (error: string) => { 201 console.error("IRC error:", error); 202}); 203 204// Slack event handlers 205slackApp.event("message", async ({ payload, context }) => { 206 // Ignore bot messages and threaded messages 207 if (payload.subtype && payload.subtype !== "file_share") return; 208 if (payload.bot_id) return; 209 if (payload.user === botUserId) return; 210 if (payload.thread_ts) return; 211 212 // Find IRC channel mapping for this Slack channel 213 const mapping = channelMappings.getBySlackChannel(payload.channel); 214 if (!mapping) { 215 console.log( 216 `No IRC channel mapping found for Slack channel ${payload.channel}`, 217 ); 218 slackClient.conversations.leave({ 219 channel: payload.channel, 220 }); 221 return; 222 } 223 224 try { 225 const userInfo = await slackClient.users.info({ 226 token: process.env.SLACK_BOT_TOKEN, 227 user: payload.user, 228 }); 229 230 // Check for user mapping, otherwise use Slack name 231 const userMapping = userMappings.getBySlackUser(payload.user); 232 const username = 233 userMapping?.irc_nick || 234 userInfo.user?.real_name || 235 userInfo.user?.name || 236 "Unknown"; 237 238 // Parse Slack mentions and replace with IRC nicks or display names 239 let messageText = payload.text; 240 const mentionRegex = /<@(U[A-Z0-9]+)(\|([^>]+))?>/g; 241 const mentions = Array.from(messageText.matchAll(mentionRegex)); 242 243 for (const match of mentions) { 244 const userId = match[1]; 245 const displayName = match[3]; // The name part after | 246 247 // Check if user has a mapped IRC nick 248 const mentionedUserMapping = userMappings.getBySlackUser(userId); 249 if (mentionedUserMapping) { 250 messageText = messageText.replace(match[0], `@${mentionedUserMapping.irc_nick}`); 251 } else if (displayName) { 252 // Use the display name from the mention format <@U123|name> 253 messageText = messageText.replace(match[0], `@${displayName}`); 254 } else { 255 // Fallback to Cachet lookup 256 try { 257 const response = await fetch( 258 `https://cachet.dunkirk.sh/users/${userId}`, 259 { 260 // @ts-ignore - Bun specific option 261 tls: { rejectUnauthorized: false }, 262 }, 263 ); 264 if (response.ok) { 265 const data = (await response.json()) as CachetUser; 266 messageText = messageText.replace(match[0], `@${data.displayName}`); 267 } 268 } catch (error) { 269 console.error(`Error fetching user ${userId} from cachet:`, error); 270 } 271 } 272 } 273 274 // Parse Slack markdown formatting 275 messageText = parseSlackMarkdown(messageText); 276 277 const message = `<${username}> ${messageText}`; 278 279 ircClient.say(mapping.irc_channel, message); 280 console.log(`Slack → IRC: ${message}`); 281 282 // Handle file uploads 283 if (payload.files && payload.files.length > 0) { 284 try { 285 // Extract private file URLs 286 const fileUrls = payload.files.map((file) => file.url_private); 287 288 // Upload to Hack Club CDN 289 const response = await fetch("https://cdn.hackclub.com/api/v3/new", { 290 method: "POST", 291 headers: { 292 Authorization: `Bearer ${process.env.CDN_TOKEN}`, 293 "X-Download-Authorization": `Bearer ${process.env.SLACK_BOT_TOKEN}`, 294 "Content-Type": "application/json", 295 }, 296 body: JSON.stringify(fileUrls), 297 }); 298 299 if (response.ok) { 300 const data = await response.json(); 301 302 // Send each uploaded file URL to IRC 303 for (const file of data.files) { 304 const fileMessage = `<${username}> ${file.deployedUrl}`; 305 ircClient.say(mapping.irc_channel, fileMessage); 306 console.log(`Slack → IRC (file): ${fileMessage}`); 307 } 308 } else { 309 console.error("Failed to upload files to CDN:", response.statusText); 310 } 311 } catch (error) { 312 console.error("Error uploading files to CDN:", error); 313 } 314 } 315 } catch (error) { 316 console.error("Error handling Slack message:", error); 317 } 318}); 319 320export default { 321 port: process.env.PORT || 3000, 322 async fetch(request: Request) { 323 const url = new URL(request.url); 324 const path = url.pathname; 325 326 switch (path) { 327 case "/": 328 return new Response(`Hello World from irc-slack-bridge@${version}`); 329 case "/health": 330 return new Response("OK"); 331 case "/slack": 332 return slackApp.run(request); 333 default: 334 return new Response("404 Not Found", { status: 404 }); 335 } 336 }, 337}; 338 339console.log( 340 `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 341); 342console.log( 343 `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 344); 345console.log(`Channel mappings: ${channelMappings.getAll().length}`); 346console.log(`User mappings: ${userMappings.getAll().length}`); 347 348export { slackApp, slackClient, ircClient };