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
+
# 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
```
+
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/db"
+
> 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
-
- Slack mentions are converted to mapped IRC nicks, or the display name from `<@U123|name>` format
+
- 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
+
- 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);
+
// 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
+
* 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 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}`);
+
// 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("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("U123", client);
+
const result = await getUserInfo("U125", client);
expect(result).toEqual({
name: "testuser",
+38 -1
src/lib/user-cache.ts
···
+
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 Slack
+
* 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,