this repo has no description

Compare changes

Choose any two refs to compare.

+16 -1
.env.example
···
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
# IRC Configuration
IRC_NICK=slackbridge
# Admin users (comma-separated Slack user IDs)
-
ADMINS=U1234567890
# Server Configuration (optional)
PORT=3000
···
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
+
# Slack workspace URL (for admin API calls)
+
SLACK_API_URL=https://hackclub.enterprise.slack.com
+
+
# 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
+
NICKSERV_PASSWORD=your-nickserv-password-here
+
NICKSERV_EMAIL=your-email@example.com
# Admin users (comma-separated Slack user IDs)
+
ADMINS=U1234567890,U0987654321
+
+
# Hack Club CDN Token (for file uploads)
+
CDN_TOKEN=your-cdn-token-here
# Server Configuration (optional)
PORT=3000
+97 -3
README.md
···
bun dev
```
### Slack App Setup
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app
···
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
# IRC Configuration
IRC_NICK=slackbridge
# Admin users (comma-separated Slack user IDs)
-
ADMINS=U1234567890
# Server Configuration (optional)
PORT=3000
```
See `.env.example` for a template.
### Managing Channel and User Mappings
Channel and user mappings are stored in a SQLite database (`bridge.db`). You can manage them through:
···
**Using Bun REPL:**
```bash
bun repl
-
> import { channelMappings, userMappings } from "./src/db"
> channelMappings.create("C1234567890", "#general")
> userMappings.create("U1234567890", "myircnick")
> channelMappings.getAll()
···
The bridge connects to `irc.hackclub.com:6667` (no TLS) and forwards messages bidirectionally based on channel mappings:
- **IRC โ†’ Slack**: Messages from mapped IRC channels appear in their corresponding Slack channels
- **Slack โ†’ IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels
-
- User mappings allow custom IRC nicknames for specific Slack users
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
···
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
+
# Slack workspace URL (for admin API calls)
+
SLACK_API_URL=https://hackclub.enterprise.slack.com
+
+
# 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
+
NICKSERV_PASSWORD=your-nickserv-password-here
+
NICKSERV_EMAIL=your-email@example.com
# Admin users (comma-separated Slack user IDs)
+
ADMINS=U1234567890,U0987654321
+
+
# Hack Club CDN Token (for file uploads)
+
CDN_TOKEN=your-cdn-token-here
# Server Configuration (optional)
PORT=3000
+
+
# Note: Channel and user mappings are now stored in the SQLite database (bridge.db)
+
# Use the API or database tools to manage mappings
```
See `.env.example` for a template.
+
### Slash Commands
+
+
The bridge provides interactive slash commands for managing mappings:
+
+
- `/irc-bridge-channel` - Bridge current Slack channel to an IRC channel
+
- `/irc-unbridge-channel` - Remove bridge from current channel
+
- `/irc-bridge-user` - Link your Slack account to an IRC nickname
+
- `/irc-unbridge-user` - Remove your IRC nickname link
+
- `/irc-bridge-list` - List all channel and user bridges
+
### Managing Channel and User Mappings
Channel and user mappings are stored in a SQLite database (`bridge.db`). You can manage them through:
···
**Using Bun REPL:**
```bash
bun repl
+
> import { channelMappings, userMappings } from "./src/lib/db"
> channelMappings.create("C1234567890", "#general")
> userMappings.create("U1234567890", "myircnick")
> channelMappings.getAll()
···
The bridge connects to `irc.hackclub.com:6667` (no TLS) and forwards messages bidirectionally based on channel mappings:
+
- **NickServ Authentication**: If `NICKSERV_PASSWORD` is configured, the bridge authenticates on connect
+
- Waits for NickServ confirmation before joining channels
+
- Auto-registers the nick if not registered (requires `NICKSERV_EMAIL`)
+
- Prevents "No external channel messages" errors by ensuring proper authentication
- **IRC โ†’ Slack**: Messages from mapped IRC channels appear in their corresponding Slack channels
+
- 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.
+37
biome.json
···
···
+
{
+
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
+
"vcs": {
+
"enabled": true,
+
"clientKind": "git",
+
"useIgnoreFile": true
+
},
+
"files": {
+
"ignoreUnknown": false
+
},
+
"formatter": {
+
"enabled": true,
+
"indentStyle": "tab"
+
},
+
"linter": {
+
"enabled": true,
+
"rules": {
+
"recommended": true,
+
"suspicious": {
+
"noControlCharactersInRegex": "off"
+
}
+
}
+
},
+
"javascript": {
+
"formatter": {
+
"quoteStyle": "double"
+
}
+
},
+
"assist": {
+
"enabled": true,
+
"actions": {
+
"source": {
+
"organizeImports": "on"
+
}
+
}
+
}
+
}
+21 -21
package.json
···
{
-
"name": "irc-slack-bridge",
-
"version": "0.0.1",
-
"module": "src/index.ts",
-
"type": "module",
-
"private": true,
-
"scripts": {
-
"dev": "bun src/index.ts",
-
"start": "bun src/index.ts",
-
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
-
},
-
"devDependencies": {
-
"@types/bun": "latest",
-
"@types/irc": "^0.5.4"
-
},
-
"peerDependencies": {
-
"typescript": "^5"
-
},
-
"dependencies": {
-
"irc": "^0.5.2",
-
"slack-edge": "^1.3.12"
-
}
}
···
{
+
"name": "irc-slack-bridge",
+
"version": "0.0.1",
+
"module": "src/index.ts",
+
"type": "module",
+
"private": true,
+
"scripts": {
+
"dev": "bun src/index.ts",
+
"start": "bun src/index.ts",
+
"ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app"
+
},
+
"devDependencies": {
+
"@types/bun": "latest",
+
"@types/irc": "^0.5.4"
+
},
+
"peerDependencies": {
+
"typescript": "^5"
+
},
+
"dependencies": {
+
"irc": "^0.5.2",
+
"slack-edge": "^1.3.12"
+
}
}
+5 -1
slack-manifest.yaml
···
- command: /irc-bridge-user
url: https://casual-renewing-reptile.ngrok-free.app/slack
description: Link your Slack account to an IRC nickname
-
usage_hint: "irc-nick"
should_escape: true
- command: /irc-unbridge-user
url: https://casual-renewing-reptile.ngrok-free.app/slack
···
- chat:write.public
- chat:write.customize
- commands
- groups:read
- groups:write
- mpim:write
···
request_url: https://casual-renewing-reptile.ngrok-free.app/slack
bot_events:
- message.channels
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
···
- command: /irc-bridge-user
url: https://casual-renewing-reptile.ngrok-free.app/slack
description: Link your Slack account to an IRC nickname
+
usage_hint: irc-nick
should_escape: true
- command: /irc-unbridge-user
url: https://casual-renewing-reptile.ngrok-free.app/slack
···
- chat:write.public
- chat:write.customize
- commands
+
- files:read
- groups:read
- groups:write
- mpim:write
···
request_url: https://casual-renewing-reptile.ngrok-free.app/slack
bot_events:
- message.channels
+
interactivity:
+
is_enabled: true
+
request_url: https://casual-renewing-reptile.ngrok-free.app/slack
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
+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");
+
});
+
});
+502 -120
src/commands.ts
···
-
import { channelMappings, userMappings } from "./db";
-
import { slackApp, ircClient } from "./index";
export function registerCommands() {
-
// Link Slack channel to IRC channel
-
slackApp.command("/irc-bridge-channel", async ({ payload, context }) => {
-
const args = payload.text.trim().split(/\s+/);
-
const ircChannel = args[0];
-
if (!ircChannel || !ircChannel.startsWith("#")) {
-
return {
-
text: "Usage: `/irc-bridge-channel #irc-channel`\nExample: `/irc-bridge-channel #lounge`",
-
};
-
}
-
const slackChannelId = payload.channel_id;
-
try {
-
// Create the mapping
-
channelMappings.create(slackChannelId, ircChannel);
-
// Join the IRC channel
-
ircClient.join(ircChannel);
-
// Join the Slack channel if not already in it
-
await context.client.conversations.join({
-
channel: slackChannelId,
-
});
-
return {
-
text: `โœ… Successfully bridged this channel to ${ircChannel}`,
-
};
-
} catch (error) {
-
console.error("Error creating channel mapping:", error);
-
return {
-
text: `โŒ Failed to bridge channel: ${error}`,
-
};
-
}
-
});
-
// Unlink Slack channel from IRC
-
slackApp.command("/irc-unbridge-channel", async ({ payload }) => {
-
const slackChannelId = payload.channel_id;
-
try {
-
const mapping = channelMappings.getBySlackChannel(slackChannelId);
-
if (!mapping) {
-
return {
-
text: "โŒ This channel is not bridged to IRC",
-
};
-
}
-
channelMappings.delete(slackChannelId);
-
return {
-
text: `โœ… Removed bridge to ${mapping.irc_channel}`,
-
};
-
} catch (error) {
-
console.error("Error removing channel mapping:", error);
-
return {
-
text: `โŒ Failed to remove bridge: ${error}`,
-
};
-
}
-
});
-
// Link Slack user to IRC nick
-
slackApp.command("/irc-bridge-user", async ({ payload }) => {
-
const args = payload.text.trim().split(/\s+/);
-
const ircNick = args[0];
-
if (!ircNick) {
-
return {
-
text: "Usage: `/irc-bridge-user <irc-nick>`\nExample: `/irc-bridge-user myircnick`",
-
};
-
}
-
const slackUserId = payload.user_id;
-
try {
-
userMappings.create(slackUserId, ircNick);
-
console.log(`Created user mapping: ${slackUserId} -> ${ircNick}`);
-
return {
-
text: `โœ… Successfully linked your account to IRC nick: ${ircNick}`,
-
};
-
} catch (error) {
-
console.error("Error creating user mapping:", error);
-
return {
-
text: `โŒ Failed to link user: ${error}`,
-
};
-
}
-
});
-
// Unlink Slack user from IRC
-
slackApp.command("/irc-unbridge-user", async ({ payload }) => {
-
const slackUserId = payload.user_id;
-
try {
-
const mapping = userMappings.getBySlackUser(slackUserId);
-
if (!mapping) {
-
return {
-
text: "โŒ You don't have an IRC nick mapping",
-
};
-
}
-
userMappings.delete(slackUserId);
-
return {
-
text: `โœ… Removed link to IRC nick: ${mapping.irc_nick}`,
-
};
-
} catch (error) {
-
console.error("Error removing user mapping:", error);
-
return {
-
text: `โŒ Failed to remove link: ${error}`,
-
};
-
}
-
});
-
// List channel mappings
-
slackApp.command("/irc-bridge-list", async ({ payload }) => {
-
const channelMaps = channelMappings.getAll();
-
const userMaps = userMappings.getAll();
-
let text = "*Channel Bridges:*\n";
-
if (channelMaps.length === 0) {
-
text += "None\n";
-
} else {
-
for (const map of channelMaps) {
-
text += `โ€ข <#${map.slack_channel_id}> โ†”๏ธ ${map.irc_channel}\n`;
-
}
-
}
-
text += "\n*User Mappings:*\n";
-
if (userMaps.length === 0) {
-
text += "None\n";
-
} else {
-
for (const map of userMaps) {
-
text += `โ€ข <@${map.slack_user_id}> โ†”๏ธ ${map.irc_nick}\n`;
-
}
-
}
-
return {
-
text,
-
};
-
});
}
···
+
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
+
slackApp.command("/irc-bridge-channel", async ({ payload, context }) => {
+
context.respond({
+
response_type: "ephemeral",
+
text: "Bridge channel command received",
+
blocks: [
+
{
+
type: "input",
+
block_id: "irc_channel_input",
+
element: {
+
type: "plain_text_input",
+
action_id: "irc_channel",
+
placeholder: {
+
type: "plain_text",
+
text: "#lounge",
+
},
+
},
+
label: {
+
type: "plain_text",
+
text: "IRC Channel",
+
},
+
},
+
{
+
type: "actions",
+
elements: [
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Bridge Channel",
+
},
+
style: "primary",
+
action_id: "bridge_channel_submit",
+
value: payload.channel_id,
+
},
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Cancel",
+
},
+
action_id: "cancel",
+
},
+
],
+
},
+
],
+
replace_original: true,
+
});
+
});
+
// Handle bridge channel submission
+
slackApp.action("bridge_channel_submit", async ({ payload, context }) => {
+
const stateValues = payload.state?.values;
+
const ircChannel = stateValues?.irc_channel_input?.irc_channel?.value;
+
// @ts-expect-error
+
const slackChannelId = payload.actions?.[0]?.value;
+
const userId = payload.user?.id;
+
if (!context.respond) {
+
return;
+
}
+
if (!ircChannel || !ircChannel.startsWith("#")) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ IRC channel must start with #",
+
replace_original: true,
+
});
+
return;
+
}
+
// Check if user has permission to manage this channel
+
if (!userId || !(await canManageChannel(userId, slackChannelId))) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
+
replace_original: true,
+
});
+
return;
+
}
+
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);
+
await context.client.conversations.join({
+
channel: slackChannelId,
+
});
+
console.log(
+
`Created channel mapping: ${slackChannelId} -> ${ircChannel}`,
+
);
+
context.respond({
+
response_type: "ephemeral",
+
text: `โœ… Successfully bridged <#${slackChannelId}> to ${ircChannel}`,
+
replace_original: true,
+
});
+
} catch (error) {
+
console.error("Error creating channel mapping:", error);
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ Failed to bridge channel: ${error}`,
+
replace_original: true,
+
});
+
}
+
});
+
// Unlink Slack channel from IRC
+
slackApp.command("/irc-unbridge-channel", async ({ payload, context }) => {
+
const slackChannelId = payload.channel_id;
+
const userId = payload.user_id;
+
const mapping = channelMappings.getBySlackChannel(slackChannelId);
+
if (!mapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ This channel is not bridged to IRC",
+
});
+
return;
+
}
+
// Check if user has permission to manage this channel
+
if (!(await canManageChannel(userId, slackChannelId))) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ You don't have permission to manage this channel. You must be the channel creator, a channel manager, or an admin.",
+
});
+
return;
+
}
+
context.respond({
+
response_type: "ephemeral",
+
text: `Are you sure you want to remove the bridge to *${mapping.irc_channel}*?`,
+
blocks: [
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: `Are you sure you want to remove the bridge to *${mapping.irc_channel}*?`,
+
},
+
},
+
{
+
type: "actions",
+
elements: [
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Remove Bridge",
+
},
+
style: "danger",
+
action_id: "unbridge_channel_confirm",
+
value: slackChannelId,
+
},
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Cancel",
+
},
+
action_id: "cancel",
+
},
+
],
+
},
+
],
+
});
+
});
+
// Handle unbridge confirmation
+
slackApp.action("unbridge_channel_confirm", async ({ payload, context }) => {
+
// @ts-expect-error
+
const slackChannelId = payload.actions?.[0]?.value;
+
if (!context.respond) return;
+
try {
+
const mapping = channelMappings.getBySlackChannel(slackChannelId);
+
if (!mapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ This channel is not bridged to IRC",
+
replace_original: true,
+
});
+
return;
+
}
+
channelMappings.delete(slackChannelId);
+
console.log(
+
`Removed channel mapping: ${slackChannelId} -> ${mapping.irc_channel}`,
+
);
+
context.respond({
+
response_type: "ephemeral",
+
text: `โœ… Removed bridge to ${mapping.irc_channel}`,
+
replace_original: true,
+
});
+
} catch (error) {
+
console.error("Error removing channel mapping:", error);
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ Failed to remove bridge: ${error}`,
+
replace_original: true,
+
});
+
}
+
});
+
// Link Slack user to IRC nick
+
slackApp.command("/irc-bridge-user", async ({ payload, context }) => {
+
context.respond({
+
response_type: "ephemeral",
+
text: "Enter your IRC nickname",
+
blocks: [
+
{
+
type: "input",
+
block_id: "irc_nick_input",
+
element: {
+
type: "plain_text_input",
+
action_id: "irc_nick",
+
placeholder: {
+
type: "plain_text",
+
text: "myircnick",
+
},
+
},
+
label: {
+
type: "plain_text",
+
text: "IRC Nickname",
+
},
+
},
+
{
+
type: "actions",
+
elements: [
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Link Account",
+
},
+
style: "primary",
+
action_id: "bridge_user_submit",
+
value: payload.user_id,
+
},
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Cancel",
+
},
+
action_id: "cancel",
+
},
+
],
+
},
+
],
+
replace_original: true,
+
});
+
});
+
// Handle bridge user submission
+
slackApp.action("bridge_user_submit", async ({ payload, context }) => {
+
const stateValues = payload.state?.values;
+
const ircNick = stateValues?.irc_nick_input?.irc_nick?.value;
+
// @ts-expect-error
+
const slackUserId = payload.actions?.[0]?.value;
+
if (!context.respond) {
+
return;
+
}
+
+
if (!ircNick) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ IRC nickname is required",
+
replace_original: true,
+
});
+
return;
+
}
+
+
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: `โœ… Successfully linked your account to IRC nick: *${ircNick}*`,
+
replace_original: true,
+
});
+
} catch (error) {
+
console.error("Error creating user mapping:", error);
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ Failed to link user: ${error}`,
+
replace_original: true,
+
});
+
}
+
});
+
+
// Unlink Slack user from IRC
+
slackApp.command("/irc-unbridge-user", async ({ payload, context }) => {
+
const slackUserId = payload.user_id;
+
const mapping = userMappings.getBySlackUser(slackUserId);
+
+
if (!mapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ You don't have an IRC nick mapping",
+
});
+
return;
+
}
+
+
context.respond({
+
response_type: "ephemeral",
+
text: `Are you sure you want to remove your link to IRC nick *${mapping.irc_nick}*?`,
+
blocks: [
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: `Are you sure you want to remove your link to IRC nick *${mapping.irc_nick}*?`,
+
},
+
},
+
{
+
type: "actions",
+
elements: [
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Remove Link",
+
},
+
style: "danger",
+
action_id: "unbridge_user_confirm",
+
value: slackUserId,
+
},
+
{
+
type: "button",
+
text: {
+
type: "plain_text",
+
text: "Cancel",
+
},
+
action_id: "cancel",
+
},
+
],
+
},
+
],
+
replace_original: true,
+
});
+
});
+
+
// Handle unbridge user confirmation
+
slackApp.action("unbridge_user_confirm", async ({ payload, context }) => {
+
// @ts-expect-error
+
const slackUserId = payload.actions?.[0]?.value;
+
if (!context.respond) {
+
return;
+
}
+
+
try {
+
const mapping = userMappings.getBySlackUser(slackUserId);
+
if (!mapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ You don't have an IRC nick mapping",
+
replace_original: true,
+
});
+
return;
+
}
+
+
userMappings.delete(slackUserId);
+
console.log(
+
`Removed user mapping: ${slackUserId} -> ${mapping.irc_nick}`,
+
);
+
+
context.respond({
+
response_type: "ephemeral",
+
text: `โœ… Removed link to IRC nick: ${mapping.irc_nick}`,
+
replace_original: true,
+
});
+
} catch (error) {
+
console.error("Error removing user mapping:", error);
+
context.respond({
+
response_type: "ephemeral",
+
text: `โŒ Failed to remove link: ${error}`,
+
replace_original: true,
+
});
+
}
+
});
+
+
// Handle cancel button
+
slackApp.action("cancel", async ({ context }) => {
+
if (!context.respond) return;
+
+
context.respond({
+
response_type: "ephemeral",
+
delete_original: true,
+
});
+
});
+
+
// List channel mappings
+
slackApp.command("/irc-bridge-list", async ({ context }) => {
+
const channelMaps = channelMappings.getAll();
+
const userMaps = userMappings.getAll();
+
+
const blocks: AnyMessageBlock[] = [
+
{
+
type: "header",
+
text: {
+
type: "plain_text",
+
text: "IRC Bridge Status",
+
},
+
},
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "*Channel Bridges:*",
+
},
+
},
+
];
+
+
if (channelMaps.length === 0) {
+
blocks.push({
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "_No channel bridges configured_",
+
},
+
});
+
} else {
+
for (const map of channelMaps) {
+
blocks.push({
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: `โ€ข <#${map.slack_channel_id}> โ†”๏ธ *${map.irc_channel}*`,
+
},
+
});
+
}
+
}
+
blocks.push(
+
{
+
type: "divider",
+
},
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "*User Mappings:*",
+
},
+
},
+
);
+
if (userMaps.length === 0) {
+
blocks.push({
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "_No user mappings configured_",
+
},
+
});
+
} else {
+
for (const map of userMaps) {
+
blocks.push({
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: `โ€ข <@${map.slack_user_id}> โ†”๏ธ *${map.irc_nick}*`,
+
},
+
});
+
}
+
}
+
context.respond({
+
response_type: "ephemeral",
+
text: "IRC mapping list",
+
blocks,
+
replace_original: true,
+
});
+
});
}
-87
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;
···
+385 -147
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";
const missingEnvVars = [];
if (!process.env.SLACK_BOT_TOKEN) missingEnvVars.push("SLACK_BOT_TOKEN");
if (!process.env.SLACK_SIGNING_SECRET)
-
missingEnvVars.push("SLACK_SIGNING_SECRET");
if (!process.env.ADMINS) missingEnvVars.push("ADMINS");
if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK");
if (missingEnvVars.length > 0) {
-
throw new Error(
-
`Missing required environment variables: ${missingEnvVars.join(", ")}`,
-
);
}
const slackApp = new SlackApp({
-
env: {
-
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string,
-
SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string,
-
SLACK_LOGGING_LEVEL: "INFO",
-
},
-
startLazyListenerAfterAck: true,
});
const slackClient = slackApp.client;
// Get bot user ID
let botUserId: string | undefined;
slackClient.auth
-
.test({
-
token: process.env.SLACK_BOT_TOKEN,
-
})
-
.then((result) => {
-
botUserId = result.user_id;
-
console.log(`Bot user ID: ${botUserId}`);
-
});
// IRC client setup
const ircClient = new irc.Client(
-
"irc.hackclub.com",
-
process.env.IRC_NICK || "slackbridge",
-
{
-
port: 6667,
-
autoRejoin: true,
-
autoConnect: true,
-
channels: [],
-
secure: false,
-
userName: process.env.IRC_NICK,
-
realName: "Slack IRC Bridge",
-
},
);
// Clean up IRC connection on hot reload or exit
process.on("beforeExit", () => {
-
ircClient.disconnect("Reloading", () => {
-
console.log("IRC client disconnected");
-
});
});
// Register slash commands
registerCommands();
// Join all mapped IRC channels on connect
ircClient.addListener("registered", async () => {
-
console.log("Connected to IRC server");
-
const mappings = channelMappings.getAll();
-
for (const mapping of mappings) {
-
ircClient.join(mapping.irc_channel);
-
}
});
ircClient.addListener("join", (channel: string, nick: string) => {
-
if (nick === process.env.IRC_NICK) {
-
console.log(`Joined IRC channel: ${channel}`);
-
}
});
ircClient.addListener(
-
"message",
-
async (nick: string, to: string, text: string) => {
-
// Ignore messages from our own bot (with or without numbers suffix)
-
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);
-
const displayName = `${nick} <irc>`;
-
let iconUrl: string | undefined;
-
if (userMapping) {
-
try {
-
iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
-
} catch (error) {
-
console.error("Error fetching user info:", error);
-
}
-
}
-
try {
-
await slackClient.chat.postMessage({
-
token: process.env.SLACK_BOT_TOKEN,
-
channel: mapping.slack_channel_id,
-
text: parseIRCFormatting(text),
-
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);
-
}
-
},
);
ircClient.addListener("error", (error: string) => {
-
console.error("IRC error:", error);
});
// Slack event handlers
slackApp.event("message", async ({ payload }) => {
-
if (payload.subtype) 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);
-
if (!mapping) {
-
console.log(
-
`No IRC channel mapping found for Slack channel ${payload.channel}`,
-
);
-
slackClient.conversations.leave({
-
channel: payload.channel,
-
});
-
return;
-
}
-
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 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];
-
try {
-
const response = await fetch(
-
`https://cachet.dunkirk.sh/users/${userId}`,
-
);
-
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);
-
const message = `<${username}> ${messageText}`;
-
ircClient.say(mapping.irc_channel, message);
-
console.log(`Slack โ†’ IRC: ${message}`);
-
} catch (error) {
-
console.error("Error handling Slack message:", error);
-
}
});
export default {
-
port: process.env.PORT || 3000,
-
async fetch(request: Request) {
-
const url = new URL(request.url);
-
const path = url.pathname;
-
switch (path) {
-
case "/":
-
return new Response(`Hello World from irc-slack-bridge@${version}`);
-
case "/health":
-
return new Response("OK");
-
case "/slack":
-
return slackApp.run(request);
-
default:
-
return new Response("404 Not Found", { status: 404 });
-
}
-
},
};
console.log(
-
`๐Ÿš€ Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`,
);
console.log(
-
`Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`,
);
console.log(`Channel mappings: ${channelMappings.getAll().length}`);
console.log(`User mappings: ${userMappings.getAll().length}`);
···
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");
if (!process.env.SLACK_SIGNING_SECRET)
+
missingEnvVars.push("SLACK_SIGNING_SECRET");
if (!process.env.ADMINS) missingEnvVars.push("ADMINS");
if (!process.env.IRC_NICK) missingEnvVars.push("IRC_NICK");
if (missingEnvVars.length > 0) {
+
throw new Error(
+
`Missing required environment variables: ${missingEnvVars.join(", ")}`,
+
);
}
const slackApp = new SlackApp({
+
env: {
+
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string,
+
SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string,
+
SLACK_LOGGING_LEVEL: "INFO",
+
},
+
startLazyListenerAfterAck: true,
});
const slackClient = slackApp.client;
// Get bot user ID
let botUserId: string | undefined;
slackClient.auth
+
.test({
+
token: process.env.SLACK_BOT_TOKEN,
+
})
+
.then((result) => {
+
botUserId = result.user_id;
+
console.log(`Bot user ID: ${botUserId}`);
+
});
// IRC client setup
const ircClient = new irc.Client(
+
"irc.hackclub.com",
+
process.env.IRC_NICK || "slackbridge",
+
{
+
port: 6667,
+
autoRejoin: true,
+
autoConnect: true,
+
channels: [],
+
secure: false,
+
userName: process.env.IRC_NICK,
+
realName: "Slack IRC Bridge",
+
},
);
// Clean up IRC connection on hot reload or exit
process.on("beforeExit", () => {
+
ircClient.disconnect("Reloading", () => {
+
console.log("IRC client disconnected");
+
});
});
// 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;
+
console.log("Authenticating with NickServ...");
+
ircClient.say("NickServ", `IDENTIFY ${process.env.NICKSERV_PASSWORD}`);
+
// Don't join channels yet - wait for NickServ response
+
} else if (!process.env.NICKSERV_PASSWORD) {
+
// No auth needed, join immediately
+
const mappings = channelMappings.getAll();
+
for (const mapping of mappings) {
+
ircClient.join(mapping.irc_channel);
+
}
+
}
});
ircClient.addListener("join", (channel: string, nick: string) => {
+
if (nick === process.env.IRC_NICK) {
+
console.log(`Joined IRC channel: ${channel}`);
+
}
});
+
// 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",
+
async (nick: string, to: string, text: string) => {
+
// Ignore messages from our own bot (with or without numbers suffix)
+
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);
+
+
const displayName = `${nick} <irc>`;
+
let iconUrl: string;
+
+
if (userMapping) {
+
iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
+
} else {
+
// Use stable random avatar for unmapped users
+
iconUrl = getAvatarForNick(nick);
+
}
+
+
// 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
+
if (imageUrls.length > 0) {
+
const attachments = imageUrls.map((match) => ({
+
image_url: match[0],
+
fallback: match[0],
+
}));
+
+
await slackClient.chat.postMessage({
+
token: process.env.SLACK_BOT_TOKEN,
+
channel: mapping.slack_channel_id,
+
text: messageText,
+
username: displayName,
+
icon_url: iconUrl,
+
attachments: attachments,
+
unfurl_links: false,
+
unfurl_media: false,
+
thread_ts: threadTs,
+
});
+
} else {
+
await slackClient.chat.postMessage({
+
token: process.env.SLACK_BOT_TOKEN,
+
channel: mapping.slack_channel_id,
+
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);
+
}
+
},
);
ircClient.addListener("error", (error: string) => {
+
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);
+
if (!mapping) {
+
console.log(
+
`No IRC channel mapping found for Slack channel ${payload.channel}`,
+
);
+
slackClient.conversations.leave({
+
channel: payload.channel,
+
});
+
return;
+
}
+
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()) {
+
const message = `<${username}> ${messageText}`;
+
ircClient.say(mapping.irc_channel, message);
+
console.log(`Slack โ†’ IRC: ${message}`);
+
}
+
// 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);
+
}
+
}
+
} catch (error) {
+
console.error("Error handling Slack message:", error);
+
}
});
export default {
+
port: process.env.PORT || 3000,
+
async fetch(request: Request) {
+
const url = new URL(request.url);
+
const path = url.pathname;
+
switch (path) {
+
case "/":
+
return new Response(`Hello World from irc-slack-bridge@${version}`);
+
case "/health":
+
return new Response("OK");
+
case "/slack":
+
return slackApp.run(request);
+
default:
+
return new Response("404 Not Found", { status: 404 });
+
}
+
},
};
console.log(
+
`๐Ÿš€ Server Started in ${Bun.nanoseconds() / 1000000} milliseconds on version: ${version}!\n\n----------------------------------\n`,
);
console.log(
+
`Connecting to IRC: irc.hackclub.com:6667 as ${process.env.IRC_NICK}`,
);
console.log(`Channel mappings: ${channelMappings.getAll().length}`);
console.log(`User mappings: ${userMappings.getAll().length}`);
+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);
+
}
+
}
+
}
-89
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;
-
}
-
···
+7
src/types.ts
···
imageUrl: string;
expiration: string;
}
···
imageUrl: string;
expiration: string;
}
+
+
export interface CDNUploadResponse {
+
files: Array<{
+
deployedUrl: string;
+
originalUrl: string;
+
}>;
+
}
+24 -24
tsconfig.json
···
{
-
"compilerOptions": {
-
// Environment setup & latest features
-
"lib": ["ESNext"],
-
"target": "ESNext",
-
"module": "Preserve",
-
"moduleDetection": "force",
-
"jsx": "react-jsx",
-
"allowJs": true,
-
// Bundler mode
-
"moduleResolution": "bundler",
-
"allowImportingTsExtensions": true,
-
"verbatimModuleSyntax": true,
-
"noEmit": true,
-
// Best practices
-
"strict": true,
-
"skipLibCheck": true,
-
"noFallthroughCasesInSwitch": true,
-
"noUncheckedIndexedAccess": true,
-
"noImplicitOverride": true,
-
// Some stricter flags (disabled by default)
-
"noUnusedLocals": false,
-
"noUnusedParameters": false,
-
"noPropertyAccessFromIndexSignature": false
-
}
}
···
{
+
"compilerOptions": {
+
// Environment setup & latest features
+
"lib": ["ESNext"],
+
"target": "ESNext",
+
"module": "Preserve",
+
"moduleDetection": "force",
+
"jsx": "react-jsx",
+
"allowJs": true,
+
// Bundler mode
+
"moduleResolution": "bundler",
+
"allowImportingTsExtensions": true,
+
"verbatimModuleSyntax": true,
+
"noEmit": true,
+
// Best practices
+
"strict": true,
+
"skipLibCheck": true,
+
"noFallthroughCasesInSwitch": true,
+
"noUncheckedIndexedAccess": true,
+
"noImplicitOverride": true,
+
// Some stricter flags (disabled by default)
+
"noUnusedLocals": false,
+
"noUnusedParameters": false,
+
"noPropertyAccessFromIndexSignature": false
+
}
}