this repo has no description

feat: make commands work

dunkirk.sh 9c84fca1 29902a8c

verified
+324 -147
+27 -2
slack-manifest.yaml
··· 6 6 bot_user: 7 7 display_name: IRC Bridge 8 8 always_online: true 9 + slash_commands: 10 + - command: /irc-bridge-channel 11 + url: https://casual-renewing-reptile.ngrok-free.app/slack 12 + description: Bridge this Slack channel to an IRC channel 13 + usage_hint: "#irc-channel" 14 + should_escape: true 15 + - command: /irc-unbridge-channel 16 + url: https://casual-renewing-reptile.ngrok-free.app/slack 17 + description: Remove bridge from this Slack channel 18 + should_escape: true 19 + - command: /irc-bridge-user 20 + url: https://casual-renewing-reptile.ngrok-free.app/slack 21 + description: Link your Slack account to an IRC nickname 22 + usage_hint: "irc-nick" 23 + should_escape: true 24 + - command: /irc-unbridge-user 25 + url: https://casual-renewing-reptile.ngrok-free.app/slack 26 + description: Remove your IRC nickname link 27 + should_escape: true 28 + - command: /irc-bridge-list 29 + url: https://casual-renewing-reptile.ngrok-free.app/slack 30 + description: List all channel and user bridges 31 + should_escape: true 9 32 oauth_config: 10 33 scopes: 11 34 bot: 12 35 - channels:history 13 36 - channels:read 14 - - channels:write 15 37 - channels:manage 38 + - channels:join 16 39 - chat:write 40 + - chat:write.public 17 41 - chat:write.customize 42 + - commands 18 43 - groups:read 19 44 - groups:write 20 45 - mpim:write 21 - - im:write 46 + - im:read 22 47 - users:read 23 48 settings: 24 49 event_subscriptions:
+146
src/commands.ts
··· 1 + import { channelMappings, userMappings } from "./db"; 2 + import { slackApp, ircClient } from "./index"; 3 + 4 + export function registerCommands() { 5 + // Link Slack channel to IRC channel 6 + slackApp.command("/irc-bridge-channel", async ({ payload, context }) => { 7 + const args = payload.text.trim().split(/\s+/); 8 + const ircChannel = args[0]; 9 + 10 + if (!ircChannel || !ircChannel.startsWith("#")) { 11 + return { 12 + text: "Usage: `/irc-bridge-channel #irc-channel`\nExample: `/irc-bridge-channel #lounge`", 13 + }; 14 + } 15 + 16 + const slackChannelId = payload.channel_id; 17 + 18 + try { 19 + // Create the mapping 20 + channelMappings.create(slackChannelId, ircChannel); 21 + 22 + // Join the IRC channel 23 + ircClient.join(ircChannel); 24 + 25 + // Join the Slack channel if not already in it 26 + await context.client.conversations.join({ 27 + channel: slackChannelId, 28 + }); 29 + 30 + return { 31 + text: `✅ Successfully bridged this channel to ${ircChannel}`, 32 + }; 33 + } catch (error) { 34 + console.error("Error creating channel mapping:", error); 35 + return { 36 + text: `❌ Failed to bridge channel: ${error}`, 37 + }; 38 + } 39 + }); 40 + 41 + // Unlink Slack channel from IRC 42 + slackApp.command("/irc-unbridge-channel", async ({ payload }) => { 43 + const slackChannelId = payload.channel_id; 44 + 45 + try { 46 + const mapping = channelMappings.getBySlackChannel(slackChannelId); 47 + if (!mapping) { 48 + return { 49 + text: "❌ This channel is not bridged to IRC", 50 + }; 51 + } 52 + 53 + channelMappings.delete(slackChannelId); 54 + 55 + return { 56 + text: `✅ Removed bridge to ${mapping.irc_channel}`, 57 + }; 58 + } catch (error) { 59 + console.error("Error removing channel mapping:", error); 60 + return { 61 + text: `❌ Failed to remove bridge: ${error}`, 62 + }; 63 + } 64 + }); 65 + 66 + // Link Slack user to IRC nick 67 + slackApp.command("/irc-bridge-user", async ({ payload }) => { 68 + const args = payload.text.trim().split(/\s+/); 69 + const ircNick = args[0]; 70 + 71 + if (!ircNick) { 72 + return { 73 + text: "Usage: `/irc-bridge-user <irc-nick>`\nExample: `/irc-bridge-user myircnick`", 74 + }; 75 + } 76 + 77 + const slackUserId = payload.user_id; 78 + 79 + try { 80 + userMappings.create(slackUserId, ircNick); 81 + console.log(`Created user mapping: ${slackUserId} -> ${ircNick}`); 82 + 83 + return { 84 + text: `✅ Successfully linked your account to IRC nick: ${ircNick}`, 85 + }; 86 + } catch (error) { 87 + console.error("Error creating user mapping:", error); 88 + return { 89 + text: `❌ Failed to link user: ${error}`, 90 + }; 91 + } 92 + }); 93 + 94 + // Unlink Slack user from IRC 95 + slackApp.command("/irc-unbridge-user", async ({ payload }) => { 96 + const slackUserId = payload.user_id; 97 + 98 + try { 99 + const mapping = userMappings.getBySlackUser(slackUserId); 100 + if (!mapping) { 101 + return { 102 + text: "❌ You don't have an IRC nick mapping", 103 + }; 104 + } 105 + 106 + userMappings.delete(slackUserId); 107 + 108 + return { 109 + text: `✅ Removed link to IRC nick: ${mapping.irc_nick}`, 110 + }; 111 + } catch (error) { 112 + console.error("Error removing user mapping:", error); 113 + return { 114 + text: `❌ Failed to remove link: ${error}`, 115 + }; 116 + } 117 + }); 118 + 119 + // List channel mappings 120 + slackApp.command("/irc-bridge-list", async ({ payload }) => { 121 + const channelMaps = channelMappings.getAll(); 122 + const userMaps = userMappings.getAll(); 123 + 124 + let text = "*Channel Bridges:*\n"; 125 + if (channelMaps.length === 0) { 126 + text += "None\n"; 127 + } else { 128 + for (const map of channelMaps) { 129 + text += `• <#${map.slack_channel_id}> ↔️ ${map.irc_channel}\n`; 130 + } 131 + } 132 + 133 + text += "\n*User Mappings:*\n"; 134 + if (userMaps.length === 0) { 135 + text += "None\n"; 136 + } else { 137 + for (const map of userMaps) { 138 + text += `• <@${map.slack_user_id}> ↔️ ${map.irc_nick}\n`; 139 + } 140 + } 141 + 142 + return { 143 + text, 144 + }; 145 + }); 146 + }
+151 -145
src/index.ts
··· 1 1 import * as irc from "irc"; 2 2 import { SlackApp } from "slack-edge"; 3 3 import { version } from "../package.json"; 4 + import { registerCommands } from "./commands"; 4 5 import { channelMappings, userMappings } from "./db"; 5 - import { parseSlackMarkdown, parseIRCFormatting } from "./parser"; 6 + import { parseIRCFormatting, parseSlackMarkdown } from "./parser"; 6 7 import type { CachetUser } from "./types"; 7 8 8 9 const missingEnvVars = []; 9 10 if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN"); 10 11 if (!process.env.SLACK_SIGNING_SECRET) 11 - missingEnvVars.push("SLACK_SIGNING_SECRET"); 12 + missingEnvVars.push("SLACK_SIGNING_SECRET"); 12 13 if (!process.env.ADMINS) missingEnvVars.push("ADMINS"); 13 14 if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK"); 14 15 15 16 if (missingEnvVars.length > 0) { 16 - throw new Error( 17 - `Missing required environment variables: ${missingEnvVars.join(", ")}`, 18 - ); 17 + throw new Error( 18 + `Missing required environment variables: ${missingEnvVars.join(", ")}`, 19 + ); 19 20 } 20 21 21 22 const slackApp = new SlackApp({ 22 - env: { 23 - SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string, 24 - SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string, 25 - SLACK_LOGGING_LEVEL: "INFO", 26 - }, 27 - startLazyListenerAfterAck: true, 23 + env: { 24 + SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string, 25 + SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string, 26 + SLACK_LOGGING_LEVEL: "INFO", 27 + }, 28 + startLazyListenerAfterAck: true, 28 29 }); 29 30 const slackClient = slackApp.client; 30 31 31 32 // Get bot user ID 32 33 let botUserId: string | undefined; 33 34 slackClient.auth 34 - .test({ 35 - token: process.env.SLACK_BOT_TOKEN, 36 - }) 37 - .then((result) => { 38 - botUserId = result.user_id; 39 - console.log(`Bot user ID: ${botUserId}`); 40 - }); 35 + .test({ 36 + token: process.env.SLACK_BOT_TOKEN, 37 + }) 38 + .then((result) => { 39 + botUserId = result.user_id; 40 + console.log(`Bot user ID: ${botUserId}`); 41 + }); 41 42 42 43 // IRC client setup 43 44 const ircClient = new irc.Client( 44 - "irc.hackclub.com", 45 - process.env.IRC_NICK || "slackbridge", 46 - { 47 - port: 6667, 48 - autoRejoin: true, 49 - autoConnect: true, 50 - channels: [], 51 - secure: false, 52 - userName: process.env.IRC_NICK, 53 - realName: "Slack IRC Bridge", 54 - }, 45 + "irc.hackclub.com", 46 + process.env.IRC_NICK || "slackbridge", 47 + { 48 + port: 6667, 49 + autoRejoin: true, 50 + autoConnect: true, 51 + channels: [], 52 + secure: false, 53 + userName: process.env.IRC_NICK, 54 + realName: "Slack IRC Bridge", 55 + }, 55 56 ); 56 57 57 58 // Clean up IRC connection on hot reload or exit 58 59 process.on("beforeExit", () => { 59 - ircClient.disconnect("Reloading", () => { 60 - console.log("IRC client disconnected"); 61 - }); 60 + ircClient.disconnect("Reloading", () => { 61 + console.log("IRC client disconnected"); 62 + }); 62 63 }); 63 64 65 + // Register slash commands 66 + registerCommands(); 67 + 64 68 // Join all mapped IRC channels on connect 65 69 ircClient.addListener("registered", async () => { 66 - console.log("Connected to IRC server"); 67 - const mappings = channelMappings.getAll(); 68 - for (const mapping of mappings) { 69 - ircClient.join(mapping.irc_channel); 70 - } 70 + console.log("Connected to IRC server"); 71 + const mappings = channelMappings.getAll(); 72 + for (const mapping of mappings) { 73 + ircClient.join(mapping.irc_channel); 74 + } 71 75 }); 72 76 73 77 ircClient.addListener("join", (channel: string, nick: string) => { 74 - if (nick === process.env.IRC_NICK) { 75 - console.log(`Joined IRC channel: ${channel}`); 76 - } 78 + if (nick === process.env.IRC_NICK) { 79 + console.log(`Joined IRC channel: ${channel}`); 80 + } 77 81 }); 78 82 79 83 ircClient.addListener( 80 - "message", 81 - async (nick: string, to: string, text: string) => { 82 - // Ignore messages from our own bot (with or without numbers suffix) 83 - const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 84 - if (botNickPattern.test(nick)) return; 85 - if (nick === "****") return; 84 + "message", 85 + async (nick: string, to: string, text: string) => { 86 + // Ignore messages from our own bot (with or without numbers suffix) 87 + const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`); 88 + if (botNickPattern.test(nick)) return; 89 + if (nick === "****") return; 86 90 87 - // Find Slack channel mapping for this IRC channel 88 - const mapping = channelMappings.getByIrcChannel(to); 89 - if (!mapping) return; 91 + // Find Slack channel mapping for this IRC channel 92 + const mapping = channelMappings.getByIrcChannel(to); 93 + if (!mapping) return; 90 94 91 - // Check if this IRC nick is mapped to a Slack user 92 - const userMapping = userMappings.getByIrcNick(nick); 95 + // Check if this IRC nick is mapped to a Slack user 96 + const userMapping = userMappings.getByIrcNick(nick); 93 97 94 - const displayName = `${nick} <irc>`; 95 - let iconUrl: string | undefined; 98 + const displayName = `${nick} <irc>`; 99 + let iconUrl: string | undefined; 96 100 97 - if (userMapping) { 98 - try { 99 - iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 100 - } catch (error) { 101 - console.error("Error fetching user info:", error); 102 - } 103 - } 101 + if (userMapping) { 102 + try { 103 + iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`; 104 + } catch (error) { 105 + console.error("Error fetching user info:", error); 106 + } 107 + } 104 108 105 - try { 106 - await slackClient.chat.postMessage({ 107 - token: process.env.SLACK_BOT_TOKEN, 108 - channel: mapping.slack_channel_id, 109 - text: parseIRCFormatting(text), 110 - username: displayName, 111 - icon_url: iconUrl, 112 - unfurl_links: false, 113 - unfurl_media: false, 114 - }); 115 - console.log(`IRC → Slack: <${nick}> ${text}`); 116 - } catch (error) { 117 - console.error("Error posting to Slack:", error); 118 - } 119 - }, 109 + try { 110 + await slackClient.chat.postMessage({ 111 + token: process.env.SLACK_BOT_TOKEN, 112 + channel: mapping.slack_channel_id, 113 + text: parseIRCFormatting(text), 114 + username: displayName, 115 + icon_url: iconUrl, 116 + unfurl_links: false, 117 + unfurl_media: false, 118 + }); 119 + console.log(`IRC → Slack: <${nick}> ${text}`); 120 + } catch (error) { 121 + console.error("Error posting to Slack:", error); 122 + } 123 + }, 120 124 ); 121 125 122 126 ircClient.addListener("error", (error: string) => { 123 - console.error("IRC error:", error); 127 + console.error("IRC error:", error); 124 128 }); 125 129 126 130 // Slack event handlers 127 131 slackApp.event("message", async ({ payload }) => { 128 - if (payload.subtype) return; 129 - if (payload.bot_id) return; 130 - if (payload.user === botUserId) return; 132 + if (payload.subtype) return; 133 + if (payload.bot_id) return; 134 + if (payload.user === botUserId) return; 131 135 132 - // Find IRC channel mapping for this Slack channel 133 - const mapping = channelMappings.getBySlackChannel(payload.channel); 134 - if (!mapping) { 135 - console.log( 136 - `No IRC channel mapping found for Slack channel ${payload.channel}`, 137 - ); 138 - slackClient.conversations.leave({ 139 - channel: payload.channel, 140 - }); 141 - return; 142 - } 136 + // Find IRC channel mapping for this Slack channel 137 + const mapping = channelMappings.getBySlackChannel(payload.channel); 138 + if (!mapping) { 139 + console.log( 140 + `No IRC channel mapping found for Slack channel ${payload.channel}`, 141 + ); 142 + slackClient.conversations.leave({ 143 + channel: payload.channel, 144 + }); 145 + return; 146 + } 143 147 144 - try { 145 - const userInfo = await slackClient.users.info({ 146 - token: process.env.SLACK_BOT_TOKEN, 147 - user: payload.user, 148 - }); 148 + try { 149 + const userInfo = await slackClient.users.info({ 150 + token: process.env.SLACK_BOT_TOKEN, 151 + user: payload.user, 152 + }); 149 153 150 - // Check for user mapping, otherwise use Slack name 151 - const userMapping = userMappings.getBySlackUser(payload.user); 152 - const username = 153 - userMapping?.irc_nick || 154 - userInfo.user?.real_name || 155 - userInfo.user?.name || 156 - "Unknown"; 154 + // Check for user mapping, otherwise use Slack name 155 + const userMapping = userMappings.getBySlackUser(payload.user); 156 + const username = 157 + userMapping?.irc_nick || 158 + userInfo.user?.real_name || 159 + userInfo.user?.name || 160 + "Unknown"; 157 161 158 - // Parse Slack mentions and replace with display names 159 - let messageText = payload.text; 160 - const mentionRegex = /<@(U[A-Z0-9]+)>/g; 161 - const mentions = Array.from(messageText.matchAll(mentionRegex)); 162 + // Parse Slack mentions and replace with display names 163 + let messageText = payload.text; 164 + const mentionRegex = /<@(U[A-Z0-9]+)>/g; 165 + const mentions = Array.from(messageText.matchAll(mentionRegex)); 162 166 163 - for (const match of mentions) { 164 - const userId = match[1]; 165 - try { 166 - const response = await fetch( 167 - `https://cachet.dunkirk.sh/users/${userId}`, 168 - ); 169 - if (response.ok) { 170 - const data = await response.json() as CachetUser; 171 - messageText = messageText.replace(match[0], `@${data.displayName}`); 172 - } 173 - } catch (error) { 174 - console.error(`Error fetching user ${userId} from cachet:`, error); 175 - } 176 - } 167 + for (const match of mentions) { 168 + const userId = match[1]; 169 + try { 170 + const response = await fetch( 171 + `https://cachet.dunkirk.sh/users/${userId}`, 172 + ); 173 + if (response.ok) { 174 + const data = (await response.json()) as CachetUser; 175 + messageText = messageText.replace(match[0], `@${data.displayName}`); 176 + } 177 + } catch (error) { 178 + console.error(`Error fetching user ${userId} from cachet:`, error); 179 + } 180 + } 177 181 178 - // Parse Slack markdown formatting 179 - messageText = parseSlackMarkdown(messageText); 182 + // Parse Slack markdown formatting 183 + messageText = parseSlackMarkdown(messageText); 180 184 181 - const message = `<${username}> ${messageText}`; 185 + const message = `<${username}> ${messageText}`; 182 186 183 - ircClient.say(mapping.irc_channel, message); 184 - console.log(`Slack → IRC: ${message}`); 185 - } catch (error) { 186 - console.error("Error handling Slack message:", error); 187 - } 187 + ircClient.say(mapping.irc_channel, message); 188 + console.log(`Slack → IRC: ${message}`); 189 + } catch (error) { 190 + console.error("Error handling Slack message:", error); 191 + } 188 192 }); 189 193 190 194 export default { 191 - port: process.env.PORT || 3000, 192 - async fetch(request: Request) { 193 - const url = new URL(request.url); 194 - const path = url.pathname; 195 + port: process.env.PORT || 3000, 196 + async fetch(request: Request) { 197 + const url = new URL(request.url); 198 + const path = url.pathname; 195 199 196 - switch (path) { 197 - case "/": 198 - return new Response(`Hello World from irc-slack-bridge@${version}`); 199 - case "/health": 200 - return new Response("OK"); 201 - case "/slack": 202 - return slackApp.run(request); 203 - default: 204 - return new Response("404 Not Found", { status: 404 }); 205 - } 206 - }, 200 + switch (path) { 201 + case "/": 202 + return new Response(`Hello World from irc-slack-bridge@${version}`); 203 + case "/health": 204 + return new Response("OK"); 205 + case "/slack": 206 + return slackApp.run(request); 207 + default: 208 + return new Response("404 Not Found", { status: 404 }); 209 + } 210 + }, 207 211 }; 208 212 209 213 console.log( 210 - `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 214 + `🚀 Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`, 211 215 ); 212 216 console.log( 213 - `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 217 + `Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`, 214 218 ); 215 219 console.log(`Channel mappings: ${channelMappings.getAll().length}`); 216 220 console.log(`User mappings: ${userMappings.getAll().length}`); 221 + 222 + export { slackApp, slackClient, ircClient };