···3131SLACK_BOT_TOKEN=xoxb-your-bot-token-here
3232SLACK_SIGNING_SECRET=your-signing-secret-here
33333434+# Slack workspace URL (for admin API calls)
3535+SLACK_API_URL=https://hackclub.enterprise.slack.com
3636+3737+# Optional: For channel manager permission checks
3838+SLACK_USER_COOKIE=your-slack-cookie-here
3939+SLACK_USER_TOKEN=your-user-token-here
4040+3441# IRC Configuration
3542IRC_NICK=slackbridge
36433744# Admin users (comma-separated Slack user IDs)
3838-ADMINS=U1234567890
4545+ADMINS=U1234567890,U0987654321
39464047# Hack Club CDN Token (for file uploads)
4148CDN_TOKEN=your-cdn-token-here
42494350# Server Configuration (optional)
4451PORT=3000
5252+5353+# Note: Channel and user mappings are now stored in the SQLite database (bridge.db)
5454+# Use the API or database tools to manage mappings
4555```
46564757See `.env.example` for a template.
+23-1
src/commands.ts
···11import type { AnyMessageBlock, Block, BlockElement } from "slack-edge";
22import { channelMappings, userMappings } from "./db";
33import { slackApp, ircClient } from "./index";
44+import { canManageChannel } from "./permissions";
4556export function registerCommands() {
67 // Link Slack channel to IRC channel
···5960 const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value;
6061 // @ts-expect-error
6162 const slackChannelId = payload.actions?.[0]?.value;
6363+ const userId = payload.user?.id;
6464+6265 if (!context.respond) {
6366 return;
6467 }
···7275 return;
7376 }
74777878+ // Check if user has permission to manage this channel
7979+ if (!userId || !(await canManageChannel(userId, slackChannelId))) {
8080+ context.respond({
8181+ response_type: "ephemeral",
8282+ text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
8383+ replace_original: true,
8484+ });
8585+ return;
8686+ }
8787+7588 try {
7689 channelMappings.create(slackChannelId, ircChannel);
7790 ircClient.join(ircChannel);
···102115 // Unlink Slack channel from IRC
103116 slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => {
104117 const slackChannelId = payload.channel_id;
118118+ const userId = payload.user_id;
105119 const mapping = channelMappings.getBySlackChannel(slackChannelId);
106120107121 if (!mapping) {
···112126 return;
113127 }
114128129129+ // Check if user has permission to manage this channel
130130+ if (!(await canManageChannel(userId, slackChannelId))) {
131131+ context.respond({
132132+ response_type: "ephemeral",
133133+ text: "❌ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
134134+ });
135135+ return;
136136+ }
137137+115138 context.respond({
116139 response_type: "ephemeral",
117140 text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?",
···147170 ],
148171 },
149172 ],
150150- replace_original: true,
151173 });
152174 });
153175
+21-10
src/index.ts
···1919function getAvatarForNick(nick: string): string {
2020 let hash = 0;
2121 for (let i = 0; i < nick.length; i++) {
2222- hash = ((hash << 5) - hash) + nick.charCodeAt(i);
2222+ hash = (hash << 5) - hash + nick.charCodeAt(i);
2323 hash = hash & hash; // Convert to 32bit integer
2424 }
2525 return DEFAULT_AVATARS[Math.abs(hash) % DEFAULT_AVATARS.length];
···126126127127 // Parse IRC mentions and convert to Slack mentions
128128 let messageText = parseIRCFormatting(text);
129129-129129+130130 // Extract image URLs from the message
131131- const imagePattern = /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
131131+ const imagePattern =
132132+ /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
132133 const imageUrls = Array.from(messageText.matchAll(imagePattern));
133133-134134+134135 // Find all @mentions and nick: mentions in the IRC message
135136 const atMentionPattern = /@(\w+)/g;
136137 const nickMentionPattern = /(\w+):/g;
137137-138138+138139 const atMentions = Array.from(messageText.matchAll(atMentionPattern));
139140 const nickMentions = Array.from(messageText.matchAll(nickMentionPattern));
140140-141141+141142 for (const match of atMentions) {
142143 const mentionedNick = match[1] as string;
143144 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
144145 if (mentionedUserMapping) {
145145- messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>`);
146146+ messageText = messageText.replace(
147147+ match[0],
148148+ `<@${mentionedUserMapping.slack_user_id}>`,
149149+ );
146150 }
147151 }
148148-152152+149153 for (const match of nickMentions) {
150154 const mentionedNick = match[1] as string;
151155 const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
152156 if (mentionedUserMapping) {
153153- messageText = messageText.replace(match[0], `<@${mentionedUserMapping.slack_user_id}>:`);
157157+ messageText = messageText.replace(
158158+ match[0],
159159+ `<@${mentionedUserMapping.slack_user_id}>:`,
160160+ );
154161 }
155162 }
156163···238245 try {
239246 const response = await fetch(
240247 `https://cachet.dunkirk.sh/users/${userId}`,
248248+ {
249249+ // @ts-ignore - Bun specific option
250250+ tls: { rejectUnauthorized: false },
251251+ },
241252 );
242253 if (response.ok) {
243254 const data = (await response.json()) as CachetUser;
···275286276287 if (response.ok) {
277288 const data = await response.json();
278278-289289+279290 // Send each uploaded file URL to IRC
280291 for (const file of data.files) {
281292 const fileMessage = `<${username}> ${file.deployedUrl}`;
+71
src/permissions.ts
···11+/**
22+ * Check if a user has permission to manage a Slack channel
33+ * Returns true if the user is:
44+ * - A global admin (in ADMINS env var)
55+ * - The channel creator
66+ * - A channel manager
77+ */
88+export async function canManageChannel(
99+ userId: string,
1010+ channelId: string,
1111+): Promise<boolean> {
1212+ // Check if user is a global admin
1313+ const admins = process.env.ADMINS?.split(",").map((id) => id.trim()) || [];
1414+ if (admins.includes(userId)) {
1515+ return true;
1616+ }
1717+1818+ try {
1919+ // Check if user is channel creator
2020+ const channelInfo = await fetch(
2121+ "https://slack.com/api/conversations.info",
2222+ {
2323+ method: "POST",
2424+ headers: {
2525+ "Content-Type": "application/json",
2626+ Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
2727+ },
2828+ body: JSON.stringify({ channel: channelId }),
2929+ },
3030+ ).then((res) => res.json());
3131+3232+ if (channelInfo.ok && channelInfo.channel?.creator === userId) {
3333+ return true;
3434+ }
3535+3636+ // Check if user is a channel manager
3737+ if (
3838+ process.env.SLACK_USER_COOKIE &&
3939+ process.env.SLACK_USER_TOKEN &&
4040+ process.env.SLACK_API_URL
4141+ ) {
4242+ const formdata = new FormData();
4343+ formdata.append("token", process.env.SLACK_USER_TOKEN);
4444+ formdata.append("entity_id", channelId);
4545+4646+ const response = await fetch(
4747+ `${process.env.SLACK_API_URL}/api/admin.roles.entity.listAssignments`,
4848+ {
4949+ method: "POST",
5050+ headers: {
5151+ Cookie: process.env.SLACK_USER_COOKIE,
5252+ },
5353+ body: formdata,
5454+ },
5555+ );
5656+5757+ const json = await response.json();
5858+5959+ if (json.ok) {
6060+ const managers = json.role_assignments?.[0]?.users || [];
6161+ if (managers.includes(userId)) {
6262+ return true;
6363+ }
6464+ }
6565+ }
6666+ } catch (error) {
6767+ console.error("Error checking channel permissions:", error);
6868+ }
6969+7070+ return false;
7171+}