this repo has no description

feat: cache user lookups

dunkirk.sh cbf29d18 67129005

verified
+3
.env.example
···
SLACK_USER_COOKIE=your-slack-cookie-here
SLACK_USER_TOKEN=your-user-token-here
# IRC Configuration
IRC_NICK=slackbridge
NICKSERV_PASSWORD=your-nickserv-password-here
···
SLACK_USER_COOKIE=your-slack-cookie-here
SLACK_USER_TOKEN=your-user-token-here
+
# Optional: Enable Cachet API for user lookups (recommended for better performance)
+
CACHET_ENABLED=true
+
# IRC Configuration
IRC_NICK=slackbridge
NICKSERV_PASSWORD=your-nickserv-password-here
+46 -3
README.md
···
bun dev
```
### Slack App Setup
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app
···
SLACK_USER_COOKIE=your-slack-cookie-here
SLACK_USER_TOKEN=your-user-token-here
# IRC Configuration
IRC_NICK=slackbridge
NICKSERV_PASSWORD=your-nickserv-password-here
···
**Using Bun REPL:**
```bash
bun repl
-
> import { channelMappings, userMappings } from "./src/db"
> channelMappings.create("C1234567890", "#general")
> userMappings.create("U1234567890", "myircnick")
> channelMappings.getAll()
···
- IRC `/me` actions are displayed in a context block with the user's avatar
- Thread replies: Use `@xxxxx` (5-char thread ID) to reply to a Slack thread from IRC
- **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels
-
- Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format
- Slack markdown is converted to IRC formatting codes
- File attachments are uploaded to Hack Club CDN and URLs are shared
- Thread messages are prefixed with `@xxxxx` (5-char thread ID) to show they're part of a thread
- First reply in a thread includes a quote of the parent message
- **User mappings** allow custom IRC nicknames for specific Slack users and enable proper mentions both ways
#### Thread Support
···
- **IRC → Slack**: Reply to a thread by including the thread ID in your message
- Example: `@abc12 this is my reply`
- The bridge removes the `@xxxxx` prefix and sends your message to the correct thread
-
- Thread IDs are unique per thread and persist across restarts
The bridge ignores its own messages and bot messages to prevent loops.
If you want to report an issue the main repo is [the tangled repo](https://tangled.org/dunkirk.sh/irc-slack-bridge) and the github is just a mirror.
···
bun dev
```
+
To run tests:
+
```bash
+
bun test
+
```
+
### Slack App Setup
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app
···
SLACK_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
···
**Using Bun REPL:**
```bash
bun repl
+
> import { channelMappings, userMappings } from "./src/lib/db"
> channelMappings.create("C1234567890", "#general")
> userMappings.create("U1234567890", "myircnick")
> channelMappings.getAll()
···
- IRC `/me` actions are displayed in a context block with the user's avatar
- Thread replies: Use `@xxxxx` (5-char thread ID) to reply to a Slack thread from IRC
- **Slack → IRC**: Messages from mapped Slack channels are sent to their corresponding IRC channels
+
- User display names: Uses name from Slack event if available, otherwise Cachet API (if `CACHET_ENABLED=true`), then Slack API fallback
+
- All lookups are cached locally (1 hour TTL) to reduce API calls
+
- Slack mentions are converted to mapped IRC nicks, or looked up via the above priority
- Slack markdown is converted to IRC formatting codes
- File attachments are uploaded to Hack Club CDN and URLs are shared
- Thread messages are prefixed with `@xxxxx` (5-char thread ID) to show they're part of a thread
- First reply in a thread includes a quote of the parent message
- **User mappings** allow custom IRC nicknames for specific Slack users and enable proper mentions both ways
+
- **Permissions**: Only channel creators, channel managers, or global admins can bridge/unbridge channels
#### Thread Support
···
- **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.
+11 -1
src/index.ts
···
}
try {
-
const userInfo = await getUserInfo(payload.user, slackClient);
// Check for user mapping, otherwise use Slack name
const userMapping = userMappings.getBySlackUser(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;
+
+
const userInfo = await getUserInfo(
+
payload.user,
+
slackClient,
+
displayNameFromEvent,
+
);
// Check for user mapping, otherwise use Slack name
const userMapping = userMappings.getBySlackUser(payload.user);
+14 -8
src/lib/mentions.ts
···
}
/**
-
* Converts Slack user mentions to IRC @mentions, with Cachet fallback
*/
export async function convertSlackMentionsToIrc(
messageText: string,
···
const mentionedUserMapping = userMappings.getBySlackUser(userId);
if (mentionedUserMapping) {
result = result.replace(match[0], `@${mentionedUserMapping.irc_nick}`);
-
} else if (displayName) {
-
// Use the display name from the mention format <@U123|name>
-
result = result.replace(match[0], `@${displayName}`);
} else {
-
// Fallback to Cachet lookup
-
const data = await getCachetUser(userId);
-
if (data) {
-
result = result.replace(match[0], `@${data.displayName}`);
}
}
}
···
}
/**
+
* Converts Slack user mentions to IRC @mentions
+
* Priority: user mappings > display name from mention > Cachet lookup
*/
export async function convertSlackMentionsToIrc(
messageText: string,
···
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}`);
}
}
}
+23 -1
src/lib/user-cache.test.ts
···
});
describe("getUserInfo", () => {
test("fetches user info from Slack on cache miss", async () => {
const client = {
users: {
···
},
};
-
const result = await getUserInfo("U123", client);
expect(result).toEqual({
name: "testuser",
···
});
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: {
···
},
};
+
const result = await getUserInfo("U125", client);
expect(result).toEqual({
name: "testuser",
+38 -1
src/lib/user-cache.ts
···
interface CachedUserInfo {
name: string;
realName: string;
···
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
/**
-
* Get user info from cache or fetch from Slack
*/
export async function getUserInfo(
userId: string,
slackClient: SlackClient,
): Promise<{ name: string; realName: string } | null> {
const cached = userCache.get(userId);
const now = Date.now();
···
return { name: cached.name, realName: cached.realName };
}
try {
const userInfo = await slackClient.users.info({
token: process.env.SLACK_BOT_TOKEN,
···
+
import { getCachetUser } from "./cachet";
+
interface CachedUserInfo {
name: string;
realName: string;
···
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();
···
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,