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 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 try { 246 const response = await fetch( 247 `https://cachet.dunkirk.sh/users/${userId}`, 248 { 249 // @ts-ignore - Bun specific option 250 tls: { rejectUnauthorized: false }, 251 }, 252 ); 253 if (response.ok) { 254 const data = (await response.json()) as CachetUser; 255 messageText = messageText.replace(match[0], `@${data.displayName}`); 256 } 257 } catch (error) { 258 console.error(`Error fetching user ${userId} from cachet:`, error); 259 } 260 } 261 262 // Parse Slack markdown formatting 263 messageText = parseSlackMarkdown(messageText); 264 265 const message = `<${username}> ${messageText}`; 266 267 ircClient.say(mapping.irc_channel, message); 268 console.log(`Slack → IRC: ${message}`); 269 270 // Handle file uploads 271 if (payload.files && payload.files.length > 0) { 272 try { 273 // Extract private file URLs 274 const fileUrls = payload.files.map((file) => file.url_private); 275 276 // Upload to Hack Club CDN 277 const response = await fetch("https://cdn.hackclub.com/api/v3/new", { 278 method: "POST", 279 headers: { 280 Authorization: `Bearer ${process.env.CDN_TOKEN}`, 281 "X-Download-Authorization": `Bearer ${process.env.SLACK_BOT_TOKEN}`, 282 "Content-Type": "application/json", 283 }, 284 body: JSON.stringify(fileUrls), 285 }); 286 287 if (response.ok) { 288 const data = await response.json(); 289 290 // Send each uploaded file URL to IRC 291 for (const file of data.files) { 292 const fileMessage = `<${username}> ${file.deployedUrl}`; 293 ircClient.say(mapping.irc_channel, fileMessage); 294 console.log(`Slack → IRC (file): ${fileMessage}`); 295 } 296 } else { 297 console.error("Failed to upload files to CDN:", response.statusText); 298 } 299 } catch (error) { 300 console.error("Error uploading files to CDN:", error); 301 } 302 } 303 } catch (error) { 304 console.error("Error handling Slack message:", error); 305 } 306}); 307 308export default { 309 port: process.env.PORT || 3000, 310 async fetch(request: Request) { 311 const url = new URL(request.url); 312 const path = url.pathname; 313 314 switch (path) { 315 case "/": 316 return new Response(`Hello World from irc-slack-bridge@${version}`); 317 case "/health": 318 return new Response("OK"); 319 case "/slack": 320 return slackApp.run(request); 321 default: 322 return new Response("404 Not Found", { status: 404 }); 323 } 324 }, 325}; 326 327console.log( 328 `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 329); 330console.log( 331 `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 332); 333console.log(`Channel mappings: ${channelMappings.getAll().length}`); 334console.log(`User mappings: ${userMappings.getAll().length}`); 335 336export { slackApp, slackClient, ircClient };