this repo has no description

feat: add thead replies

dunkirk.sh bf2dbec6 7515505d

verified
+186 -3
+15
README.md
··· 101 101 - IRC mentions (`@nick` or `nick:`) are converted to Slack mentions for mapped users 102 102 - IRC formatting codes are converted to Slack markdown 103 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 104 105 - **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels 105 106 - Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format 106 107 - Slack markdown is converted to IRC formatting codes 107 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 108 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 109 124 110 125 The bridge ignores its own messages and bot messages to prevent loops. 111 126
+73 -3
src/index.ts
··· 10 10 convertSlackMentionsToIrc, 11 11 } from "./lib/mentions"; 12 12 import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser"; 13 + import { 14 + cleanupOldThreads, 15 + getThreadByThreadId, 16 + isFirstThreadMessage, 17 + updateThreadTimestamp, 18 + } from "./lib/threads"; 13 19 14 20 const missingEnvVars = []; 15 21 if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN"); ··· 69 75 70 76 // Register slash commands 71 77 registerCommands(); 78 + 79 + // Periodic cleanup of old thread timestamps (every hour) 80 + setInterval(() => { 81 + cleanupOldThreads(); 82 + }, 60 * 60 * 1000); 72 83 73 84 // Track NickServ authentication state 74 85 let nickServAuthAttempted = false; ··· 174 185 // Parse IRC mentions and convert to Slack mentions 175 186 let messageText = parseIRCFormatting(text); 176 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 + 177 203 // Extract image URLs from the message 178 204 const imagePattern = 179 205 /https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi; ··· 198 224 attachments: attachments, 199 225 unfurl_links: false, 200 226 unfurl_media: false, 227 + thread_ts: threadTs, 201 228 }); 202 229 } else { 203 230 await slackClient.chat.postMessage({ ··· 208 235 icon_url: iconUrl, 209 236 unfurl_links: true, 210 237 unfurl_media: true, 238 + thread_ts: threadTs, 211 239 }); 212 240 } 213 241 console.log(`IRC (${to}) → Slack: <${nick}> ${text}`); ··· 279 307 280 308 // Slack event handlers 281 309 slackApp.event("message", async ({ payload }) => { 282 - // Ignore bot messages and threaded messages 310 + // Ignore bot messages 283 311 if (payload.subtype && payload.subtype !== "file_share") return; 284 312 if (payload.bot_id) return; 285 313 if (payload.user === botUserId) return; 286 - if (payload.thread_ts) return; 287 314 288 315 // Find IRC channel mapping for this Slack channel 289 316 const mapping = channelMappings.getBySlackChannel(payload.channel); ··· 317 344 // Parse Slack markdown formatting 318 345 messageText = parseSlackMarkdown(messageText); 319 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 + 320 389 // Send message only if there's text content 321 390 if (messageText.trim()) { 322 391 const message = `<${username}> ${messageText}`; ··· 331 400 const data = await uploadToCDN(fileUrls); 332 401 333 402 for (const file of data.files) { 334 - const fileMessage = `<${username}> ${file.deployedUrl}`; 403 + const threadPrefix = threadId ? `@${threadId} ` : ""; 404 + const fileMessage = `<${username}> ${threadPrefix}${file.deployedUrl}`; 335 405 ircClient.say(mapping.irc_channel, fileMessage); 336 406 console.log(`Slack → IRC (file): ${fileMessage}`); 337 407 }
+47
src/lib/db.ts
··· 20 20 ) 21 21 `); 22 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 + 23 36 export interface ChannelMapping { 24 37 id?: number; 25 38 slack_channel_id: string; ··· 91 104 92 105 delete(slackUserId: string): void { 93 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 + ]); 94 141 }, 95 142 }; 96 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 + }