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 };