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 { getAvatarForNick } from "./lib/avatars";
6import { uploadToCDN } from "./lib/cdn";
7import { channelMappings, userMappings } from "./lib/db";
8import {
9 convertIrcMentionsToSlack,
10 convertSlackMentionsToIrc,
11} from "./lib/mentions";
12import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser";
13import {
14 cleanupOldThreads,
15 getThreadByThreadId,
16 isFirstThreadMessage,
17 updateThreadTimestamp,
18} from "./lib/threads";
19import { cleanupUserCache, getUserInfo } from "./lib/user-cache";
20
21const missingEnvVars = [];
22if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN");
23if (!process.env.SLACK_SIGNING_SECRET)
24 missingEnvVars.push("SLACK_SIGNING_SECRET");
25if (!process.env.ADMINS) missingEnvVars.push("ADMINS");
26if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK");
27
28if (missingEnvVars.length > 0) {
29 throw new Error(
30 `Missing required environment variables: ${missingEnvVars.join(", ")}`,
31 );
32}
33
34const slackApp = new SlackApp({
35 env: {
36 SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string,
37 SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string,
38 SLACK_LOGGING_LEVEL: "INFO",
39 },
40 startLazyListenerAfterAck: true,
41});
42const slackClient = slackApp.client;
43
44// Get bot user ID
45let botUserId: string | undefined;
46slackClient.auth
47 .test({
48 token: process.env.SLACK_BOT_TOKEN,
49 })
50 .then((result) => {
51 botUserId = result.user_id;
52 console.log(`Bot user ID: ${botUserId}`);
53 });
54
55// IRC client setup
56const ircClient = new irc.Client(
57 "irc.hackclub.com",
58 process.env.IRC_NICK || "slackbridge",
59 {
60 port: 6667,
61 autoRejoin: true,
62 autoConnect: true,
63 channels: [],
64 secure: false,
65 userName: process.env.IRC_NICK,
66 realName: "Slack IRC Bridge",
67 },
68);
69
70// Clean up IRC connection on hot reload or exit
71process.on("beforeExit", () => {
72 ircClient.disconnect("Reloading", () => {
73 console.log("IRC client disconnected");
74 });
75});
76
77// Register slash commands
78registerCommands();
79
80// Periodic cleanup of old thread timestamps (every hour)
81setInterval(
82 () => {
83 cleanupOldThreads();
84 cleanupUserCache();
85 },
86 60 * 60 * 1000,
87);
88
89// Track NickServ authentication state
90let nickServAuthAttempted = false;
91let _isAuthenticated = false;
92
93// Join all mapped IRC channels on connect
94ircClient.addListener("registered", async () => {
95 console.log("Connected to IRC server");
96
97 // Authenticate with NickServ if password is provided
98 if (process.env.NICKSERV_PASSWORD && !nickServAuthAttempted) {
99 nickServAuthAttempted = true;
100 console.log("Authenticating with NickServ...");
101 ircClient.say("NickServ", `IDENTIFY ${process.env.NICKSERV_PASSWORD}`);
102 // Don't join channels yet - wait for NickServ response
103 } else if (!process.env.NICKSERV_PASSWORD) {
104 // No auth needed, join immediately
105 const mappings = channelMappings.getAll();
106 for (const mapping of mappings) {
107 ircClient.join(mapping.irc_channel);
108 }
109 }
110});
111
112ircClient.addListener("join", (channel: string, nick: string) => {
113 if (nick === process.env.IRC_NICK) {
114 console.log(`Joined IRC channel: ${channel}`);
115 }
116});
117
118// Handle NickServ notices
119ircClient.addListener(
120 "notice",
121 async (nick: string, _to: string, text: string) => {
122 if (nick !== "NickServ") return;
123
124 console.log(`NickServ: ${text}`);
125
126 // Check for successful authentication
127 if (
128 text.includes("You are now identified") ||
129 text.includes("Password accepted")
130 ) {
131 console.log("✓ Successfully authenticated with NickServ");
132 _isAuthenticated = true;
133
134 // Join channels after successful auth
135 const mappings = channelMappings.getAll();
136 for (const mapping of mappings) {
137 ircClient.join(mapping.irc_channel);
138 }
139 }
140 // Check if nick is not registered
141 else if (
142 text.includes("isn't registered") ||
143 text.includes("not registered")
144 ) {
145 console.log("Nick not registered, registering with NickServ...");
146 if (process.env.NICKSERV_PASSWORD && process.env.NICKSERV_EMAIL) {
147 ircClient.say(
148 "NickServ",
149 `REGISTER ${process.env.NICKSERV_PASSWORD} ${process.env.NICKSERV_EMAIL}`,
150 );
151 } else {
152 console.error("Cannot register: NICKSERV_EMAIL not configured");
153 }
154 }
155 // Check for failed authentication
156 else if (
157 text.includes("Invalid password") ||
158 text.includes("Access denied")
159 ) {
160 console.error("✗ NickServ authentication failed: Invalid password");
161 }
162 },
163);
164
165ircClient.addListener(
166 "message",
167 async (nick: string, to: string, text: string) => {
168 // Ignore messages from our own bot (with or without numbers suffix)
169 const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`);
170 if (botNickPattern.test(nick)) return;
171 if (nick === "****") return;
172
173 // Find Slack channel mapping for this IRC channel
174 const mapping = channelMappings.getByIrcChannel(to);
175 if (!mapping) return;
176
177 // Check if this IRC nick is mapped to a Slack user
178 const userMapping = userMappings.getByIrcNick(nick);
179
180 const displayName = `${nick} <irc>`;
181 let iconUrl: string;
182
183 if (userMapping) {
184 iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
185 } else {
186 // Use stable random avatar for unmapped users
187 iconUrl = getAvatarForNick(nick);
188 }
189
190 // Parse IRC mentions and convert to Slack mentions
191 let messageText = parseIRCFormatting(text);
192
193 // Check for @xxxxx mentions to reply to threads
194 const threadMentionPattern = /@([a-z0-9]{5})\b/i;
195 const threadMatch = messageText.match(threadMentionPattern);
196 let threadTs: string | undefined;
197
198 if (threadMatch) {
199 const threadId = threadMatch[1];
200 const threadInfo = getThreadByThreadId(threadId);
201 if (
202 threadInfo &&
203 threadInfo.slack_channel_id === mapping.slack_channel_id
204 ) {
205 threadTs = threadInfo.thread_ts;
206 // Remove the @xxxxx from the message
207 messageText = messageText.replace(threadMentionPattern, "").trim();
208 }
209 }
210
211 // Extract image URLs from the message
212 const imagePattern =
213 /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
214 const imageUrls = Array.from(messageText.matchAll(imagePattern));
215
216 messageText = convertIrcMentionsToSlack(messageText);
217
218 try {
219 // If there are image URLs, send them as attachments
220 if (imageUrls.length > 0) {
221 const attachments = imageUrls.map((match) => ({
222 image_url: match[0],
223 fallback: match[0],
224 }));
225
226 await slackClient.chat.postMessage({
227 token: process.env.SLACK_BOT_TOKEN,
228 channel: mapping.slack_channel_id,
229 text: messageText,
230 username: displayName,
231 icon_url: iconUrl,
232 attachments: attachments,
233 unfurl_links: false,
234 unfurl_media: false,
235 thread_ts: threadTs,
236 });
237 } else {
238 await slackClient.chat.postMessage({
239 token: process.env.SLACK_BOT_TOKEN,
240 channel: mapping.slack_channel_id,
241 text: messageText,
242 username: displayName,
243 icon_url: iconUrl,
244 unfurl_links: true,
245 unfurl_media: true,
246 thread_ts: threadTs,
247 });
248 }
249 console.log(`IRC (${to}) → Slack: <${nick}> ${text}`);
250 } catch (error) {
251 console.error("Error posting to Slack:", error);
252 }
253 },
254);
255
256ircClient.addListener("error", (error: string) => {
257 console.error("IRC error:", error);
258});
259
260// Handle IRC /me actions
261ircClient.addListener(
262 "action",
263 async (nick: string, to: string, text: string) => {
264 // Ignore messages from our own bot
265 const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`);
266 if (botNickPattern.test(nick)) return;
267 if (nick === "****") return;
268
269 // Find Slack channel mapping for this IRC channel
270 const mapping = channelMappings.getByIrcChannel(to);
271 if (!mapping) return;
272
273 // Check if this IRC nick is mapped to a Slack user
274 const userMapping = userMappings.getByIrcNick(nick);
275
276 let iconUrl: string;
277 if (userMapping) {
278 iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
279 } else {
280 iconUrl = getAvatarForNick(nick);
281 }
282
283 // Parse IRC formatting and mentions
284 let messageText = parseIRCFormatting(text);
285 messageText = convertIrcMentionsToSlack(messageText);
286
287 // Format as action message with context block
288 const actionText = `${nick} ${messageText}`;
289
290 await slackClient.chat.postMessage({
291 token: process.env.SLACK_BOT_TOKEN,
292 channel: mapping.slack_channel_id,
293 text: actionText,
294 blocks: [
295 {
296 type: "context",
297 elements: [
298 {
299 type: "image",
300 image_url: iconUrl,
301 alt_text: nick,
302 },
303 {
304 type: "mrkdwn",
305 text: actionText,
306 },
307 ],
308 },
309 ],
310 });
311
312 console.log(`IRC (${to}) → Slack (action): ${actionText}`);
313 },
314);
315
316// Slack event handlers
317slackApp.event("message", async ({ payload }) => {
318 // Ignore bot messages
319 if (payload.subtype && payload.subtype !== "file_share") return;
320 if (payload.bot_id) return;
321 if (payload.user === botUserId) return;
322
323 // Find IRC channel mapping for this Slack channel
324 const mapping = channelMappings.getBySlackChannel(payload.channel);
325 if (!mapping) {
326 console.log(
327 `No IRC channel mapping found for Slack channel ${payload.channel}`,
328 );
329 slackClient.conversations.leave({
330 channel: payload.channel,
331 });
332 return;
333 }
334
335 try {
336 // Get display name from payload if available, otherwise fetch from API
337 const displayNameFromEvent =
338 (payload as any).user_profile?.display_name ||
339 (payload as any).user_profile?.real_name ||
340 (payload as any).username;
341
342 const userInfo = await getUserInfo(
343 payload.user,
344 slackClient,
345 displayNameFromEvent,
346 );
347
348 // Check for user mapping, otherwise use Slack name
349 const userMapping = userMappings.getBySlackUser(payload.user);
350 const username =
351 userMapping?.irc_nick ||
352 userInfo?.realName ||
353 userInfo?.name ||
354 "Unknown";
355
356 // Parse Slack mentions and replace with IRC nicks or display names
357 let messageText = await convertSlackMentionsToIrc(payload.text);
358
359 // Parse Slack markdown formatting
360 messageText = parseSlackMarkdown(messageText);
361
362 let threadId: string | undefined;
363
364 // Handle thread messages
365 if (payload.thread_ts) {
366 const threadTs = payload.thread_ts;
367 const isFirstReply = isFirstThreadMessage(threadTs);
368 threadId = updateThreadTimestamp(threadTs, payload.channel);
369
370 if (isFirstReply) {
371 // First reply to thread, fetch and quote the parent message
372 try {
373 const parentResult = await slackClient.conversations.history({
374 token: process.env.SLACK_BOT_TOKEN,
375 channel: payload.channel,
376 latest: threadTs,
377 inclusive: true,
378 limit: 1,
379 });
380
381 if (parentResult.messages && parentResult.messages.length > 0) {
382 const parentMessage = parentResult.messages[0];
383 let parentText = await convertSlackMentionsToIrc(
384 parentMessage.text || "",
385 );
386 parentText = parseSlackMarkdown(parentText);
387
388 // Send the quoted parent message with thread ID
389 const quotedMessage = `<${username}> @${threadId} > ${parentText}`;
390 ircClient.say(mapping.irc_channel, quotedMessage);
391 console.log(`Slack → IRC (thread quote): ${quotedMessage}`);
392 }
393 } catch (error) {
394 console.error("Error fetching parent message:", error);
395 }
396 }
397
398 // Add thread ID to message
399 if (messageText.trim()) {
400 messageText = `@${threadId} ${messageText}`;
401 }
402 }
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 const fileUrls = payload.files.map((file) => file.url_private);
415 const data = await uploadToCDN(fileUrls);
416
417 for (const file of data.files) {
418 const threadPrefix = threadId ? `@${threadId} ` : "";
419 const fileMessage = `<${username}> ${threadPrefix}${file.deployedUrl}`;
420 ircClient.say(mapping.irc_channel, fileMessage);
421 console.log(`Slack → IRC (file): ${fileMessage}`);
422 }
423 } catch (error) {
424 console.error("Error uploading files to CDN:", error);
425 }
426 }
427 } catch (error) {
428 console.error("Error handling Slack message:", error);
429 }
430});
431
432export default {
433 port: process.env.PORT || 3000,
434 async fetch(request: Request) {
435 const url = new URL(request.url);
436 const path = url.pathname;
437
438 switch (path) {
439 case "/":
440 return new Response(`Hello World from irc-slack-bridge@${version}`);
441 case "/health":
442 return new Response("OK");
443 case "/slack":
444 return slackApp.run(request);
445 default:
446 return new Response("404 Not Found", { status: 404 });
447 }
448 },
449};
450
451console.log(
452 `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`,
453);
454console.log(
455 `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`,
456);
457console.log(`Channel mappings: ${channelMappings.getAll().length}`);
458console.log(`User mappings: ${userMappings.getAll().length}`);
459
460export { slackApp, slackClient, ircClient };