this repo has no description

Compare changes

Choose any two refs to compare.

+3
.env.example
···
SLACK_USER_COOKIE=your-slack-cookie-here
SLACK_USER_TOKEN=your-user-token-here
# IRC Configuration
IRC_NICK=slackbridge
NICKSERV_PASSWORD=your-nickserv-password-here
···
SLACK_USER_COOKIE=your-slack-cookie-here
SLACK_USER_TOKEN=your-user-token-here
+
# Optional: Enable Cachet API for user lookups (recommended for better performance)
+
CACHET_ENABLED=true
+
# IRC Configuration
IRC_NICK=slackbridge
NICKSERV_PASSWORD=your-nickserv-password-here
+61 -2
README.md
···
bun dev
```
### Slack App Setup
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app
···
# Optional: For channel manager permission checks
SLACK_USER_COOKIE=your-slack-cookie-here
SLACK_USER_TOKEN=your-user-token-here
# IRC Configuration
IRC_NICK=slackbridge
···
**Using Bun REPL:**
```bash
bun repl
-
> import { channelMappings, userMappings } from "./src/db"
> channelMappings.create("C1234567890", "#general")
> userMappings.create("U1234567890", "myircnick")
> channelMappings.getAll()
···
- Image URLs are automatically displayed as inline attachments
- IRC mentions (`@nick` or `nick:`) are converted to Slack mentions for mapped users
- IRC formatting codes are converted to Slack markdown
- **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.
If you want to report an issue the main repo is [the tangled repo](https://tangled.org/dunkirk.sh/irc-slack-bridge) and the github is just a mirror.
···
bun dev
```
+
To run tests:
+
```bash
+
bun test
+
```
+
### Slack App Setup
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app
···
# Optional: For channel manager permission checks
SLACK_USER_COOKIE=your-slack-cookie-here
SLACK_USER_TOKEN=your-user-token-here
+
+
# Optional: Enable Cachet API for user lookups (recommended for better performance)
+
CACHET_ENABLED=true
# IRC Configuration
IRC_NICK=slackbridge
···
**Using Bun REPL:**
```bash
bun repl
+
> import { channelMappings, userMappings } from "./src/lib/db"
> channelMappings.create("C1234567890", "#general")
> userMappings.create("U1234567890", "myircnick")
> channelMappings.getAll()
···
- Image URLs are automatically displayed as inline attachments
- 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
+
- User display names: Uses name from Slack event if available, otherwise Cachet API (if `CACHET_ENABLED=true`), then Slack API fallback
+
- All lookups are cached locally (1 hour TTL) to reduce API calls
+
- Slack mentions are converted to mapped IRC nicks, or looked up via the above priority
- 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
+
- **Permissions**: Only channel creators, channel managers, or global admins can bridge/unbridge channels
+
+
#### 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 (stored in SQLite)
The bridge ignores its own messages and bot messages to prevent loops.
+
+
### Architecture
+
+
The bridge consists of several modules:
+
+
- **`src/index.ts`** - Main application entry point with IRC/Slack event handlers
+
- **`src/commands.ts`** - Slash command handlers for managing bridges
+
- **`src/lib/db.ts`** - SQLite database layer for channel/user/thread mappings
+
- **`src/lib/parser.ts`** - Bidirectional message formatting conversion (IRC โ†” Slack)
+
- **`src/lib/mentions.ts`** - User mention conversion with Cachet integration
+
- **`src/lib/threads.ts`** - Thread tracking and ID generation
+
- **`src/lib/user-cache.ts`** - Cached Slack user info lookups (1-hour TTL)
+
- **`src/lib/permissions.ts`** - Channel management permission checks
+
- **`src/lib/avatars.ts`** - Stable avatar URL generation for IRC users
+
- **`src/lib/cdn.ts`** - File upload integration with Hack Club CDN
+
- **`src/lib/cachet.ts`** - User profile lookups from Cachet API
+
+
### Testing
+
+
The project includes comprehensive unit tests covering all core functionality:
+
+
```bash
+
bun test
+
```
+
+
Tests cover:
+
- Message format parsing (IRC โ†” Slack)
+
- User mention conversion
+
- Thread ID generation and tracking
+
- User info caching
+
- Database operations
+
- Avatar generation
If you want to report an issue the main repo is [the tangled repo](https://tangled.org/dunkirk.sh/irc-slack-bridge) and the github is just a mirror.
+4 -1
biome.json
···
"linter": {
"enabled": true,
"rules": {
-
"recommended": true
}
},
"javascript": {
···
"linter": {
"enabled": true,
"rules": {
+
"recommended": true,
+
"suspicious": {
+
"noControlCharactersInRegex": "off"
+
}
}
},
"javascript": {
+96
src/commands.test.ts
···
···
+
import { describe, expect, test, beforeEach } from "bun:test";
+
import { channelMappings, userMappings } from "./lib/db";
+
+
describe("channel mappings uniqueness", () => {
+
beforeEach(() => {
+
// Clean up mappings before each test
+
const channels = channelMappings.getAll();
+
for (const channel of channels) {
+
channelMappings.delete(channel.slack_channel_id);
+
}
+
});
+
+
test("prevents duplicate IRC channel mappings", () => {
+
channelMappings.create("C001", "#test");
+
+
const existing = channelMappings.getByIrcChannel("#test");
+
expect(existing).not.toBeNull();
+
expect(existing?.slack_channel_id).toBe("C001");
+
+
// Trying to map a different Slack channel to the same IRC channel should be prevented
+
const duplicate = channelMappings.getByIrcChannel("#test");
+
expect(duplicate).not.toBeNull();
+
expect(duplicate?.slack_channel_id).toBe("C001");
+
});
+
+
test("prevents duplicate Slack channel mappings", () => {
+
channelMappings.create("C001", "#test");
+
+
const existing = channelMappings.getBySlackChannel("C001");
+
expect(existing).not.toBeNull();
+
expect(existing?.irc_channel).toBe("#test");
+
+
// The same Slack channel should keep its original mapping
+
channelMappings.create("C001", "#new");
+
const updated = channelMappings.getBySlackChannel("C001");
+
expect(updated?.irc_channel).toBe("#new");
+
});
+
+
test("allows different channels to map to different IRC channels", () => {
+
channelMappings.create("C001", "#test1");
+
channelMappings.create("C002", "#test2");
+
+
const mapping1 = channelMappings.getBySlackChannel("C001");
+
const mapping2 = channelMappings.getBySlackChannel("C002");
+
+
expect(mapping1?.irc_channel).toBe("#test1");
+
expect(mapping2?.irc_channel).toBe("#test2");
+
});
+
});
+
+
describe("user mappings uniqueness", () => {
+
beforeEach(() => {
+
// Clean up mappings before each test
+
const users = userMappings.getAll();
+
for (const user of users) {
+
userMappings.delete(user.slack_user_id);
+
}
+
});
+
+
test("prevents duplicate IRC nick mappings", () => {
+
userMappings.create("U001", "testnick");
+
+
const existing = userMappings.getByIrcNick("testnick");
+
expect(existing).not.toBeNull();
+
expect(existing?.slack_user_id).toBe("U001");
+
+
// Trying to map a different Slack user to the same IRC nick should be prevented
+
const duplicate = userMappings.getByIrcNick("testnick");
+
expect(duplicate).not.toBeNull();
+
expect(duplicate?.slack_user_id).toBe("U001");
+
});
+
+
test("prevents duplicate Slack user mappings", () => {
+
userMappings.create("U001", "testnick");
+
+
const existing = userMappings.getBySlackUser("U001");
+
expect(existing).not.toBeNull();
+
expect(existing?.irc_nick).toBe("testnick");
+
+
// The same Slack user should keep its original mapping
+
userMappings.create("U001", "newnick");
+
const updated = userMappings.getBySlackUser("U001");
+
expect(updated?.irc_nick).toBe("newnick");
+
});
+
+
test("allows different users to map to different IRC nicks", () => {
+
userMappings.create("U001", "nick1");
+
userMappings.create("U002", "nick2");
+
+
const mapping1 = userMappings.getBySlackUser("U001");
+
const mapping2 = userMappings.getBySlackUser("U002");
+
+
expect(mapping1?.irc_nick).toBe("nick1");
+
expect(mapping2?.irc_nick).toBe("nick2");
+
});
+
});
+51 -7
src/commands.ts
···
-
import type { AnyMessageBlock, Block, BlockElement } from "slack-edge";
-
import { channelMappings, userMappings } from "./db";
-
import { slackApp, ircClient } from "./index";
-
import { canManageChannel } from "./permissions";
export function registerCommands() {
// Link Slack channel to IRC channel
···
}
try {
channelMappings.create(slackChannelId, ircChannel);
ircClient.join(ircChannel);
···
context.respond({
response_type: "ephemeral",
-
text: "Are you sure you want to remove the bridge to *${mapping.irc_channel}*?",
blocks: [
{
type: "section",
···
}
try {
userMappings.create(slackUserId, ircNick);
console.log(`Created user mapping: ${slackUserId} -> ${ircNick}`);
···
context.respond({
response_type: "ephemeral",
-
text: "Are you sure you want to remove your link to IRC nick *${mapping.irc_nick}*?",
blocks: [
{
type: "section",
···
});
// List channel mappings
-
slackApp.command("/irc-bridge-list", async ({ payload, context }) => {
const channelMaps = channelMappings.getAll();
const userMaps = userMappings.getAll();
···
+
import type { AnyMessageBlock } from "slack-edge";
+
import { ircClient, slackApp } from "./index";
+
import { channelMappings, userMappings } from "./lib/db";
+
import { canManageChannel } from "./lib/permissions";
export function registerCommands() {
// Link Slack channel to IRC channel
···
}
try {
+
// Check if IRC channel is already linked
+
const existingIrcMapping = channelMappings.getByIrcChannel(ircChannel);
+
if (existingIrcMapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ IRC channel ${ircChannel} is already bridged to <#${existingIrcMapping.slack_channel_id}>`,
+
replace_original: true,
+
});
+
return;
+
}
+
+
// Check if Slack channel is already linked
+
const existingSlackMapping = channelMappings.getBySlackChannel(slackChannelId);
+
if (existingSlackMapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ This channel is already bridged to ${existingSlackMapping.irc_channel}`,
+
replace_original: true,
+
});
+
return;
+
}
+
channelMappings.create(slackChannelId, ircChannel);
ircClient.join(ircChannel);
···
context.respond({
response_type: "ephemeral",
+
text: `Are you sure you want to remove the bridge to *${mapping.irc_channel}*?`,
blocks: [
{
type: "section",
···
}
try {
+
// Check if IRC nick is already linked
+
const existingIrcMapping = userMappings.getByIrcNick(ircNick);
+
if (existingIrcMapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ IRC nick *${ircNick}* is already linked to <@${existingIrcMapping.slack_user_id}>`,
+
replace_original: true,
+
});
+
return;
+
}
+
+
// Check if Slack user is already linked
+
const existingSlackMapping = userMappings.getBySlackUser(slackUserId);
+
if (existingSlackMapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ You are already linked to IRC nick *${existingSlackMapping.irc_nick}*`,
+
replace_original: true,
+
});
+
return;
+
}
+
userMappings.create(slackUserId, ircNick);
console.log(`Created user mapping: ${slackUserId} -> ${ircNick}`);
···
context.respond({
response_type: "ephemeral",
+
text: `Are you sure you want to remove your link to IRC nick *${mapping.irc_nick}*?`,
blocks: [
{
type: "section",
···
});
// List channel mappings
+
slackApp.command("/irc-bridge-list", async ({ context }) => {
const channelMaps = channelMappings.getAll();
const userMaps = userMappings.getAll();
-97
src/db.ts
···
-
import { Database } from "bun:sqlite";
-
-
const db = new Database("bridge.db");
-
-
db.run(`
-
CREATE TABLE IF NOT EXISTS channel_mappings (
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
-
slack_channel_id TEXT NOT NULL UNIQUE,
-
irc_channel TEXT NOT NULL,
-
created_at INTEGER DEFAULT (strftime('%s', 'now'))
-
)
-
`);
-
-
db.run(`
-
CREATE TABLE IF NOT EXISTS user_mappings (
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
-
slack_user_id TEXT NOT NULL UNIQUE,
-
irc_nick TEXT NOT NULL,
-
created_at INTEGER DEFAULT (strftime('%s', 'now'))
-
)
-
`);
-
-
export interface ChannelMapping {
-
id?: number;
-
slack_channel_id: string;
-
irc_channel: string;
-
created_at?: number;
-
}
-
-
export interface UserMapping {
-
id?: number;
-
slack_user_id: string;
-
irc_nick: string;
-
created_at?: number;
-
}
-
-
export const channelMappings = {
-
getAll(): ChannelMapping[] {
-
return db.query("SELECT * FROM channel_mappings").all() as ChannelMapping[];
-
},
-
-
getBySlackChannel(slackChannelId: string): ChannelMapping | null {
-
return db
-
.query("SELECT * FROM channel_mappings WHERE slack_channel_id = ?")
-
.get(slackChannelId) as ChannelMapping | null;
-
},
-
-
getByIrcChannel(ircChannel: string): ChannelMapping | null {
-
return db
-
.query("SELECT * FROM channel_mappings WHERE irc_channel = ?")
-
.get(ircChannel) as ChannelMapping | null;
-
},
-
-
create(slackChannelId: string, ircChannel: string): void {
-
db.run(
-
"INSERT OR REPLACE INTO channel_mappings (slack_channel_id, irc_channel) VALUES (?, ?)",
-
[slackChannelId, ircChannel],
-
);
-
},
-
-
delete(slackChannelId: string): void {
-
db.run("DELETE FROM channel_mappings WHERE slack_channel_id = ?", [
-
slackChannelId,
-
]);
-
},
-
};
-
-
export const userMappings = {
-
getAll(): UserMapping[] {
-
return db.query("SELECT * FROM user_mappings").all() as UserMapping[];
-
},
-
-
getBySlackUser(slackUserId: string): UserMapping | null {
-
return db
-
.query("SELECT * FROM user_mappings WHERE slack_user_id = ?")
-
.get(slackUserId) as UserMapping | null;
-
},
-
-
getByIrcNick(ircNick: string): UserMapping | null {
-
return db
-
.query("SELECT * FROM user_mappings WHERE irc_nick = ?")
-
.get(ircNick) as UserMapping | null;
-
},
-
-
create(slackUserId: string, ircNick: string): void {
-
db.run(
-
"INSERT OR REPLACE INTO user_mappings (slack_user_id, irc_nick) VALUES (?, ?)",
-
[slackUserId, ircNick],
-
);
-
},
-
-
delete(slackUserId: string): void {
-
db.run("DELETE FROM user_mappings WHERE slack_user_id = ?", [slackUserId]);
-
},
-
};
-
-
export default db;
···
+209 -145
src/index.ts
···
import { SlackApp } from "slack-edge";
import { version } from "../package.json";
import { registerCommands } from "./commands";
-
import { channelMappings, userMappings } from "./db";
-
import { parseIRCFormatting, parseSlackMarkdown } from "./parser";
-
import type { CachetUser } from "./types";
-
-
// Default profile pictures for unmapped IRC users
-
const DEFAULT_AVATARS = [
-
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/4183627c4d26c56c915e104a8a7374f43acd1733_pfp__1_.png",
-
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/389b1e6bd4248a7e5dd88e14c1adb8eb01267080_pfp__2_.png",
-
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/03011a5e59548191de058f33ccd1d1cb1d64f2a0_pfp__3_.png",
-
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/f9c57b88fbd4633114c1864bcc2968db555dbd2a_pfp__4_.png",
-
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/e61a8cabee5a749588125242747b65122fb94205_pfp.png",
-
];
-
-
// Hash function for stable avatar selection
-
function getAvatarForNick(nick: string): string {
-
let hash = 0;
-
for (let i = 0; i < nick.length; i++) {
-
hash = (hash << 5) - hash + nick.charCodeAt(i);
-
hash = hash & hash; // Convert to 32bit integer
-
}
-
return DEFAULT_AVATARS[Math.abs(hash) % DEFAULT_AVATARS.length];
-
}
const missingEnvVars = [];
if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN");
···
// Register slash commands
registerCommands();
// Track NickServ authentication state
let nickServAuthAttempted = false;
-
let isAuthenticated = false;
// Join all mapped IRC channels on connect
ircClient.addListener("registered", async () => {
console.log("Connected to IRC server");
-
// Authenticate with NickServ if password is provided
if (process.env.NICKSERV_PASSWORD && !nickServAuthAttempted) {
nickServAuthAttempted = true;
···
});
// Handle NickServ notices
-
ircClient.addListener("notice", async (nick: string, to: string, text: string) => {
-
if (nick !== "NickServ") return;
-
-
console.log(`NickServ: ${text}`);
-
-
// Check for successful authentication
-
if (text.includes("You are now identified") || text.includes("Password accepted")) {
-
console.log("โœ“ Successfully authenticated with NickServ");
-
isAuthenticated = true;
-
-
// Join channels after successful auth
-
const mappings = channelMappings.getAll();
-
for (const mapping of mappings) {
-
ircClient.join(mapping.irc_channel);
}
-
}
-
// Check if nick is not registered
-
else if (text.includes("isn't registered") || text.includes("not registered")) {
-
console.log("Nick not registered, registering with NickServ...");
-
if (process.env.NICKSERV_PASSWORD && process.env.NICKSERV_EMAIL) {
-
ircClient.say("NickServ", `REGISTER ${process.env.NICKSERV_PASSWORD} ${process.env.NICKSERV_EMAIL}`);
-
} else {
-
console.error("Cannot register: NICKSERV_EMAIL not configured");
}
-
}
-
// Check for failed authentication
-
else if (text.includes("Invalid password") || text.includes("Access denied")) {
-
console.error("โœ— NickServ authentication failed: Invalid password");
-
}
-
});
ircClient.addListener(
"message",
···
// 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;
const imageUrls = Array.from(messageText.matchAll(imagePattern));
-
// Find all @mentions and nick: mentions in the IRC message
-
const atMentionPattern = /@(\w+)/g;
-
const nickMentionPattern = /(\w+):/g;
-
-
const atMentions = Array.from(messageText.matchAll(atMentionPattern));
-
const nickMentions = Array.from(messageText.matchAll(nickMentionPattern));
-
-
for (const match of atMentions) {
-
const mentionedNick = match[1] as string;
-
const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
-
if (mentionedUserMapping) {
-
messageText = messageText.replace(
-
match[0],
-
`<@${mentionedUserMapping.slack_user_id}>`,
-
);
-
}
-
}
-
-
for (const match of nickMentions) {
-
const mentionedNick = match[1] as string;
-
const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
-
if (mentionedUserMapping) {
-
messageText = messageText.replace(
-
match[0],
-
`<@${mentionedUserMapping.slack_user_id}>:`,
-
);
-
}
-
}
try {
// If there are image URLs, send them as attachments
···
attachments: attachments,
unfurl_links: false,
unfurl_media: false,
});
} else {
await slackClient.chat.postMessage({
···
text: messageText,
username: displayName,
icon_url: iconUrl,
-
unfurl_links: false,
-
unfurl_media: false,
});
}
-
console.log(`IRC โ†’ Slack: <${nick}> ${text}`);
} catch (error) {
console.error("Error posting to Slack:", error);
}
···
console.error("IRC error:", error);
});
// Slack event handlers
-
slackApp.event("message", async ({ payload, context }) => {
-
// 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);
···
}
try {
-
const userInfo = await slackClient.users.info({
-
token: process.env.SLACK_BOT_TOKEN,
-
user: payload.user,
-
});
// Check for user mapping, otherwise use Slack name
const userMapping = userMappings.getBySlackUser(payload.user);
const username =
userMapping?.irc_nick ||
-
userInfo.user?.real_name ||
-
userInfo.user?.name ||
"Unknown";
// Parse Slack mentions and replace with IRC nicks or display names
-
let messageText = payload.text;
-
const mentionRegex = /<@(U[A-Z0-9]+)(\|([^>]+))?>/g;
-
const mentions = Array.from(messageText.matchAll(mentionRegex));
-
for (const match of mentions) {
-
const userId = match[1];
-
const displayName = match[3]; // The name part after |
-
-
// Check if user has a mapped IRC nick
-
const mentionedUserMapping = userMappings.getBySlackUser(userId);
-
if (mentionedUserMapping) {
-
messageText = messageText.replace(match[0], `@${mentionedUserMapping.irc_nick}`);
-
} else if (displayName) {
-
// Use the display name from the mention format <@U123|name>
-
messageText = messageText.replace(match[0], `@${displayName}`);
-
} else {
-
// Fallback to Cachet lookup
try {
-
const response = await fetch(
-
`https://cachet.dunkirk.sh/users/${userId}`,
-
{
-
// @ts-ignore - Bun specific option
-
tls: { rejectUnauthorized: false },
-
},
-
);
-
if (response.ok) {
-
const data = (await response.json()) as CachetUser;
-
messageText = messageText.replace(match[0], `@${data.displayName}`);
}
} catch (error) {
-
console.error(`Error fetching user ${userId} from cachet:`, error);
}
}
-
}
-
// Parse Slack markdown formatting
-
messageText = parseSlackMarkdown(messageText);
// Send message only if there's text content
if (messageText.trim()) {
···
// Handle file uploads
if (payload.files && payload.files.length > 0) {
try {
-
// Extract private file URLs
const fileUrls = payload.files.map((file) => file.url_private);
-
-
// Upload to Hack Club CDN
-
const response = await fetch("https://cdn.hackclub.com/api/v3/new", {
-
method: "POST",
-
headers: {
-
Authorization: `Bearer ${process.env.CDN_TOKEN}`,
-
"X-Download-Authorization": `Bearer ${process.env.SLACK_BOT_TOKEN}`,
-
"Content-Type": "application/json",
-
},
-
body: JSON.stringify(fileUrls),
-
});
-
if (response.ok) {
-
const data = await response.json();
-
-
// Send each uploaded file URL to IRC
-
for (const file of data.files) {
-
const fileMessage = `<${username}> ${file.deployedUrl}`;
-
ircClient.say(mapping.irc_channel, fileMessage);
-
console.log(`Slack โ†’ IRC (file): ${fileMessage}`);
-
}
-
} else {
-
console.error("Failed to upload files to CDN:", response.statusText);
}
} catch (error) {
console.error("Error uploading files to CDN:", error);
···
import { SlackApp } from "slack-edge";
import { version } from "../package.json";
import { registerCommands } from "./commands";
+
import { getAvatarForNick } from "./lib/avatars";
+
import { uploadToCDN } from "./lib/cdn";
+
import { channelMappings, userMappings } from "./lib/db";
+
import {
+
convertIrcMentionsToSlack,
+
convertSlackMentionsToIrc,
+
} from "./lib/mentions";
+
import { parseIRCFormatting, parseSlackMarkdown } from "./lib/parser";
+
import {
+
cleanupOldThreads,
+
getThreadByThreadId,
+
isFirstThreadMessage,
+
updateThreadTimestamp,
+
} from "./lib/threads";
+
import { cleanupUserCache, getUserInfo } from "./lib/user-cache";
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();
+
cleanupUserCache();
+
},
+
60 * 60 * 1000,
+
);
+
// Track NickServ authentication state
let nickServAuthAttempted = false;
+
let _isAuthenticated = false;
// Join all mapped IRC channels on connect
ircClient.addListener("registered", async () => {
console.log("Connected to IRC server");
+
// Authenticate with NickServ if password is provided
if (process.env.NICKSERV_PASSWORD && !nickServAuthAttempted) {
nickServAuthAttempted = true;
···
});
// Handle NickServ notices
+
ircClient.addListener(
+
"notice",
+
async (nick: string, _to: string, text: string) => {
+
if (nick !== "NickServ") return;
+
+
console.log(`NickServ: ${text}`);
+
+
// Check for successful authentication
+
if (
+
text.includes("You are now identified") ||
+
text.includes("Password accepted")
+
) {
+
console.log("โœ“ Successfully authenticated with NickServ");
+
_isAuthenticated = true;
+
+
// Join channels after successful auth
+
const mappings = channelMappings.getAll();
+
for (const mapping of mappings) {
+
ircClient.join(mapping.irc_channel);
+
}
+
}
+
// Check if nick is not registered
+
else if (
+
text.includes("isn't registered") ||
+
text.includes("not registered")
+
) {
+
console.log("Nick not registered, registering with NickServ...");
+
if (process.env.NICKSERV_PASSWORD && process.env.NICKSERV_EMAIL) {
+
ircClient.say(
+
"NickServ",
+
`REGISTER ${process.env.NICKSERV_PASSWORD} ${process.env.NICKSERV_EMAIL}`,
+
);
+
} else {
+
console.error("Cannot register: NICKSERV_EMAIL not configured");
+
}
}
+
// Check for failed authentication
+
else if (
+
text.includes("Invalid password") ||
+
text.includes("Access denied")
+
) {
+
console.error("โœ— NickServ authentication failed: Invalid password");
}
+
},
+
);
ircClient.addListener(
"message",
···
// 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;
const imageUrls = Array.from(messageText.matchAll(imagePattern));
+
messageText = convertIrcMentionsToSlack(messageText);
try {
// If there are image URLs, send them as attachments
···
attachments: attachments,
unfurl_links: false,
unfurl_media: false,
+
thread_ts: threadTs,
});
} else {
await slackClient.chat.postMessage({
···
text: messageText,
username: displayName,
icon_url: iconUrl,
+
unfurl_links: true,
+
unfurl_media: true,
+
thread_ts: threadTs,
});
}
+
console.log(`IRC (${to}) โ†’ Slack: <${nick}> ${text}`);
} catch (error) {
console.error("Error posting to Slack:", error);
}
···
console.error("IRC error:", error);
});
+
// Handle IRC /me actions
+
ircClient.addListener(
+
"action",
+
async (nick: string, to: string, text: string) => {
+
// Ignore messages from our own bot
+
const botNickPattern = new RegExp(`^${process.env.IRC_NICK}\\d*$`);
+
if (botNickPattern.test(nick)) return;
+
if (nick === "****") return;
+
+
// Find Slack channel mapping for this IRC channel
+
const mapping = channelMappings.getByIrcChannel(to);
+
if (!mapping) return;
+
+
// Check if this IRC nick is mapped to a Slack user
+
const userMapping = userMappings.getByIrcNick(nick);
+
+
let iconUrl: string;
+
if (userMapping) {
+
iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
+
} else {
+
iconUrl = getAvatarForNick(nick);
+
}
+
+
// Parse IRC formatting and mentions
+
let messageText = parseIRCFormatting(text);
+
messageText = convertIrcMentionsToSlack(messageText);
+
+
// Format as action message with context block
+
const actionText = `${nick} ${messageText}`;
+
+
await slackClient.chat.postMessage({
+
token: process.env.SLACK_BOT_TOKEN,
+
channel: mapping.slack_channel_id,
+
text: actionText,
+
blocks: [
+
{
+
type: "context",
+
elements: [
+
{
+
type: "image",
+
image_url: iconUrl,
+
alt_text: nick,
+
},
+
{
+
type: "mrkdwn",
+
text: actionText,
+
},
+
],
+
},
+
],
+
});
+
+
console.log(`IRC (${to}) โ†’ Slack (action): ${actionText}`);
+
},
+
);
+
// 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);
···
}
try {
+
// Get display name from payload if available, otherwise fetch from API
+
const displayNameFromEvent =
+
(payload as any).user_profile?.display_name ||
+
(payload as any).user_profile?.real_name ||
+
(payload as any).username;
+
+
const userInfo = await getUserInfo(
+
payload.user,
+
slackClient,
+
displayNameFromEvent,
+
);
// Check for user mapping, otherwise use Slack name
const userMapping = userMappings.getBySlackUser(payload.user);
const username =
userMapping?.irc_nick ||
+
userInfo?.realName ||
+
userInfo?.name ||
"Unknown";
// Parse Slack mentions and replace with IRC nicks or display names
+
let messageText = await convertSlackMentionsToIrc(payload.text);
+
+
// 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()) {
···
// Handle file uploads
if (payload.files && payload.files.length > 0) {
try {
const fileUrls = payload.files.map((file) => file.url_private);
+
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}`);
}
} catch (error) {
console.error("Error uploading files to CDN:", error);
+44
src/lib/avatars.test.ts
···
···
+
import { describe, expect, test } from "bun:test";
+
import { getAvatarForNick } from "./avatars";
+
+
describe("getAvatarForNick", () => {
+
test("returns a valid URL", () => {
+
const avatar = getAvatarForNick("testnick");
+
expect(avatar).toBeString();
+
expect(avatar).toStartWith("https://");
+
});
+
+
test("returns consistent avatar for same nick", () => {
+
const avatar1 = getAvatarForNick("alice");
+
const avatar2 = getAvatarForNick("alice");
+
expect(avatar1).toBe(avatar2);
+
});
+
+
test("returns different avatars for different nicks", () => {
+
const avatar1 = getAvatarForNick("alice");
+
const avatar2 = getAvatarForNick("bob");
+
+
// They might occasionally be the same due to hash collisions,
+
// but let's test they can be different
+
expect(avatar1).toBeString();
+
expect(avatar2).toBeString();
+
});
+
+
test("handles empty string", () => {
+
const avatar = getAvatarForNick("");
+
expect(avatar).toBeString();
+
expect(avatar).toStartWith("https://");
+
});
+
+
test("handles special characters", () => {
+
const avatar = getAvatarForNick("user-123_test");
+
expect(avatar).toBeString();
+
expect(avatar).toStartWith("https://");
+
});
+
+
test("handles unicode characters", () => {
+
const avatar = getAvatarForNick("็”จๆˆทๅ");
+
expect(avatar).toBeString();
+
expect(avatar).toStartWith("https://");
+
});
+
});
+19
src/lib/avatars.ts
···
···
+
const DEFAULT_AVATARS = [
+
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/4183627c4d26c56c915e104a8a7374f43acd1733_pfp__1_.png",
+
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/389b1e6bd4248a7e5dd88e14c1adb8eb01267080_pfp__2_.png",
+
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/03011a5e59548191de058f33ccd1d1cb1d64f2a0_pfp__3_.png",
+
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/f9c57b88fbd4633114c1864bcc2968db555dbd2a_pfp__4_.png",
+
"https://hc-cdn.hel1.your-objectstorage.com/s/v3/e61a8cabee5a749588125242747b65122fb94205_pfp.png",
+
];
+
+
/**
+
* Returns a stable avatar URL for an IRC nick based on hash
+
*/
+
export function getAvatarForNick(nick: string): string {
+
let hash = 0;
+
for (let i = 0; i < nick.length; i++) {
+
hash = (hash << 5) - hash + nick.charCodeAt(i);
+
hash = hash & hash; // Convert to 32bit integer
+
}
+
return DEFAULT_AVATARS[Math.abs(hash) % DEFAULT_AVATARS.length] as string;
+
}
+21
src/lib/cachet.ts
···
···
+
import type { CachetUser } from "../types";
+
+
/**
+
* Fetches user information from Cachet API
+
*/
+
export async function getCachetUser(
+
userId: string,
+
): Promise<CachetUser | null> {
+
try {
+
const response = await fetch(`https://cachet.dunkirk.sh/users/${userId}`, {
+
tls: { rejectUnauthorized: false },
+
});
+
if (response.ok) {
+
return (await response.json()) as CachetUser;
+
}
+
return null;
+
} catch (error) {
+
console.error(`Error fetching user ${userId} from cachet:`, error);
+
return null;
+
}
+
}
+21
src/lib/cdn.ts
···
···
+
import type { CDNUploadResponse } from "../types";
+
+
export async function uploadToCDN(
+
fileUrls: string[],
+
): Promise<CDNUploadResponse> {
+
const response = await fetch("https://cdn.hackclub.com/api/v3/new", {
+
method: "POST",
+
headers: {
+
Authorization: `Bearer ${process.env.CDN_TOKEN}`,
+
"X-Download-Authorization": `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+
"Content-Type": "application/json",
+
},
+
body: JSON.stringify(fileUrls),
+
});
+
+
if (!response.ok) {
+
throw new Error(`CDN upload failed: ${response.statusText}`);
+
}
+
+
return (await response.json()) as CDNUploadResponse;
+
}
+160
src/lib/db.test.ts
···
···
+
import { afterEach, describe, expect, test } from "bun:test";
+
import { channelMappings, userMappings } from "./db";
+
+
describe("channelMappings", () => {
+
const testSlackChannel = "C123TEST";
+
const testIrcChannel = "#test-channel";
+
+
afterEach(() => {
+
// Cleanup test data
+
try {
+
channelMappings.delete(testSlackChannel);
+
} catch {
+
// Ignore if doesn't exist
+
}
+
});
+
+
test("creates a channel mapping", () => {
+
channelMappings.create(testSlackChannel, testIrcChannel);
+
const mapping = channelMappings.getBySlackChannel(testSlackChannel);
+
+
expect(mapping).toBeDefined();
+
expect(mapping?.slack_channel_id).toBe(testSlackChannel);
+
expect(mapping?.irc_channel).toBe(testIrcChannel);
+
});
+
+
test("retrieves mapping by Slack channel ID", () => {
+
channelMappings.create(testSlackChannel, testIrcChannel);
+
const mapping = channelMappings.getBySlackChannel(testSlackChannel);
+
+
expect(mapping).not.toBeNull();
+
expect(mapping?.irc_channel).toBe(testIrcChannel);
+
});
+
+
test("retrieves mapping by IRC channel", () => {
+
channelMappings.create(testSlackChannel, testIrcChannel);
+
const mapping = channelMappings.getByIrcChannel(testIrcChannel);
+
+
expect(mapping).not.toBeNull();
+
expect(mapping?.slack_channel_id).toBe(testSlackChannel);
+
});
+
+
test("returns null for non-existent mapping", () => {
+
const mapping = channelMappings.getBySlackChannel("C999NOTFOUND");
+
expect(mapping).toBeNull();
+
});
+
+
test("deletes a channel mapping", () => {
+
channelMappings.create(testSlackChannel, testIrcChannel);
+
channelMappings.delete(testSlackChannel);
+
+
const mapping = channelMappings.getBySlackChannel(testSlackChannel);
+
expect(mapping).toBeNull();
+
});
+
+
test("replaces existing mapping on create", () => {
+
channelMappings.create(testSlackChannel, "#old-channel");
+
channelMappings.create(testSlackChannel, testIrcChannel);
+
+
const mapping = channelMappings.getBySlackChannel(testSlackChannel);
+
expect(mapping?.irc_channel).toBe(testIrcChannel);
+
});
+
+
test("getAll returns all mappings", () => {
+
const testChannel2 = "C456TEST";
+
const testIrc2 = "#another-channel";
+
+
channelMappings.create(testSlackChannel, testIrcChannel);
+
channelMappings.create(testChannel2, testIrc2);
+
+
const all = channelMappings.getAll();
+
const testMappings = all.filter(
+
(m) =>
+
m.slack_channel_id === testSlackChannel ||
+
m.slack_channel_id === testChannel2,
+
);
+
+
expect(testMappings.length).toBeGreaterThanOrEqual(2);
+
+
// Cleanup
+
channelMappings.delete(testChannel2);
+
});
+
});
+
+
describe("userMappings", () => {
+
const testSlackUser = "U123TEST";
+
const testIrcNick = "testnick";
+
+
afterEach(() => {
+
// Cleanup test data
+
try {
+
userMappings.delete(testSlackUser);
+
} catch {
+
// Ignore if doesn't exist
+
}
+
});
+
+
test("creates a user mapping", () => {
+
userMappings.create(testSlackUser, testIrcNick);
+
const mapping = userMappings.getBySlackUser(testSlackUser);
+
+
expect(mapping).toBeDefined();
+
expect(mapping?.slack_user_id).toBe(testSlackUser);
+
expect(mapping?.irc_nick).toBe(testIrcNick);
+
});
+
+
test("retrieves mapping by Slack user ID", () => {
+
userMappings.create(testSlackUser, testIrcNick);
+
const mapping = userMappings.getBySlackUser(testSlackUser);
+
+
expect(mapping).not.toBeNull();
+
expect(mapping?.irc_nick).toBe(testIrcNick);
+
});
+
+
test("retrieves mapping by IRC nick", () => {
+
userMappings.create(testSlackUser, testIrcNick);
+
const mapping = userMappings.getByIrcNick(testIrcNick);
+
+
expect(mapping).not.toBeNull();
+
expect(mapping?.slack_user_id).toBe(testSlackUser);
+
});
+
+
test("returns null for non-existent mapping", () => {
+
const mapping = userMappings.getBySlackUser("U999NOTFOUND");
+
expect(mapping).toBeNull();
+
});
+
+
test("deletes a user mapping", () => {
+
userMappings.create(testSlackUser, testIrcNick);
+
userMappings.delete(testSlackUser);
+
+
const mapping = userMappings.getBySlackUser(testSlackUser);
+
expect(mapping).toBeNull();
+
});
+
+
test("replaces existing mapping on create", () => {
+
userMappings.create(testSlackUser, "oldnick");
+
userMappings.create(testSlackUser, testIrcNick);
+
+
const mapping = userMappings.getBySlackUser(testSlackUser);
+
expect(mapping?.irc_nick).toBe(testIrcNick);
+
});
+
+
test("getAll returns all mappings", () => {
+
const testUser2 = "U456TEST";
+
const testNick2 = "anothernick";
+
+
userMappings.create(testSlackUser, testIrcNick);
+
userMappings.create(testUser2, testNick2);
+
+
const all = userMappings.getAll();
+
const testMappings = all.filter(
+
(m) => m.slack_user_id === testSlackUser || m.slack_user_id === testUser2,
+
);
+
+
expect(testMappings.length).toBeGreaterThanOrEqual(2);
+
+
// Cleanup
+
userMappings.delete(testUser2);
+
});
+
});
+255
src/lib/db.ts
···
···
+
import { Database } from "bun:sqlite";
+
+
const db = new Database("bridge.db");
+
+
db.run(`
+
CREATE TABLE IF NOT EXISTS channel_mappings (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
slack_channel_id TEXT NOT NULL UNIQUE,
+
irc_channel TEXT NOT NULL UNIQUE,
+
created_at INTEGER DEFAULT (strftime('%s', 'now'))
+
)
+
`);
+
+
db.run(`
+
CREATE TABLE IF NOT EXISTS user_mappings (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
slack_user_id TEXT NOT NULL UNIQUE,
+
irc_nick TEXT NOT NULL UNIQUE,
+
created_at INTEGER DEFAULT (strftime('%s', 'now'))
+
)
+
`);
+
+
// Migration: Add unique constraints if they don't exist
+
// SQLite doesn't support ALTER TABLE to add constraints, so we need to recreate the table
+
function migrateSchema() {
+
// Check if irc_channel has unique constraint by examining table schema
+
const channelSchema = db
+
.query("SELECT sql FROM sqlite_master WHERE type='table' AND name='channel_mappings'")
+
.get() as { sql: string } | null;
+
+
const hasIrcChannelUnique = channelSchema?.sql?.includes("irc_channel TEXT NOT NULL UNIQUE") ?? false;
+
+
if (!hasIrcChannelUnique && channelSchema) {
+
// Check if table has any data with duplicate irc_channel values
+
const duplicates = db.query(
+
"SELECT irc_channel, COUNT(*) as count FROM channel_mappings GROUP BY irc_channel HAVING count > 1",
+
).all();
+
+
if (duplicates.length > 0) {
+
console.warn(
+
"Warning: Found duplicate IRC channel mappings. Keeping only the most recent mapping for each IRC channel.",
+
);
+
for (const dup of duplicates as { irc_channel: string }[]) {
+
// Delete all but the most recent mapping for this IRC channel
+
db.run(
+
`DELETE FROM channel_mappings
+
WHERE irc_channel = ?
+
AND id NOT IN (
+
SELECT id FROM channel_mappings
+
WHERE irc_channel = ?
+
ORDER BY created_at DESC
+
LIMIT 1
+
)`,
+
[dup.irc_channel, dup.irc_channel],
+
);
+
}
+
}
+
+
// Recreate the table with unique constraint
+
db.run(`
+
CREATE TABLE channel_mappings_new (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
slack_channel_id TEXT NOT NULL UNIQUE,
+
irc_channel TEXT NOT NULL UNIQUE,
+
created_at INTEGER DEFAULT (strftime('%s', 'now'))
+
)
+
`);
+
+
db.run(
+
"INSERT INTO channel_mappings_new SELECT * FROM channel_mappings",
+
);
+
db.run("DROP TABLE channel_mappings");
+
db.run("ALTER TABLE channel_mappings_new RENAME TO channel_mappings");
+
console.log("Migrated channel_mappings table to add unique constraint on irc_channel");
+
}
+
+
// Check if irc_nick has unique constraint by examining table schema
+
const userSchema = db
+
.query("SELECT sql FROM sqlite_master WHERE type='table' AND name='user_mappings'")
+
.get() as { sql: string } | null;
+
+
const hasIrcNickUnique = userSchema?.sql?.includes("irc_nick TEXT NOT NULL UNIQUE") ?? false;
+
+
if (!hasIrcNickUnique && userSchema) {
+
// Check if table has any data with duplicate irc_nick values
+
const duplicates = db.query(
+
"SELECT irc_nick, COUNT(*) as count FROM user_mappings GROUP BY irc_nick HAVING count > 1",
+
).all();
+
+
if (duplicates.length > 0) {
+
console.warn(
+
"Warning: Found duplicate IRC nick mappings. Keeping only the most recent mapping for each IRC nick.",
+
);
+
for (const dup of duplicates as { irc_nick: string }[]) {
+
// Delete all but the most recent mapping for this IRC nick
+
db.run(
+
`DELETE FROM user_mappings
+
WHERE irc_nick = ?
+
AND id NOT IN (
+
SELECT id FROM user_mappings
+
WHERE irc_nick = ?
+
ORDER BY created_at DESC
+
LIMIT 1
+
)`,
+
[dup.irc_nick, dup.irc_nick],
+
);
+
}
+
}
+
+
// Recreate the table with unique constraint
+
db.run(`
+
CREATE TABLE user_mappings_new (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
slack_user_id TEXT NOT NULL UNIQUE,
+
irc_nick TEXT NOT NULL UNIQUE,
+
created_at INTEGER DEFAULT (strftime('%s', 'now'))
+
)
+
`);
+
+
db.run("INSERT INTO user_mappings_new SELECT * FROM user_mappings");
+
db.run("DROP TABLE user_mappings");
+
db.run("ALTER TABLE user_mappings_new RENAME TO user_mappings");
+
console.log("Migrated user_mappings table to add unique constraint on irc_nick");
+
}
+
}
+
+
migrateSchema();
+
+
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;
+
irc_channel: string;
+
created_at?: number;
+
}
+
+
export interface UserMapping {
+
id?: number;
+
slack_user_id: string;
+
irc_nick: string;
+
created_at?: number;
+
}
+
+
export const channelMappings = {
+
getAll(): ChannelMapping[] {
+
return db.query("SELECT * FROM channel_mappings").all() as ChannelMapping[];
+
},
+
+
getBySlackChannel(slackChannelId: string): ChannelMapping | null {
+
return db
+
.query("SELECT * FROM channel_mappings WHERE slack_channel_id = ?")
+
.get(slackChannelId) as ChannelMapping | null;
+
},
+
+
getByIrcChannel(ircChannel: string): ChannelMapping | null {
+
return db
+
.query("SELECT * FROM channel_mappings WHERE irc_channel = ?")
+
.get(ircChannel) as ChannelMapping | null;
+
},
+
+
create(slackChannelId: string, ircChannel: string): void {
+
db.run(
+
"INSERT OR REPLACE INTO channel_mappings (slack_channel_id, irc_channel) VALUES (?, ?)",
+
[slackChannelId, ircChannel],
+
);
+
},
+
+
delete(slackChannelId: string): void {
+
db.run("DELETE FROM channel_mappings WHERE slack_channel_id = ?", [
+
slackChannelId,
+
]);
+
},
+
};
+
+
export const userMappings = {
+
getAll(): UserMapping[] {
+
return db.query("SELECT * FROM user_mappings").all() as UserMapping[];
+
},
+
+
getBySlackUser(slackUserId: string): UserMapping | null {
+
return db
+
.query("SELECT * FROM user_mappings WHERE slack_user_id = ?")
+
.get(slackUserId) as UserMapping | null;
+
},
+
+
getByIrcNick(ircNick: string): UserMapping | null {
+
return db
+
.query("SELECT * FROM user_mappings WHERE irc_nick = ?")
+
.get(ircNick) as UserMapping | null;
+
},
+
+
create(slackUserId: string, ircNick: string): void {
+
db.run(
+
"INSERT OR REPLACE INTO user_mappings (slack_user_id, irc_nick) VALUES (?, ?)",
+
[slackUserId, ircNick],
+
);
+
},
+
+
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,
+
]);
+
},
+
};
+
+
export default db;
+56
src/lib/mentions.test.ts
···
···
+
import { describe, expect, test } from "bun:test";
+
import { convertIrcMentionsToSlack } from "./mentions";
+
import { userMappings } from "./db";
+
+
describe("convertIrcMentionsToSlack", () => {
+
test("converts @mention when user mapping exists", () => {
+
// Setup test data
+
userMappings.create("U123", "testuser");
+
+
const result = convertIrcMentionsToSlack("Hey @testuser how are you?");
+
expect(result).toBe("Hey <@U123> how are you?");
+
+
// Cleanup
+
userMappings.delete("U123");
+
});
+
+
test("leaves @mention unchanged when no mapping exists", () => {
+
const result = convertIrcMentionsToSlack("Hey @unknownuser");
+
expect(result).toBe("Hey @unknownuser");
+
});
+
+
test("converts nick: mention when user mapping exists", () => {
+
userMappings.create("U456", "alice");
+
+
const result = convertIrcMentionsToSlack("alice: hello");
+
expect(result).toBe("<@U456>: hello");
+
+
userMappings.delete("U456");
+
});
+
+
test("leaves nick: unchanged when no mapping exists", () => {
+
const result = convertIrcMentionsToSlack("bob: hello");
+
expect(result).toBe("bob: hello");
+
});
+
+
test("handles multiple mentions", () => {
+
userMappings.create("U123", "alice");
+
userMappings.create("U456", "bob");
+
+
const result = convertIrcMentionsToSlack("@alice and bob: hello!");
+
expect(result).toBe("<@U123> and <@U456>: hello!");
+
+
userMappings.delete("U123");
+
userMappings.delete("U456");
+
});
+
+
test("handles mixed mapped and unmapped mentions", () => {
+
userMappings.create("U123", "alice");
+
+
const result = convertIrcMentionsToSlack("@alice and @unknown user");
+
expect(result).toContain("<@U123>");
+
expect(result).toContain("@unknown");
+
+
userMappings.delete("U123");
+
});
+
});
+79
src/lib/mentions.ts
···
···
+
import { getCachetUser } from "./cachet";
+
import { userMappings } from "./db";
+
+
/**
+
* Converts IRC @mentions and nick: mentions to Slack user mentions
+
*/
+
export function convertIrcMentionsToSlack(messageText: string): string {
+
let result = messageText;
+
+
// Find all @mentions and nick: mentions in the IRC message
+
const atMentionPattern = /@(\w+)/g;
+
const nickMentionPattern = /(\w+):/g;
+
+
const atMentions = Array.from(result.matchAll(atMentionPattern));
+
const nickMentions = Array.from(result.matchAll(nickMentionPattern));
+
+
for (const match of atMentions) {
+
const mentionedNick = match[1] as string;
+
const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
+
if (mentionedUserMapping) {
+
result = result.replace(
+
match[0],
+
`<@${mentionedUserMapping.slack_user_id}>`,
+
);
+
}
+
}
+
+
for (const match of nickMentions) {
+
const mentionedNick = match[1] as string;
+
const mentionedUserMapping = userMappings.getByIrcNick(mentionedNick);
+
if (mentionedUserMapping) {
+
result = result.replace(
+
match[0],
+
`<@${mentionedUserMapping.slack_user_id}>:`,
+
);
+
}
+
}
+
+
return result;
+
}
+
+
/**
+
* Converts Slack user mentions to IRC @mentions
+
* Priority: user mappings > display name from mention > Cachet lookup
+
*/
+
export async function convertSlackMentionsToIrc(
+
messageText: string,
+
): Promise<string> {
+
let result = messageText;
+
const mentionRegex = /<@(U[A-Z0-9]+)(\|([^>]+))?>/g;
+
const mentions = Array.from(result.matchAll(mentionRegex));
+
+
for (const match of mentions) {
+
const userId = match[1] as string;
+
const displayName = match[3] as string; // The name part after |
+
+
// Check if user has a mapped IRC nick
+
const mentionedUserMapping = userMappings.getBySlackUser(userId);
+
if (mentionedUserMapping) {
+
result = result.replace(match[0], `@${mentionedUserMapping.irc_nick}`);
+
} else {
+
// Try Cachet lookup if enabled
+
if (process.env.CACHET_ENABLED === "true") {
+
const data = await getCachetUser(userId);
+
if (data) {
+
result = result.replace(match[0], `@${data.displayName}`);
+
continue;
+
}
+
}
+
+
// Fallback to display name from the mention format <@U123|name>
+
if (displayName) {
+
result = result.replace(match[0], `@${displayName}`);
+
}
+
}
+
}
+
+
return result;
+
}
+137
src/lib/parser.test.ts
···
···
+
import { describe, expect, test } from "bun:test";
+
import { parseIRCFormatting, parseSlackMarkdown } from "./parser";
+
+
describe("parseSlackMarkdown", () => {
+
test("converts channel mentions with name", () => {
+
const result = parseSlackMarkdown("Check out <#C123ABC|general>");
+
expect(result).toBe("Check out #general");
+
});
+
+
test("converts channel mentions without name", () => {
+
const result = parseSlackMarkdown("Check out <#C123ABC>");
+
expect(result).toBe("Check out #channel");
+
});
+
+
test("converts links with text", () => {
+
const result = parseSlackMarkdown(
+
"Visit <https://example.com|Example Site>",
+
);
+
expect(result).toBe("Visit Example Site (https://example.com)");
+
});
+
+
test("converts links without text", () => {
+
const result = parseSlackMarkdown("Visit <https://example.com>");
+
expect(result).toBe("Visit https://example.com");
+
});
+
+
test("converts mailto links", () => {
+
const result = parseSlackMarkdown(
+
"Email <mailto:test@example.com|Support>",
+
);
+
expect(result).toBe("Email Support <test@example.com>");
+
});
+
+
test("converts special mentions", () => {
+
expect(parseSlackMarkdown("<!here> everyone")).toBe("@here everyone");
+
expect(parseSlackMarkdown("<!channel> announcement")).toBe(
+
"@channel announcement",
+
);
+
expect(parseSlackMarkdown("<!everyone> alert")).toBe("@everyone alert");
+
});
+
+
test("converts user group mentions", () => {
+
const result = parseSlackMarkdown("Hey <!subteam^GROUP123|developers>");
+
expect(result).toBe("Hey @developers");
+
});
+
+
test("converts bold formatting", () => {
+
const result = parseSlackMarkdown("This is *bold* text");
+
expect(result).toBe("This is \x02bold\x02 text");
+
});
+
+
test("converts italic formatting", () => {
+
const result = parseSlackMarkdown("This is _italic_ text");
+
expect(result).toBe("This is \x1Ditalic\x1D text");
+
});
+
+
test("strips strikethrough formatting", () => {
+
const result = parseSlackMarkdown("This is ~strikethrough~ text");
+
expect(result).toBe("This is strikethrough text");
+
});
+
+
test("strips code blocks", () => {
+
const result = parseSlackMarkdown("Code: ```const x = 1;```");
+
expect(result).toBe("Code: const x = 1;");
+
});
+
+
test("strips inline code", () => {
+
const result = parseSlackMarkdown("Run `npm install` to start");
+
expect(result).toBe("Run npm install to start");
+
});
+
+
test("unescapes HTML entities", () => {
+
const result = parseSlackMarkdown("a &lt; b &amp;&amp; c &gt; d");
+
expect(result).toBe("a < b && c > d");
+
});
+
+
test("handles mixed formatting", () => {
+
const result = parseSlackMarkdown(
+
"*Bold* and _italic_ with <https://example.com|link>",
+
);
+
expect(result).toBe(
+
"\x02Bold\x02 and \x1Ditalic\x1D with link (https://example.com)",
+
);
+
});
+
});
+
+
describe("parseIRCFormatting", () => {
+
test("strips IRC color codes", () => {
+
const result = parseIRCFormatting("\x0304red text\x03 normal");
+
expect(result).toBe("red text normal");
+
});
+
+
test("converts bold formatting", () => {
+
const result = parseIRCFormatting("This is \x02bold\x02 text");
+
expect(result).toBe("This is *bold* text");
+
});
+
+
test("converts italic formatting", () => {
+
const result = parseIRCFormatting("This is \x1Ditalic\x1D text");
+
expect(result).toBe("This is _italic_ text");
+
});
+
+
test("converts underline to italic", () => {
+
const result = parseIRCFormatting("This is \x1Funderline\x1F text");
+
expect(result).toBe("This is _underline_ text");
+
});
+
+
test("strips reverse/inverse formatting", () => {
+
const result = parseIRCFormatting("Normal \x16reversed\x16 normal");
+
expect(result).toBe("Normal reversed normal");
+
});
+
+
test("strips reset formatting", () => {
+
const result = parseIRCFormatting("Text\x0F reset");
+
expect(result).toBe("Text reset");
+
});
+
+
test("escapes special Slack characters", () => {
+
const result = parseIRCFormatting("a < b & c > d");
+
expect(result).toBe("a &lt; b &amp; c &gt; d");
+
});
+
+
test("handles mixed formatting", () => {
+
const result = parseIRCFormatting("\x02Bold\x02 and \x1Ditalic\x1D");
+
expect(result).toBe("*Bold* and _italic_");
+
});
+
+
test("handles nested formatting codes", () => {
+
const result = parseIRCFormatting("\x02\x1Dbold italic\x1D\x02");
+
expect(result).toBe("*_bold italic_*");
+
});
+
+
test("handles color codes with background", () => {
+
const result = parseIRCFormatting("\x0304,08red on yellow\x03");
+
expect(result).toBe("red on yellow");
+
});
+
});
+88
src/lib/parser.ts
···
···
+
/**
+
* Parse Slack mrkdwn formatting and convert to IRC-friendly plain text
+
*/
+
export function parseSlackMarkdown(text: string): string {
+
let parsed = text;
+
+
// Replace channel mentions <#C123ABC|channel-name> or <#C123ABC>
+
parsed = parsed.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
+
parsed = parsed.replace(/<#[A-Z0-9]+>/g, "#channel");
+
+
// Replace links <http://example.com|text> or <http://example.com>
+
parsed = parsed.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "$2 ($1)");
+
parsed = parsed.replace(/<(https?:\/\/[^>]+)>/g, "$1");
+
+
// Replace mailto links <mailto:email|text>
+
parsed = parsed.replace(/<mailto:([^|>]+)\|([^>]+)>/g, "$2 <$1>");
+
parsed = parsed.replace(/<mailto:([^>]+)>/g, "$1");
+
+
// Replace special mentions
+
parsed = parsed.replace(/<!here>/g, "@here");
+
parsed = parsed.replace(/<!channel>/g, "@channel");
+
parsed = parsed.replace(/<!everyone>/g, "@everyone");
+
+
// Replace user group mentions <!subteam^GROUP_ID|handle>
+
parsed = parsed.replace(/<!subteam\^[A-Z0-9]+\|([^>]+)>/g, "@$1");
+
parsed = parsed.replace(/<!subteam\^[A-Z0-9]+>/g, "@group");
+
+
// Date formatting - just use fallback text
+
parsed = parsed.replace(/<!date\^[0-9]+\^[^|]+\|([^>]+)>/g, "$1");
+
+
// Replace Slack bold *text* with IRC bold \x02text\x02
+
parsed = parsed.replace(/\*((?:[^*]|\\\*)+)\*/g, "\x02$1\x02");
+
+
// Replace Slack italic _text_ with IRC italic \x1Dtext\x1D
+
parsed = parsed.replace(/_((?:[^_]|\\_)+)_/g, "\x1D$1\x1D");
+
+
// Replace Slack strikethrough ~text~ with plain text (IRC doesn't support strikethrough well)
+
parsed = parsed.replace(/~((?:[^~]|\\~)+)~/g, "$1");
+
+
// Replace code blocks ```code``` with plain text
+
parsed = parsed.replace(/```([^`]+)```/g, "$1");
+
+
// Replace inline code `code` with plain text
+
parsed = parsed.replace(/`([^`]+)`/g, "$1");
+
+
// Handle block quotes - prefix with >
+
parsed = parsed.replace(/^>/gm, ">");
+
+
// Unescape HTML entities
+
parsed = parsed.replace(/&amp;/g, "&");
+
parsed = parsed.replace(/&lt;/g, "<");
+
parsed = parsed.replace(/&gt;/g, ">");
+
+
return parsed;
+
}
+
+
/**
+
* Parse IRC formatting codes and convert to Slack mrkdwn
+
*/
+
export function parseIRCFormatting(text: string): string {
+
let parsed = text;
+
+
// IRC color codes - strip them (Slack doesn't support colors in the same way)
+
// \x03 followed by optional color codes
+
parsed = parsed.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "");
+
+
// IRC bold \x02text\x02 -> Slack bold *text*
+
parsed = parsed.replace(/\x02([^\x02]*)\x02/g, "*$1*");
+
+
// IRC italic \x1D text\x1D -> Slack italic _text_
+
parsed = parsed.replace(/\x1D([^\x1D]*)\x1D/g, "_$1_");
+
+
// IRC underline \x1F text\x1F -> Slack doesn't have underline, use italic instead
+
parsed = parsed.replace(/\x1F([^\x1F]*)\x1F/g, "_$1_");
+
+
// IRC reverse/inverse \x16 - strip it (Slack doesn't support)
+
parsed = parsed.replace(/\x16/g, "");
+
+
// IRC reset \x0F - strip it
+
parsed = parsed.replace(/\x0F/g, "");
+
+
// Escape special Slack characters that would be interpreted as formatting
+
parsed = parsed.replace(/&/g, "&amp;");
+
parsed = parsed.replace(/</g, "&lt;");
+
parsed = parsed.replace(/>/g, "&gt;");
+
+
return parsed;
+
}
+99
src/lib/permissions.ts
···
···
+
interface SlackChannel {
+
id: string;
+
created: number;
+
creator: string;
+
name: string;
+
is_channel: boolean;
+
is_private: boolean;
+
is_archived: boolean;
+
[key: string]: unknown;
+
}
+
+
interface ConversationsInfoResponse {
+
ok: boolean;
+
channel?: SlackChannel;
+
error?: string;
+
}
+
+
interface RoleAssignmentsResponse {
+
ok: boolean;
+
role_assignments?: Array<{
+
users: string[];
+
}>;
+
error?: string;
+
}
+
+
/**
+
* Check if a user has permission to manage a Slack channel
+
* Returns true if the user is:
+
* - A global admin (in ADMINS env var)
+
* - The channel creator
+
* - A channel manager
+
*/
+
export async function canManageChannel(
+
userId: string,
+
channelId: string,
+
): Promise<boolean> {
+
// Check if user is a global admin
+
const admins = process.env.ADMINS?.split(",").map((id) => id.trim()) || [];
+
if (admins.includes(userId)) {
+
return true;
+
}
+
+
try {
+
// Check if user is channel creator
+
const formdata = new FormData();
+
formdata.append("channel", channelId);
+
+
const channelInfo: ConversationsInfoResponse = (await fetch(
+
"https://slack.com/api/conversations.info",
+
{
+
method: "POST",
+
headers: {
+
Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+
},
+
body: formdata,
+
},
+
).then((res) => res.json())) as ConversationsInfoResponse;
+
+
if (channelInfo.ok && channelInfo.channel?.creator === userId) {
+
return true;
+
}
+
+
// Check if user is a channel manager
+
if (
+
process.env.SLACK_USER_COOKIE &&
+
process.env.SLACK_USER_TOKEN &&
+
process.env.SLACK_API_URL
+
) {
+
const formdata = new FormData();
+
formdata.append("token", process.env.SLACK_USER_TOKEN);
+
formdata.append("entity_id", channelId);
+
+
const response = await fetch(
+
`${process.env.SLACK_API_URL}/api/admin.roles.entity.listAssignments`,
+
{
+
method: "POST",
+
headers: {
+
Cookie: process.env.SLACK_USER_COOKIE,
+
},
+
body: formdata,
+
},
+
);
+
+
const json: RoleAssignmentsResponse =
+
(await response.json()) as RoleAssignmentsResponse;
+
+
if (json.ok) {
+
const managers = json.role_assignments?.[0]?.users || [];
+
if (managers.includes(userId)) {
+
return true;
+
}
+
}
+
}
+
} catch (error) {
+
console.error("Error checking channel permissions:", error);
+
}
+
+
return false;
+
}
+106
src/lib/threads.test.ts
···
···
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+
import {
+
generateThreadId,
+
getThreadByThreadId,
+
isFirstThreadMessage,
+
updateThreadTimestamp,
+
} from "./threads";
+
import { threadTimestamps } from "./db";
+
+
describe("threads", () => {
+
const testChannelId = "C123TEST";
+
const testThreadTs = "1234567890.123456";
+
+
afterEach(() => {
+
// Clean up test data
+
const thread = threadTimestamps.get(testThreadTs);
+
if (thread) {
+
threadTimestamps.cleanup(Date.now() + 1000);
+
}
+
});
+
+
describe("generateThreadId", () => {
+
test("generates a 5-character thread ID", () => {
+
const threadId = generateThreadId(testThreadTs);
+
expect(threadId).toBeString();
+
expect(threadId.length).toBe(5);
+
});
+
+
test("generates consistent IDs for same input", () => {
+
const id1 = generateThreadId(testThreadTs);
+
const id2 = generateThreadId(testThreadTs);
+
expect(id1).toBe(id2);
+
});
+
+
test("generates different IDs for different inputs", () => {
+
const id1 = generateThreadId("1234567890.123456");
+
const id2 = generateThreadId("9876543210.654321");
+
expect(id1).not.toBe(id2);
+
});
+
+
test("generates alphanumeric IDs", () => {
+
const threadId = generateThreadId(testThreadTs);
+
expect(threadId).toMatch(/^[a-z0-9]{5}$/);
+
});
+
});
+
+
describe("isFirstThreadMessage", () => {
+
test("returns true for new thread", () => {
+
const result = isFirstThreadMessage(testThreadTs);
+
expect(result).toBe(true);
+
});
+
+
test("returns false for existing thread", () => {
+
updateThreadTimestamp(testThreadTs, testChannelId);
+
const result = isFirstThreadMessage(testThreadTs);
+
expect(result).toBe(false);
+
});
+
});
+
+
describe("updateThreadTimestamp", () => {
+
test("creates new thread entry", () => {
+
const threadId = updateThreadTimestamp(testThreadTs, testChannelId);
+
+
expect(threadId).toBeString();
+
expect(threadId.length).toBe(5);
+
+
const thread = threadTimestamps.get(testThreadTs);
+
expect(thread).toBeDefined();
+
expect(thread?.thread_id).toBe(threadId);
+
expect(thread?.slack_channel_id).toBe(testChannelId);
+
});
+
+
test("updates existing thread timestamp", () => {
+
const threadId1 = updateThreadTimestamp(testThreadTs, testChannelId);
+
const thread1 = threadTimestamps.get(testThreadTs);
+
const timestamp1 = thread1?.last_message_time;
+
+
// Wait a bit to ensure timestamp changes
+
Bun.sleepSync(10);
+
+
const threadId2 = updateThreadTimestamp(testThreadTs, testChannelId);
+
const thread2 = threadTimestamps.get(testThreadTs);
+
const timestamp2 = thread2?.last_message_time;
+
+
expect(threadId1).toBe(threadId2);
+
expect(timestamp2).toBeGreaterThan(timestamp1!);
+
});
+
});
+
+
describe("getThreadByThreadId", () => {
+
test("retrieves thread by thread ID", () => {
+
const threadId = updateThreadTimestamp(testThreadTs, testChannelId);
+
const thread = getThreadByThreadId(threadId);
+
+
expect(thread).toBeDefined();
+
expect(thread?.thread_ts).toBe(testThreadTs);
+
expect(thread?.thread_id).toBe(threadId);
+
expect(thread?.slack_channel_id).toBe(testChannelId);
+
});
+
+
test("returns null for non-existent thread ID", () => {
+
const thread = getThreadByThreadId("xxxxx");
+
expect(thread).toBeNull();
+
});
+
});
+
});
+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);
+
}
+168
src/lib/user-cache.test.ts
···
···
+
import { afterEach, describe, expect, mock, test } from "bun:test";
+
import { cleanupUserCache, getUserInfo } from "./user-cache";
+
+
describe("user-cache", () => {
+
const mockSlackClient = {
+
users: {
+
info: mock(async () => ({
+
user: {
+
name: "testuser",
+
real_name: "Test User",
+
},
+
})),
+
},
+
};
+
+
afterEach(() => {
+
cleanupUserCache();
+
mockSlackClient.users.info.mockClear();
+
mockSlackClient.users.info.mockReset();
+
});
+
+
describe("getUserInfo", () => {
+
test("uses display name from event if provided", async () => {
+
const client = {
+
users: {
+
info: mock(async () => ({
+
user: {
+
name: "testuser",
+
real_name: "Test User",
+
},
+
})),
+
},
+
};
+
+
const result = await getUserInfo("U123", client, "Event Display Name");
+
+
expect(result).toEqual({
+
name: "Event Display Name",
+
realName: "Event Display Name",
+
});
+
// Should not call API when display name provided
+
expect(client.users.info).toHaveBeenCalledTimes(0);
+
});
+
+
test("fetches user info from Slack on cache miss", async () => {
+
const client = {
+
users: {
+
info: mock(async () => ({
+
user: {
+
name: "testuser",
+
real_name: "Test User",
+
},
+
})),
+
},
+
};
+
+
const result = await getUserInfo("U125", client);
+
+
expect(result).toEqual({
+
name: "testuser",
+
realName: "Test User",
+
});
+
expect(client.users.info).toHaveBeenCalledTimes(1);
+
});
+
+
test("returns cached data on cache hit", async () => {
+
const client = {
+
users: {
+
info: mock(async () => ({
+
user: {
+
name: "testuser",
+
real_name: "Test User",
+
},
+
})),
+
},
+
};
+
+
// First call - cache miss
+
await getUserInfo("U124", client);
+
expect(client.users.info).toHaveBeenCalledTimes(1);
+
+
// Second call - cache hit
+
const result = await getUserInfo("U124", client);
+
expect(result).toEqual({
+
name: "testuser",
+
realName: "Test User",
+
});
+
expect(client.users.info).toHaveBeenCalledTimes(1); // Still 1
+
});
+
+
test("uses name as fallback for real_name", async () => {
+
const client = {
+
users: {
+
info: mock(async () => ({
+
user: {
+
name: "testuser",
+
},
+
})),
+
},
+
};
+
+
const result = await getUserInfo("U456", client);
+
expect(result).toEqual({
+
name: "testuser",
+
realName: "testuser",
+
});
+
});
+
+
test("handles missing user data gracefully", async () => {
+
const client = {
+
users: {
+
info: mock(async () => ({})),
+
},
+
};
+
+
const result = await getUserInfo("U789", client);
+
expect(result).toEqual({
+
name: "Unknown",
+
realName: "Unknown",
+
});
+
});
+
+
test("handles Slack API errors", async () => {
+
const client = {
+
users: {
+
info: mock(async () => {
+
throw new Error("API Error");
+
}),
+
},
+
};
+
+
const result = await getUserInfo("U999", client);
+
expect(result).toBeNull();
+
});
+
+
test("caches different users separately", async () => {
+
const client = {
+
users: {
+
info: mock(async ({ user }: { user: string }) => {
+
if (user === "U111") {
+
return { user: { name: "alice", real_name: "Alice" } };
+
}
+
return { user: { name: "bob", real_name: "Bob" } };
+
}),
+
},
+
};
+
+
const result1 = await getUserInfo("U111", client);
+
const result2 = await getUserInfo("U222", client);
+
+
expect(result1?.name).toBe("alice");
+
expect(result2?.name).toBe("bob");
+
expect(client.users.info).toHaveBeenCalledTimes(2);
+
+
// Both should be cached now
+
await getUserInfo("U111", client);
+
await getUserInfo("U222", client);
+
expect(client.users.info).toHaveBeenCalledTimes(2); // Still 2
+
});
+
});
+
+
describe("cleanupUserCache", () => {
+
test("cleanup runs without errors", () => {
+
// Just test that cleanup doesn't throw
+
expect(() => cleanupUserCache()).not.toThrow();
+
});
+
});
+
});
+104
src/lib/user-cache.ts
···
···
+
import { getCachetUser } from "./cachet";
+
+
interface CachedUserInfo {
+
name: string;
+
realName: string;
+
timestamp: number;
+
}
+
+
interface SlackClient {
+
users: {
+
info: (params: { token: string; user: string }) => Promise<{
+
user?: {
+
name?: string;
+
real_name?: string;
+
};
+
}>;
+
};
+
}
+
+
const userCache = new Map<string, CachedUserInfo>();
+
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
+
+
/**
+
* Get user info from cache or fetch from Cachet (if enabled) or Slack API
+
* If displayName is provided (from Slack event), use that directly and cache it
+
*/
+
export async function getUserInfo(
+
userId: string,
+
slackClient: SlackClient,
+
displayName?: string,
+
): Promise<{ name: string; realName: string } | null> {
+
const cached = userCache.get(userId);
+
const now = Date.now();
+
+
if (cached && now - cached.timestamp < CACHE_TTL) {
+
return { name: cached.name, realName: cached.realName };
+
}
+
+
// If we have a display name from the event, use it directly
+
if (displayName) {
+
userCache.set(userId, {
+
name: displayName,
+
realName: displayName,
+
timestamp: now,
+
});
+
+
return { name: displayName, realName: displayName };
+
}
+
+
// Try Cachet first if enabled (it has its own caching)
+
if (process.env.CACHET_ENABLED === "true") {
+
try {
+
const cachetUser = await getCachetUser(userId);
+
if (cachetUser) {
+
const name = cachetUser.displayName || "Unknown";
+
const realName = cachetUser.displayName || "Unknown";
+
+
userCache.set(userId, {
+
name,
+
realName,
+
timestamp: now,
+
});
+
+
return { name, realName };
+
}
+
} catch (error) {
+
console.error(`Error fetching user from Cachet for ${userId}:`, error);
+
}
+
}
+
+
// Fallback to Slack API
+
try {
+
const userInfo = await slackClient.users.info({
+
token: process.env.SLACK_BOT_TOKEN,
+
user: userId,
+
});
+
+
const name = userInfo.user?.name || "Unknown";
+
const realName = userInfo.user?.real_name || name;
+
+
userCache.set(userId, {
+
name,
+
realName,
+
timestamp: now,
+
});
+
+
return { name, realName };
+
} catch (error) {
+
console.error(`Error fetching user info for ${userId}:`, error);
+
return null;
+
}
+
}
+
+
/**
+
* Clear expired entries from cache
+
*/
+
export function cleanupUserCache(): void {
+
const now = Date.now();
+
for (const [userId, info] of userCache.entries()) {
+
if (now - info.timestamp > CACHE_TTL) {
+
userCache.delete(userId);
+
}
+
}
+
}
-88
src/parser.ts
···
-
/**
-
* Parse Slack mrkdwn formatting and convert to IRC-friendly plain text
-
*/
-
export function parseSlackMarkdown(text: string): string {
-
let parsed = text;
-
-
// Replace channel mentions <#C123ABC|channel-name> or <#C123ABC>
-
parsed = parsed.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
-
parsed = parsed.replace(/<#[A-Z0-9]+>/g, "#channel");
-
-
// Replace links <http://example.com|text> or <http://example.com>
-
parsed = parsed.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "$2 ($1)");
-
parsed = parsed.replace(/<(https?:\/\/[^>]+)>/g, "$1");
-
-
// Replace mailto links <mailto:email|text>
-
parsed = parsed.replace(/<mailto:([^|>]+)\|([^>]+)>/g, "$2 <$1>");
-
parsed = parsed.replace(/<mailto:([^>]+)>/g, "$1");
-
-
// Replace special mentions
-
parsed = parsed.replace(/<!here>/g, "@here");
-
parsed = parsed.replace(/<!channel>/g, "@channel");
-
parsed = parsed.replace(/<!everyone>/g, "@everyone");
-
-
// Replace user group mentions <!subteam^GROUP_ID|handle>
-
parsed = parsed.replace(/<!subteam\^[A-Z0-9]+\|([^>]+)>/g, "@$1");
-
parsed = parsed.replace(/<!subteam\^[A-Z0-9]+>/g, "@group");
-
-
// Date formatting - just use fallback text
-
parsed = parsed.replace(/<!date\^[0-9]+\^[^|]+\|([^>]+)>/g, "$1");
-
-
// Replace Slack bold *text* with IRC bold \x02text\x02
-
parsed = parsed.replace(/\*((?:[^\*]|\\\*)+)\*/g, "\x02$1\x02");
-
-
// Replace Slack italic _text_ with IRC italic \x1Dtext\x1D
-
parsed = parsed.replace(/_((?:[^_]|\\_)+)_/g, "\x1D$1\x1D");
-
-
// Replace Slack strikethrough ~text~ with plain text (IRC doesn't support strikethrough well)
-
parsed = parsed.replace(/~((?:[^~]|\\~)+)~/g, "$1");
-
-
// Replace code blocks ```code``` with plain text
-
parsed = parsed.replace(/```([^`]+)```/g, "$1");
-
-
// Replace inline code `code` with plain text
-
parsed = parsed.replace(/`([^`]+)`/g, "$1");
-
-
// Handle block quotes - prefix with >
-
parsed = parsed.replace(/^>/gm, ">");
-
-
// Unescape HTML entities
-
parsed = parsed.replace(/&amp;/g, "&");
-
parsed = parsed.replace(/&lt;/g, "<");
-
parsed = parsed.replace(/&gt;/g, ">");
-
-
return parsed;
-
}
-
-
/**
-
* Parse IRC formatting codes and convert to Slack mrkdwn
-
*/
-
export function parseIRCFormatting(text: string): string {
-
let parsed = text;
-
-
// IRC color codes - strip them (Slack doesn't support colors in the same way)
-
// \x03 followed by optional color codes
-
parsed = parsed.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "");
-
-
// IRC bold \x02text\x02 -> Slack bold *text*
-
parsed = parsed.replace(/\x02([^\x02]*)\x02/g, "*$1*");
-
-
// IRC italic \x1D text\x1D -> Slack italic _text_
-
parsed = parsed.replace(/\x1D([^\x1D]*)\x1D/g, "_$1_");
-
-
// IRC underline \x1F text\x1F -> Slack doesn't have underline, use italic instead
-
parsed = parsed.replace(/\x1F([^\x1F]*)\x1F/g, "_$1_");
-
-
// IRC reverse/inverse \x16 - strip it (Slack doesn't support)
-
parsed = parsed.replace(/\x16/g, "");
-
-
// IRC reset \x0F - strip it
-
parsed = parsed.replace(/\x0F/g, "");
-
-
// Escape special Slack characters that would be interpreted as formatting
-
parsed = parsed.replace(/&/g, "&amp;");
-
parsed = parsed.replace(/</g, "&lt;");
-
parsed = parsed.replace(/>/g, "&gt;");
-
-
return parsed;
-
}
···
-71
src/permissions.ts
···
-
/**
-
* Check if a user has permission to manage a Slack channel
-
* Returns true if the user is:
-
* - A global admin (in ADMINS env var)
-
* - The channel creator
-
* - A channel manager
-
*/
-
export async function canManageChannel(
-
userId: string,
-
channelId: string,
-
): Promise<boolean> {
-
// Check if user is a global admin
-
const admins = process.env.ADMINS?.split(",").map((id) => id.trim()) || [];
-
if (admins.includes(userId)) {
-
return true;
-
}
-
-
try {
-
// Check if user is channel creator
-
const channelInfo = await fetch(
-
"https://slack.com/api/conversations.info",
-
{
-
method: "POST",
-
headers: {
-
"Content-Type": "application/json",
-
Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
-
},
-
body: JSON.stringify({ channel: channelId }),
-
},
-
).then((res) => res.json());
-
-
if (channelInfo.ok && channelInfo.channel?.creator === userId) {
-
return true;
-
}
-
-
// Check if user is a channel manager
-
if (
-
process.env.SLACK_USER_COOKIE &&
-
process.env.SLACK_USER_TOKEN &&
-
process.env.SLACK_API_URL
-
) {
-
const formdata = new FormData();
-
formdata.append("token", process.env.SLACK_USER_TOKEN);
-
formdata.append("entity_id", channelId);
-
-
const response = await fetch(
-
`${process.env.SLACK_API_URL}/api/admin.roles.entity.listAssignments`,
-
{
-
method: "POST",
-
headers: {
-
Cookie: process.env.SLACK_USER_COOKIE,
-
},
-
body: formdata,
-
},
-
);
-
-
const json = await response.json();
-
-
if (json.ok) {
-
const managers = json.role_assignments?.[0]?.users || [];
-
if (managers.includes(userId)) {
-
return true;
-
}
-
}
-
}
-
} catch (error) {
-
console.error("Error checking channel permissions:", error);
-
}
-
-
return false;
-
}
···
+7
src/types.ts
···
imageUrl: string;
expiration: string;
}
···
imageUrl: string;
expiration: string;
}
+
+
export interface CDNUploadResponse {
+
files: Array<{
+
deployedUrl: string;
+
originalUrl: string;
+
}>;
+
}