···31SLACK_BOT_TOKEN=xoxb-your-bot-token-here
32SLACK_SIGNING_SECRET=your-signing-secret-here
33000000034# IRC Configuration
35IRC_NICK=slackbridge
3637# Admin users (comma-separated Slack user IDs)
38-ADMINS=U1234567890
3940# Hack Club CDN Token (for file uploads)
41CDN_TOKEN=your-cdn-token-here
4243# Server Configuration (optional)
44PORT=3000
00045```
4647See `.env.example` for a template.
···31SLACK_BOT_TOKEN=xoxb-your-bot-token-here
32SLACK_SIGNING_SECRET=your-signing-secret-here
3334+# Slack workspace URL (for admin API calls)
35+SLACK_API_URL=https://hackclub.enterprise.slack.com
36+37+# Optional: For channel manager permission checks
38+SLACK_USER_COOKIE=your-slack-cookie-here
39+SLACK_USER_TOKEN=your-user-token-here
40+41# IRC Configuration
42IRC_NICK=slackbridge
4344# Admin users (comma-separated Slack user IDs)
45+ADMINS=U1234567890,U0987654321
4647# Hack Club CDN Token (for file uploads)
48CDN_TOKEN=your-cdn-token-here
4950# Server Configuration (optional)
51PORT=3000
52+53+# Note: Channel and user mappings are now stored in the SQLite database (bridge.db)
54+# Use the API or database tools to manage mappings
55```
5657See `.env.example` for a template.
+23-1
src/commands.ts
···1import type { AnyMessageBlock, Block, BlockElement } from "slack-edge";
2import { channelMappings, userMappings } from "./db";
3import { slackApp, ircClient } from "./index";
045export function registerCommands() {
6 // Link Slack channel to IRC channel
···59 const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value;
60 // @ts-expect-error
61 const slackChannelId = payload.actions?.[0]?.value;
0062 if (!context.respond) {
63 return;
64 }
···72 return;
73 }
74000000000075 try {
76 channelMappings.create(slackChannelId, ircChannel);
77 ircClient.join(ircChannel);
···102 // Unlink Slack channel from IRC
103 slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => {
104 const slackChannelId = payload.channel_id;
0105 const mapping = channelMappings.getBySlackChannel(slackChannelId);
106107 if (!mapping) {
···112 return;
113 }
114000000000115 context.respond({
116 response_type: "ephemeral",
117 text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?",
···147 ],
148 },
149 ],
150- replace_original: true,
151 });
152 });
153
···1import type { AnyMessageBlock, Block, BlockElement } from "slack-edge";
2import { channelMappings, userMappings } from "./db";
3import { slackApp, ircClient } from "./index";
4+import { canManageChannel } from "./permissions";
56export function registerCommands() {
7 // Link Slack channel to IRC channel
···60 const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value;
61 // @ts-expect-error
62 const slackChannelId = payload.actions?.[0]?.value;
63+ const userId = payload.user?.id;
64+65 if (!context.respond) {
66 return;
67 }
···75 return;
76 }
7778+ // Check if user has permission to manage this channel
79+ if (!userId || !(await canManageChannel(userId, slackChannelId))) {
80+ context.respond({
81+ response_type: "ephemeral",
82+ text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
83+ replace_original: true,
84+ });
85+ return;
86+ }
87+88 try {
89 channelMappings.create(slackChannelId, ircChannel);
90 ircClient.join(ircChannel);
···115 // Unlink Slack channel from IRC
116 slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => {
117 const slackChannelId = payload.channel_id;
118+ const userId = payload.user_id;
119 const mapping = channelMappings.getBySlackChannel(slackChannelId);
120121 if (!mapping) {
···126 return;
127 }
128129+ // Check if user has permission to manage this channel
130+ if (!(await canManageChannel(userId, slackChannelId))) {
131+ context.respond({
132+ response_type: "ephemeral",
133+ text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
134+ });
135+ return;
136+ }
137+138 context.respond({
139 response_type: "ephemeral",
140 text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?",
···170 ],
171 },
172 ],
0173 });
174 });
175
+21-10
src/index.ts
···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];
···126127 // Parse IRC mentions and convert to Slack mentions
128 let messageText = parseIRCFormatting(text);
129-130 // Extract image URLs from the message
131- const imagePattern = /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
0132 const imageUrls = Array.from(messageText.matchAll(imagePattern));
133-134 // Find all @mentions and nick: mentions in the IRC message
135 const atMentionPattern = /@(\w+)/g;
136 const nickMentionPattern = /(\w+):/g;
137-138 const atMentions = Array.from(messageText.matchAll(atMentionPattern));
139 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern));
140-141 for (const match of atMentions) {
142 const mentionedNick = match[1] as string;
143 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
144 if (mentionedUserMapping) {
145- messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>`);
000146 }
147 }
148-149 for (const match of nickMentions) {
150 const mentionedNick = match[1] as string;
151 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
152 if (mentionedUserMapping) {
153- messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>:`);
000154 }
155 }
156···238 try {
239 const response = await fetch(
240 `https://cachet.dunkirk.sh/users/${userId}`,
0000241 );
242 if (response.ok) {
243 const data = (await response.json()) as CachetUser;
···275276 if (response.ok) {
277 const data = await response.json();
278-279 // Send each uploaded file URL to IRC
280 for (const file of data.files) {
281 const fileMessage = `<${username}> ${file.deployedUrl}`;
···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];
···126127 // 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···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;
···286287 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}`;