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