this repo has no description

feat: add thead replies

dunkirk.sh bf2dbec6 7515505d

verified
Changed files
+186 -3
src
+15
README.md
···
- IRC mentions (`@nick` or `nick:`) are converted to Slack mentions for mapped users
- IRC formatting codes are converted to Slack markdown
- IRC `/me` actions are displayed in a context block with the user's avatar
- **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels
- Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format
- Slack markdown is converted to IRC formatting codes
- File attachments are uploaded to Hack Club CDN and URLs are shared
- **User mappings** allow custom IRC nicknames for specific Slack users and enable proper mentions both ways
The bridge ignores its own messages and bot messages to prevent loops.
···
- IRC mentions (`@nick` or `nick:`) are converted to Slack mentions for mapped users
- IRC formatting codes are converted to Slack markdown
- IRC `/me` actions are displayed in a context block with the user's avatar
+
- Thread replies: Use `@xxxxx` (5-char thread ID) to reply to a Slack thread from IRC
- **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels
- Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format
- Slack markdown is converted to IRC formatting codes
- File attachments are uploaded to Hack Club CDN and URLs are shared
+
- Thread messages are prefixed with `@xxxxx` (5-char thread ID) to show they're part of a thread
+
- First reply in a thread includes a quote of the parent message
- **User mappings** allow custom IRC nicknames for specific Slack users and enable proper mentions both ways
+
+
#### Thread Support
+
+
The bridge supports Slack threads with a simple IRC-friendly syntax:
+
+
- **Slack → IRC**: Thread messages appear with a `@xxxxx` prefix (5-character thread ID)
+
- First reply in a thread includes a quote: `<user> @xxxxx > original message`
+
- Subsequent replies: `<user> @xxxxx message text`
+
- **IRC → Slack**: Reply to a thread by including the thread ID in your message
+
- Example: `@abc12 this is my reply`
+
- The bridge removes the `@xxxxx` prefix and sends your message to the correct thread
+
- Thread IDs are unique per thread and persist across restarts
The bridge ignores its own messages and bot messages to prevent loops.
+73 -3
src/index.ts
···
convertSlackMentionsToIrc,
} from "./lib/mentions";
import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser";
const missingEnvVars = [];
if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN");
···
// Register slash commands
registerCommands();
// Track NickServ authentication state
let nickServAuthAttempted = false;
···
// Parse IRC mentions and convert to Slack mentions
let messageText = parseIRCFormatting(text);
// Extract image URLs from the message
const imagePattern =
/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
···
attachments: attachments,
unfurl_links: false,
unfurl_media: false,
});
} else {
await slackClient.chat.postMessage({
···
icon_url: iconUrl,
unfurl_links: true,
unfurl_media: true,
});
}
console.log(`IRC (${to}) → Slack: <${nick}> ${text}`);
···
// Slack event handlers
slackApp.event("message", async ({ payload }) => {
-
// Ignore bot messages and threaded messages
if (payload.subtype && payload.subtype !== "file_share") return;
if (payload.bot_id) return;
if (payload.user === botUserId) return;
-
if (payload.thread_ts) return;
// Find IRC channel mapping for this Slack channel
const mapping = channelMappings.getBySlackChannel(payload.channel);
···
// Parse Slack markdown formatting
messageText = parseSlackMarkdown(messageText);
// Send message only if there's text content
if (messageText.trim()) {
const message = `<${username}> ${messageText}`;
···
const data = await uploadToCDN(fileUrls);
for (const file of data.files) {
-
const fileMessage = `<${username}> ${file.deployedUrl}`;
ircClient.say(mapping.irc_channel, fileMessage);
console.log(`Slack → IRC (file): ${fileMessage}`);
}
···
convertSlackMentionsToIrc,
} from "./lib/mentions";
import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser";
+
import {
+
cleanupOldThreads,
+
getThreadByThreadId,
+
isFirstThreadMessage,
+
updateThreadTimestamp,
+
} from "./lib/threads";
const missingEnvVars = [];
if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN");
···
// Register slash commands
registerCommands();
+
+
// Periodic cleanup of old thread timestamps (every hour)
+
setInterval(() => {
+
cleanupOldThreads();
+
}, 60 * 60 * 1000);
// Track NickServ authentication state
let nickServAuthAttempted = false;
···
// Parse IRC mentions and convert to Slack mentions
let messageText = parseIRCFormatting(text);
+
// Check for @xxxxx mentions to reply to threads
+
const threadMentionPattern = /@([a-z0-9]{5})\b/i;
+
const threadMatch = messageText.match(threadMentionPattern);
+
let threadTs: string | undefined;
+
+
if (threadMatch) {
+
const threadId = threadMatch[1];
+
const threadInfo = getThreadByThreadId(threadId);
+
if (threadInfo && threadInfo.slack_channel_id === mapping.slack_channel_id) {
+
threadTs = threadInfo.thread_ts;
+
// Remove the @xxxxx from the message
+
messageText = messageText.replace(threadMentionPattern, "").trim();
+
}
+
}
+
// Extract image URLs from the message
const imagePattern =
/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:\?[^\s]*)?/gi;
···
attachments: attachments,
unfurl_links: false,
unfurl_media: false,
+
thread_ts: threadTs,
});
} else {
await slackClient.chat.postMessage({
···
icon_url: iconUrl,
unfurl_links: true,
unfurl_media: true,
+
thread_ts: threadTs,
});
}
console.log(`IRC (${to}) → Slack: <${nick}> ${text}`);
···
// Slack event handlers
slackApp.event("message", async ({ payload }) => {
+
// Ignore bot messages
if (payload.subtype && payload.subtype !== "file_share") return;
if (payload.bot_id) return;
if (payload.user === botUserId) return;
// Find IRC channel mapping for this Slack channel
const mapping = channelMappings.getBySlackChannel(payload.channel);
···
// Parse Slack markdown formatting
messageText = parseSlackMarkdown(messageText);
+
let threadId: string | undefined;
+
+
// Handle thread messages
+
if (payload.thread_ts) {
+
const threadTs = payload.thread_ts;
+
const isFirstReply = isFirstThreadMessage(threadTs);
+
threadId = updateThreadTimestamp(threadTs, payload.channel);
+
+
if (isFirstReply) {
+
// First reply to thread, fetch and quote the parent message
+
try {
+
const parentResult = await slackClient.conversations.history({
+
token: process.env.SLACK_BOT_TOKEN,
+
channel: payload.channel,
+
latest: threadTs,
+
inclusive: true,
+
limit: 1,
+
});
+
+
if (parentResult.messages && parentResult.messages.length > 0) {
+
const parentMessage = parentResult.messages[0];
+
let parentText = await convertSlackMentionsToIrc(
+
parentMessage.text || "",
+
);
+
parentText = parseSlackMarkdown(parentText);
+
+
// Send the quoted parent message with thread ID
+
const quotedMessage = `<${username}> @${threadId} > ${parentText}`;
+
ircClient.say(mapping.irc_channel, quotedMessage);
+
console.log(`Slack → IRC (thread quote): ${quotedMessage}`);
+
}
+
} catch (error) {
+
console.error("Error fetching parent message:", error);
+
}
+
}
+
+
// Add thread ID to message
+
if (messageText.trim()) {
+
messageText = `@${threadId} ${messageText}`;
+
}
+
}
+
// Send message only if there's text content
if (messageText.trim()) {
const message = `<${username}> ${messageText}`;
···
const data = await uploadToCDN(fileUrls);
for (const file of data.files) {
+
const threadPrefix = threadId ? `@${threadId} ` : "";
+
const fileMessage = `<${username}> ${threadPrefix}${file.deployedUrl}`;
ircClient.say(mapping.irc_channel, fileMessage);
console.log(`Slack → IRC (file): ${fileMessage}`);
}
+47
src/lib/db.ts
···
)
`);
export interface ChannelMapping {
id?: number;
slack_channel_id: string;
···
delete(slackUserId: string): void {
db.run("DELETE FROM user_mappings WHERE slack_user_id = ?", [slackUserId]);
},
};
···
)
`);
+
db.run(`
+
CREATE TABLE IF NOT EXISTS thread_timestamps (
+
thread_ts TEXT PRIMARY KEY,
+
thread_id TEXT NOT NULL UNIQUE,
+
slack_channel_id TEXT NOT NULL,
+
last_message_time INTEGER NOT NULL
+
)
+
`);
+
+
db.run(`
+
CREATE INDEX IF NOT EXISTS idx_thread_id ON thread_timestamps(thread_id)
+
`);
+
export interface ChannelMapping {
id?: number;
slack_channel_id: string;
···
delete(slackUserId: string): void {
db.run("DELETE FROM user_mappings WHERE slack_user_id = ?", [slackUserId]);
+
},
+
};
+
+
export interface ThreadInfo {
+
thread_ts: string;
+
thread_id: string;
+
slack_channel_id: string;
+
last_message_time: number;
+
}
+
+
export const threadTimestamps = {
+
get(threadTs: string): ThreadInfo | null {
+
return db
+
.query("SELECT * FROM thread_timestamps WHERE thread_ts = ?")
+
.get(threadTs) as ThreadInfo | null;
+
},
+
+
getByThreadId(threadId: string): ThreadInfo | null {
+
return db
+
.query("SELECT * FROM thread_timestamps WHERE thread_id = ?")
+
.get(threadId) as ThreadInfo | null;
+
},
+
+
update(threadTs: string, threadId: string, slackChannelId: string, timestamp: number): void {
+
db.run(
+
"INSERT OR REPLACE INTO thread_timestamps (thread_ts, thread_id, slack_channel_id, last_message_time) VALUES (?, ?, ?, ?)",
+
[threadTs, threadId, slackChannelId, timestamp],
+
);
+
},
+
+
cleanup(olderThan: number): void {
+
db.run("DELETE FROM thread_timestamps WHERE last_message_time < ?", [
+
olderThan,
+
]);
},
};
+51
src/lib/threads.ts
···
···
+
import { threadTimestamps } from "./db";
+
+
const THREAD_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
+
+
/**
+
* Generate a short 5-character thread ID from thread_ts
+
*/
+
export function generateThreadId(threadTs: string): string {
+
let hash = 0;
+
for (let i = 0; i < threadTs.length; i++) {
+
hash = (hash << 5) - hash + threadTs.charCodeAt(i);
+
hash = hash & hash;
+
}
+
// Convert to base36 and take first 5 characters
+
return Math.abs(hash).toString(36).substring(0, 5);
+
}
+
+
/**
+
* Check if this is the first message in a thread (thread doesn't exist in DB yet)
+
*/
+
export function isFirstThreadMessage(threadTs: string): boolean {
+
const thread = threadTimestamps.get(threadTs);
+
return !thread;
+
}
+
+
/**
+
* Get thread info by thread ID
+
*/
+
export function getThreadByThreadId(threadId: string) {
+
return threadTimestamps.getByThreadId(threadId);
+
}
+
+
/**
+
* Update the last message time for a thread
+
*/
+
export function updateThreadTimestamp(
+
threadTs: string,
+
slackChannelId: string,
+
): string {
+
const threadId = generateThreadId(threadTs);
+
threadTimestamps.update(threadTs, threadId, slackChannelId, Date.now());
+
return threadId;
+
}
+
+
/**
+
* Clean up old thread entries (optional, for memory management)
+
*/
+
export function cleanupOldThreads(): void {
+
const cutoff = Date.now() - THREAD_TIMEOUT_MS * 2;
+
threadTimestamps.cleanup(cutoff);
+
}