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
+
# 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
+
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
```
+
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
+
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/db"
+
> 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 mappings allow custom IRC nicknames for specific Slack users
+
- 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"
+
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";
+
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 }) => {
-
const args = payload.text.trim().split(/\s+/);
-
const ircChannel = args[0];
+
// 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,
+
});
+
});
-
if (!ircChannel || !ircChannel.startsWith("#")) {
-
return {
-
text: "Usage: `/irc-bridge-channel #irc-channel`\nExample: `/irc-bridge-channel #lounge`",
-
};
-
}
+
// 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;
-
const slackChannelId = payload.channel_id;
+
if (!context.respond) {
+
return;
+
}
-
try {
-
// Create the mapping
-
channelMappings.create(slackChannelId, ircChannel);
+
if (!ircChannel || !ircChannel.startsWith("#")) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ IRC channel must start with #",
+
replace_original: true,
+
});
+
return;
+
}
-
// Join the IRC channel
-
ircClient.join(ircChannel);
+
// 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;
+
}
-
// Join the Slack channel if not already in it
-
await context.client.conversations.join({
-
channel: slackChannelId,
-
});
+
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;
+
}
-
return {
-
text: `โœ… Successfully bridged this channel to ${ircChannel}`,
-
};
-
} catch (error) {
-
console.error("Error creating channel mapping:", error);
-
return {
-
text: `โŒ Failed to bridge channel: ${error}`,
-
};
-
}
-
});
+
// 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;
+
}
-
// Unlink Slack channel from IRC
-
slackApp.command("/irc-unbridge-channel", async ({ payload }) => {
-
const slackChannelId = payload.channel_id;
+
channelMappings.create(slackChannelId, ircChannel);
+
ircClient.join(ircChannel);
-
try {
-
const mapping = channelMappings.getBySlackChannel(slackChannelId);
-
if (!mapping) {
-
return {
-
text: "โŒ This channel is not bridged to IRC",
-
};
-
}
+
await context.client.conversations.join({
+
channel: slackChannelId,
+
});
-
channelMappings.delete(slackChannelId);
+
console.log(
+
`Created channel mapping: ${slackChannelId} -> ${ircChannel}`,
+
);
-
return {
-
text: `โœ… Removed bridge to ${mapping.irc_channel}`,
-
};
-
} catch (error) {
-
console.error("Error removing channel mapping:", error);
-
return {
-
text: `โŒ Failed to remove bridge: ${error}`,
-
};
-
}
-
});
+
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,
+
});
+
}
+
});
-
// Link Slack user to IRC nick
-
slackApp.command("/irc-bridge-user", async ({ payload }) => {
-
const args = payload.text.trim().split(/\s+/);
-
const ircNick = args[0];
+
// 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 (!ircNick) {
-
return {
-
text: "Usage: `/irc-bridge-user <irc-nick>`\nExample: `/irc-bridge-user myircnick`",
-
};
-
}
+
if (!mapping) {
+
context.respond({
+
response_type: "ephemeral",
+
text: "โŒ This channel is not bridged to IRC",
+
});
+
return;
+
}
-
const slackUserId = payload.user_id;
+
// 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;
+
}
-
try {
-
userMappings.create(slackUserId, ircNick);
-
console.log(`Created user mapping: ${slackUserId} -> ${ircNick}`);
+
context.respond({
+
response_type: "ephemeral",
+
text: `Are you sure you want to remove 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",
+
},
+
],
+
},
+
],
+
});
+
});
-
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}`,
-
};
-
}
-
});
+
// Handle unbridge confirmation
+
slackApp.action("unbridge_channel_confirm", async ({ payload, context }) => {
+
// @ts-expect-error
+
const slackChannelId = payload.actions?.[0]?.value;
+
if (!context.respond) return;
-
// Unlink Slack user from IRC
-
slackApp.command("/irc-unbridge-user", async ({ payload }) => {
-
const slackUserId = payload.user_id;
+
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;
+
}
-
try {
-
const mapping = userMappings.getBySlackUser(slackUserId);
-
if (!mapping) {
-
return {
-
text: "โŒ You don't have an IRC nick mapping",
-
};
-
}
+
channelMappings.delete(slackChannelId);
+
console.log(
+
`Removed channel mapping: ${slackChannelId} -> ${mapping.irc_channel}`,
+
);
-
userMappings.delete(slackUserId);
+
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,
+
});
+
}
+
});
-
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}`,
-
};
-
}
-
});
+
// 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,
+
});
+
});
-
// List channel mappings
-
slackApp.command("/irc-bridge-list", async ({ payload }) => {
-
const channelMaps = channelMappings.getAll();
-
const userMaps = userMappings.getAll();
+
// 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}*`,
+
},
+
});
+
}
+
}
-
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`;
-
}
-
}
+
blocks.push(
+
{
+
type: "divider",
+
},
+
{
+
type: "section",
+
text: {
+
type: "mrkdwn",
+
text: "*User Mappings:*",
+
},
+
},
+
);
-
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`;
-
}
-
}
+
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}*`,
+
},
+
});
+
}
+
}
-
return {
-
text,
-
};
-
});
+
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";
+
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");
+
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(", ")}`,
-
);
+
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,
+
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}`);
-
});
+
.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",
-
},
+
"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");
-
});
+
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");
-
const mappings = channelMappings.getAll();
-
for (const mapping of mappings) {
-
ircClient.join(mapping.irc_channel);
-
}
+
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}`);
-
}
+
if (nick === process.env.IRC_NICK) {
+
console.log(`Joined IRC channel: ${channel}`);
+
}
});
+
// Handle NickServ notices
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;
+
"notice",
+
async (nick: string, _to: string, text: string) => {
+
if (nick !== "NickServ") return;
-
// Find Slack channel mapping for this IRC channel
-
const mapping = channelMappings.getByIrcChannel(to);
-
if (!mapping) return;
+
console.log(`NickServ: ${text}`);
-
// Check if this IRC nick is mapped to a Slack user
-
const userMapping = userMappings.getByIrcNick(nick);
+
// 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;
-
const displayName = `${nick} <irc>`;
-
let iconUrl: 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();
+
}
+
}
-
if (userMapping) {
-
try {
-
iconUrl = `https://cachet.dunkirk.sh/users/${userMapping.slack_user_id}/r`;
-
} catch (error) {
-
console.error("Error fetching user info:", error);
-
}
-
}
+
// 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));
-
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);
-
}
-
},
+
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);
+
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 }) => {
-
if (payload.subtype) return;
-
if (payload.bot_id) return;
-
if (payload.user === botUserId) return;
+
// 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;
-
}
+
// 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,
-
});
+
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;
-
// 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";
+
const userInfo = await getUserInfo(
+
payload.user,
+
slackClient,
+
displayNameFromEvent,
+
);
-
// 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));
+
// Check for user mapping, otherwise use Slack name
+
const userMapping = userMappings.getBySlackUser(payload.user);
+
const username =
+
userMapping?.irc_nick ||
+
userInfo?.realName ||
+
userInfo?.name ||
+
"Unknown";
-
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 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}`;
+
}
+
}
-
// Parse Slack markdown formatting
-
messageText = parseSlackMarkdown(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}`);
+
}
-
const message = `<${username}> ${messageText}`;
+
// 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);
-
ircClient.say(mapping.irc_channel, message);
-
console.log(`Slack โ†’ IRC: ${message}`);
-
} catch (error) {
-
console.error("Error handling Slack message:", error);
-
}
+
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;
+
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 });
-
}
-
},
+
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`,
+
`๐Ÿš€ 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}`,
+
`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;
}
+
+
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,
+
"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,
+
// Bundler mode
+
"moduleResolution": "bundler",
+
"allowImportingTsExtensions": true,
+
"verbatimModuleSyntax": true,
+
"noEmit": true,
-
// Best practices
-
"strict": true,
-
"skipLibCheck": true,
-
"noFallthroughCasesInSwitch": true,
-
"noUncheckedIndexedAccess": true,
-
"noImplicitOverride": 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
-
}
+
// Some stricter flags (disabled by default)
+
"noUnusedLocals": false,
+
"noUnusedParameters": false,
+
"noPropertyAccessFromIndexSignature": false
+
}
}