fix(slack): bound username cache with ttl and lru eviction
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── Mock @slack/bolt before importing adapter ──────────────────────
|
||||
|
||||
@@ -66,6 +66,10 @@ describe('SlackAdapter', () => {
|
||||
adapter = new SlackAdapter(baseConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Basic properties ──────────────────────────────────────────
|
||||
|
||||
it('has name "slack"', () => {
|
||||
@@ -221,6 +225,52 @@ describe('SlackAdapter', () => {
|
||||
expect(msg.senderId).toBe('C123:2222.3333');
|
||||
});
|
||||
|
||||
it('caches resolved usernames and avoids repeated users.info calls', async () => {
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
await adapter.connect();
|
||||
|
||||
await simulateMessage({
|
||||
ts: '1111.0001',
|
||||
channel: 'C123',
|
||||
user: 'U456',
|
||||
text: 'First message',
|
||||
});
|
||||
await simulateMessage({
|
||||
ts: '1111.0002',
|
||||
channel: 'C123',
|
||||
user: 'U456',
|
||||
text: 'Second message',
|
||||
});
|
||||
|
||||
expect(mockUsersInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refreshes cached usernames after TTL expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
await adapter.connect();
|
||||
|
||||
await simulateMessage({
|
||||
ts: '2222.0001',
|
||||
channel: 'C123',
|
||||
user: 'U456',
|
||||
text: 'First message',
|
||||
});
|
||||
expect(mockUsersInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(60 * 60 * 1_000 + 1);
|
||||
|
||||
await simulateMessage({
|
||||
ts: '2222.0002',
|
||||
channel: 'C123',
|
||||
user: 'U456',
|
||||
text: 'Second message after TTL',
|
||||
});
|
||||
expect(mockUsersInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores bot messages (bot_id present)', async () => {
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface SlackAdapterConfig {
|
||||
pairingManager?: PairingManager;
|
||||
}
|
||||
|
||||
interface CachedUserName {
|
||||
name: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Minimal shape of a Slack message event from Bolt. */
|
||||
interface SlackMessageEvent {
|
||||
ts?: string;
|
||||
@@ -63,8 +68,10 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
private app: App | null = null;
|
||||
private messageHandler?: (msg: InboundMessage) => void;
|
||||
private config: SlackAdapterConfig;
|
||||
private userNameCache: Map<string, string> = new Map();
|
||||
private userNameCache: Map<string, CachedUserName> = new Map();
|
||||
private botUserId?: string;
|
||||
private readonly userNameCacheTtlMs = 60 * 60 * 1_000;
|
||||
private readonly userNameCacheMaxEntries = 1_000;
|
||||
|
||||
get status(): ChannelStatus {
|
||||
return this._status;
|
||||
@@ -199,13 +206,28 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
|
||||
/** Resolve a Slack user ID to a display name, with caching. */
|
||||
private async resolveUserName(userId: string): Promise<string> {
|
||||
const now = Date.now();
|
||||
const cached = this.userNameCache.get(userId);
|
||||
if (cached) {return cached;}
|
||||
if (cached && cached.expiresAt > now) {
|
||||
// Refresh LRU order on cache hit.
|
||||
this.userNameCache.delete(userId);
|
||||
this.userNameCache.set(userId, cached);
|
||||
return cached.name;
|
||||
}
|
||||
if (cached) {
|
||||
this.userNameCache.delete(userId);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.app!.client.users.info({ user: userId });
|
||||
const name = result.user?.real_name || result.user?.name || userId;
|
||||
this.userNameCache.set(userId, name);
|
||||
this.userNameCache.set(userId, { name, expiresAt: now + this.userNameCacheTtlMs });
|
||||
if (this.userNameCache.size > this.userNameCacheMaxEntries) {
|
||||
const oldestKey = this.userNameCache.keys().next().value;
|
||||
if (typeof oldestKey === 'string') {
|
||||
this.userNameCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
return name;
|
||||
} catch {
|
||||
return userId;
|
||||
|
||||
Reference in New Issue
Block a user