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