this repo has no description

feat: add thead replies

dunkirk.sh bf2dbec6 7515505d

verified
+186 -3
+15
README.md
··· 101 - IRC mentions (`@nick` or `nick:`) are converted to Slack mentions for mapped users 102 - IRC formatting codes are converted to Slack markdown 103 - IRC `/me` actions are displayed in a context block with the user's avatar 104 - **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels 105 - Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format 106 - Slack markdown is converted to IRC formatting codes 107 - File attachments are uploaded to Hack Club CDN and URLs are shared 108 - **User mappings** allow custom IRC nicknames for specific Slack users and enable proper mentions both ways 109 110 The bridge ignores its own messages and bot messages to prevent loops. 111
··· 101 - IRC mentions (`@nick` or `nick:`) are converted to Slack mentions for mapped users 102 - IRC formatting codes are converted to Slack markdown 103 - IRC `/me` actions are displayed in a context block with the user's avatar 104 + - Thread replies: Use `@xxxxx` (5-char thread ID) to reply to a Slack thread from IRC 105 - **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels 106 - Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format 107 - Slack markdown is converted to IRC formatting codes 108 - File attachments are uploaded to Hack Club CDN and URLs are shared 109 + - Thread messages are prefixed with `@xxxxx` (5-char thread ID) to show they're part of a thread 110 + - First reply in a thread includes a quote of the parent message 111 - **User mappings** allow custom IRC nicknames for specific Slack users and enable proper mentions both ways 112 + 113 + #### Thread Support 114 + 115 + The bridge supports Slack threads with a simple IRC-friendly syntax: 116 + 117 + - **Slack → IRC**: Thread messages appear with a `@xxxxx` prefix (5-character thread ID) 118 + - First reply in a thread includes a quote: `<user> @xxxxx > original message` 119 + - Subsequent replies: `<user> @xxxxx message text` 120 + - **IRC → Slack**: Reply to a thread by including the thread ID in your message 121 + - Example: `@abc12 this is my reply` 122 + - The bridge removes the `@xxxxx` prefix and sends your message to the correct thread 123 + - Thread IDs are unique per thread and persist across restarts 124 125 The bridge ignores its own messages and bot messages to prevent loops. 126
+73 -3
src/index.ts
··· 10 convertSlackMentionsToIrc, 11 } from "./lib/mentions"; 12 import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser"; 13 14 const missingEnvVars = []; 15 if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN"); ··· 69 70 // Register slash commands 71 registerCommands(); 72 73 // Track NickServ authentication state 74 let nickServAuthAttempted = false; ··· 174 // Parse IRC mentions and convert to Slack mentions 175 let messageText = parseIRCFormatting(text); 176 177 // Extract image URLs from the message 178 const imagePattern = 179 /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; ··· 198 attachments: attachments, 199 unfurl_links: false, 200 unfurl_media: false, 201 }); 202 } else { 203 await slackClient.chat.postMessage({ ··· 208 icon_url: iconUrl, 209 unfurl_links: true, 210 unfurl_media: true, 211 }); 212 } 213 console.log(`IRC (${to}) → Slack: <${nick}> ${text}`); ··· 279 280 // Slack event handlers 281 slackApp.event("message", async ({ payload }) => { 282 - // Ignore bot messages and threaded messages 283 if (payload.subtype && payload.subtype !== "file_share") return; 284 if (payload.bot_id) return; 285 if (payload.user === botUserId) return; 286 - if (payload.thread_ts) return; 287 288 // Find IRC channel mapping for this Slack channel 289 const mapping = channelMappings.getBySlackChannel(payload.channel); ··· 317 // Parse Slack markdown formatting 318 messageText = parseSlackMarkdown(messageText); 319 320 // Send message only if there's text content 321 if (messageText.trim()) { 322 const message = `<${username}> ${messageText}`; ··· 331 const data = await uploadToCDN(fileUrls); 332 333 for (const file of data.files) { 334 - const fileMessage = `<${username}> ${file.deployedUrl}`; 335 ircClient.say(mapping.irc_channel, fileMessage); 336 console.log(`Slack → IRC (file): ${fileMessage}`); 337 }
··· 10 convertSlackMentionsToIrc, 11 } from "./lib/mentions"; 12 import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser"; 13 + import { 14 + cleanupOldThreads, 15 + getThreadByThreadId, 16 + isFirstThreadMessage, 17 + updateThreadTimestamp, 18 + } from "./lib/threads"; 19 20 const missingEnvVars = []; 21 if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN"); ··· 75 76 // Register slash commands 77 registerCommands(); 78 + 79 + // Periodic cleanup of old thread timestamps (every hour) 80 + setInterval(() => { 81 + cleanupOldThreads(); 82 + }, 60 * 60 * 1000); 83 84 // Track NickServ authentication state 85 let nickServAuthAttempted = false; ··· 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; ··· 224 attachments: attachments, 225 unfurl_links: false, 226 unfurl_media: false, 227 + thread_ts: threadTs, 228 }); 229 } else { 230 await slackClient.chat.postMessage({ ··· 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}`); ··· 307 308 // Slack event handlers 309 slackApp.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); ··· 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}`; ··· 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 }
+47
src/lib/db.ts
··· 20 ) 21 `); 22 23 export interface ChannelMapping { 24 id?: number; 25 slack_channel_id: string; ··· 91 92 delete(slackUserId: string): void { 93 db.run("DELETE FROM user_mappings WHERE slack_user_id = ?", [slackUserId]); 94 }, 95 }; 96
··· 20 ) 21 `); 22 23 + db.run(` 24 + CREATE TABLE IF NOT EXISTS thread_timestamps ( 25 + thread_ts TEXT PRIMARY KEY, 26 + thread_id TEXT NOT NULL UNIQUE, 27 + slack_channel_id TEXT NOT NULL, 28 + last_message_time INTEGER NOT NULL 29 + ) 30 + `); 31 + 32 + db.run(` 33 + CREATE INDEX IF NOT EXISTS idx_thread_id ON thread_timestamps(thread_id) 34 + `); 35 + 36 export interface ChannelMapping { 37 id?: number; 38 slack_channel_id: string; ··· 104 105 delete(slackUserId: string): void { 106 db.run("DELETE FROM user_mappings WHERE slack_user_id = ?", [slackUserId]); 107 + }, 108 + }; 109 + 110 + export interface ThreadInfo { 111 + thread_ts: string; 112 + thread_id: string; 113 + slack_channel_id: string; 114 + last_message_time: number; 115 + } 116 + 117 + export const threadTimestamps = { 118 + get(threadTs: string): ThreadInfo | null { 119 + return db 120 + .query("SELECT * FROM thread_timestamps WHERE thread_ts = ?") 121 + .get(threadTs) as ThreadInfo | null; 122 + }, 123 + 124 + getByThreadId(threadId: string): ThreadInfo | null { 125 + return db 126 + .query("SELECT * FROM thread_timestamps WHERE thread_id = ?") 127 + .get(threadId) as ThreadInfo | null; 128 + }, 129 + 130 + update(threadTs: string, threadId: string, slackChannelId: string, timestamp: number): void { 131 + db.run( 132 + "INSERT OR REPLACE INTO thread_timestamps (thread_ts, thread_id, slack_channel_id, last_message_time) VALUES (?, ?, ?, ?)", 133 + [threadTs, threadId, slackChannelId, timestamp], 134 + ); 135 + }, 136 + 137 + cleanup(olderThan: number): void { 138 + db.run("DELETE FROM thread_timestamps WHERE last_message_time < ?", [ 139 + olderThan, 140 + ]); 141 }, 142 }; 143
+51
src/lib/threads.ts
···
··· 1 + import { threadTimestamps } from "./db"; 2 + 3 + const THREAD_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes 4 + 5 + /** 6 + * Generate a short 5-character thread ID from thread_ts 7 + */ 8 + export function generateThreadId(threadTs: string): string { 9 + let hash = 0; 10 + for (let i = 0; i < threadTs.length; i++) { 11 + hash = (hash << 5) - hash + threadTs.charCodeAt(i); 12 + hash = hash & hash; 13 + } 14 + // Convert to base36 and take first 5 characters 15 + return Math.abs(hash).toString(36).substring(0, 5); 16 + } 17 + 18 + /** 19 + * Check if this is the first message in a thread (thread doesn't exist in DB yet) 20 + */ 21 + export function isFirstThreadMessage(threadTs: string): boolean { 22 + const thread = threadTimestamps.get(threadTs); 23 + return !thread; 24 + } 25 + 26 + /** 27 + * Get thread info by thread ID 28 + */ 29 + export function getThreadByThreadId(threadId: string) { 30 + return threadTimestamps.getByThreadId(threadId); 31 + } 32 + 33 + /** 34 + * Update the last message time for a thread 35 + */ 36 + export function updateThreadTimestamp( 37 + threadTs: string, 38 + slackChannelId: string, 39 + ): string { 40 + const threadId = generateThreadId(threadTs); 41 + threadTimestamps.update(threadTs, threadId, slackChannelId, Date.now()); 42 + return threadId; 43 + } 44 + 45 + /** 46 + * Clean up old thread entries (optional, for memory management) 47 + */ 48 + export function cleanupOldThreads(): void { 49 + const cutoff = Date.now() - THREAD_TIMEOUT_MS * 2; 50 + threadTimestamps.cleanup(cutoff); 51 + }