Files
flynn/src/channels/slack/adapter.ts
T
2026-02-15 23:14:21 -08:00

384 lines
11 KiB
TypeScript

/**
* Slack channel adapter.
*
* Implements the ChannelAdapter interface using @slack/bolt with Socket Mode.
* Thread-aware: each channel+thread gets its own session via channelId:threadTs peer IDs.
* Messages are chunked at 4000 chars for readability.
*/
import { App } from '@slack/bolt';
import type {
Attachment,
InboundMessage,
OutboundMessage,
OutboundAttachment,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
import {
allowTrustedOrPairedSender,
buildResetInboundMessage,
isAllowedByAllowlist,
normalizeResetCommandText,
shouldIgnoreForMissingMention,
splitMessage,
} from '../utils.js';
import type { PairingManager } from '../pairing.js';
/** Configuration for the Slack channel adapter. */
export interface SlackAdapterConfig {
botToken: string;
appToken: string;
signingSecret: string;
/** Channel IDs to respond in. Empty = all channels. */
allowedChannelIds?: string[];
/** Require bot mention to respond (default: false). */
requireMention?: boolean;
/** Optional pairing manager for DM pairing codes. */
pairingManager?: PairingManager;
}
interface CachedUserName {
name: string;
expiresAt: number;
}
/** Minimal shape of a Slack message event from Bolt. */
interface SlackMessageEvent {
ts?: string;
thread_ts?: string;
channel?: string;
user?: string;
text?: string;
bot_id?: string;
subtype?: string;
files?: Array<{
id?: string;
mimetype?: string;
name?: string;
size?: number;
url_private?: string;
url_private_download?: string;
}>;
}
/**
* Slack channel adapter backed by @slack/bolt.
*
* Handles channel filtering, thread-aware peer IDs,
* and message chunking for readability (4000 chars).
*/
export class SlackAdapter implements ChannelAdapter {
readonly name = 'slack';
private _status: ChannelStatus = 'disconnected';
private app: App | null = null;
private messageHandler?: (msg: InboundMessage) => void;
private config: SlackAdapterConfig;
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;
}
constructor(config: SlackAdapterConfig) {
this.config = config;
}
/** Register the inbound message handler. Called by the registry before connect(). */
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
/** Create the Bolt app with Socket Mode, wire up event handlers, and start. */
async connect(): Promise<void> {
this._status = 'connecting';
try {
this.app = new App({
token: this.config.botToken,
appToken: this.config.appToken,
signingSecret: this.config.signingSecret,
socketMode: true,
});
// Register message event handler
this.app.message(async ({ message }) => {
await this.handleMessage(message as unknown as SlackMessageEvent);
});
await this.app.start();
// Resolve bot user ID for mention detection
try {
const authResult = await this.app.client.auth.test();
this.botUserId = authResult.user_id as string | undefined;
} catch {
console.warn('Slack: could not resolve bot user ID for mention detection');
}
this._status = 'connected';
console.log('Slack bot connected via Socket Mode');
} catch (error) {
this._status = 'error';
throw error;
}
}
/** Stop the Bolt app and clean up. */
async disconnect(): Promise<void> {
if (this.app) {
try {
await this.app.stop();
} finally {
this.app = null;
}
}
this._status = 'disconnected';
}
/** Send an outbound message, automatically chunking if it exceeds 4000 chars. */
async send(peerId: string, message: OutboundMessage): Promise<void> {
if (!this.app) {throw new Error('Slack adapter not connected');}
// Parse peerId: "channelId:threadTs"
const colonIndex = peerId.indexOf(':');
if (colonIndex === -1) {throw new Error(`Invalid peer ID format: ${peerId}`);}
const channel = peerId.slice(0, colonIndex);
const threadTs = peerId.slice(colonIndex + 1);
if (!channel || !threadTs) {throw new Error(`Invalid peer ID format: ${peerId}`);}
const text = message.text;
if (text.length <= 4000) {
await this.app.client.chat.postMessage({
channel,
text,
thread_ts: threadTs,
});
} else {
const chunks = splitMessage(text, 4000);
for (const chunk of chunks) {
await this.app.client.chat.postMessage({
channel,
text: chunk,
thread_ts: threadTs,
});
}
}
// Send outbound attachments after text
if (message.attachments && message.attachments.length > 0) {
for (const attachment of message.attachments) {
await this.sendAttachment(channel, threadTs, attachment);
}
}
}
/** Upload and send a single outbound attachment via Slack's files.uploadV2 API. */
private async sendAttachment(
channel: string,
threadTs: string,
attachment: OutboundAttachment,
): Promise<void> {
if (!this.app) {return;}
try {
if (attachment.data) {
await this.app.client.files.uploadV2({
channel_id: channel,
thread_ts: threadTs,
file: Buffer.from(attachment.data, 'base64'),
filename: attachment.filename ?? 'attachment',
});
} else if (attachment.url) {
// For URL-based attachments, share as a text message with the URL
await this.app.client.chat.postMessage({
channel,
text: attachment.url,
thread_ts: threadTs,
});
}
} catch (error) {
console.error(
`Slack: failed to send ${attachment.mimeType} attachment:`,
error instanceof Error ? error.message : 'Unknown error',
);
}
}
/** 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 && 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 app = this.app;
if (!app) {
return userId;
}
const result = await app.client.users.info({ user: userId });
const name = result.user?.real_name || result.user?.name || userId;
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;
}
}
/**
* Download media files from a Slack message and convert to base64 Attachments.
* Non-media files are skipped. Download errors are logged but don't crash the handler.
*/
private async extractMediaAttachments(
files?: SlackMessageEvent['files'],
): Promise<Attachment[]> {
if (!files || files.length === 0) {return [];}
const attachments: Attachment[] = [];
for (const file of files) {
// Only process image and audio files
if (!file.mimetype?.startsWith('image/') && !file.mimetype?.startsWith('audio/')) {continue;}
const downloadUrl = file.url_private_download || file.url_private;
if (!downloadUrl) {continue;}
try {
const response = await fetch(downloadUrl, {
headers: { Authorization: `Bearer ${this.config.botToken}` },
});
if (!response.ok) {
console.warn(
`Slack: failed to download file ${file.name ?? file.id ?? 'unknown'}: HTTP ${response.status}`,
);
continue;
}
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
attachments.push({
mimeType: file.mimetype,
data: base64,
filename: file.name,
size: file.size,
});
} catch (error) {
console.warn(
`Slack: error downloading file ${file.name ?? file.id ?? 'unknown'}:`,
error instanceof Error ? error.message : 'Unknown error',
);
}
}
return attachments;
}
/** Internal: process an inbound Slack message event. */
private async handleMessage(message: SlackMessageEvent): Promise<void> {
if (!this.messageHandler) {return;}
// Ignore bot messages
if (message.bot_id || message.subtype === 'bot_message') {return;}
const channelId = message.channel;
if (!channelId) {return;}
// Check allowed channel IDs
if (!isAllowedByAllowlist(channelId, this.config.allowedChannelIds)) {
const senderId = message.user ?? '';
const allowed = await allowTrustedOrPairedSender({
pairingManager: this.config.pairingManager,
channel: 'slack',
senderId,
text: message.text ?? '',
isTrusted: false,
onPaired: async () => {
if (!this.app) {return;}
const threadTs = message.thread_ts ?? message.ts ?? '';
await this.app.client.chat.postMessage({
channel: channelId,
text: 'Pairing successful! You can now chat with Flynn.',
thread_ts: threadTs || undefined,
});
},
});
if (!allowed) {
return;
}
}
// Mention requirement
const mentionPattern = this.botUserId ? `<@${this.botUserId}>` : undefined;
if (shouldIgnoreForMissingMention({
requireMention: mentionPattern ? this.config.requireMention : false,
defaultRequireMention: false,
mentionsBot: mentionPattern ? (message.text ?? '').includes(mentionPattern) : false,
})) {
return;
}
// Note: Slack doesn't expose a typing indicator API for bots
// Build peer ID: channelId:threadTs (thread-aware)
const threadTs = message.thread_ts ?? message.ts ?? '';
const peerId = `${channelId}:${threadTs}`;
// Strip bot mentions: <@U\w+> pattern
const rawText = (message.text ?? '').replace(/<@U\w+>/g, '').trim();
const text = normalizeResetCommandText(rawText);
// Resolve display name from Slack user ID
const senderName = message.user
? await this.resolveUserName(message.user)
: undefined;
// Extract media attachments from Slack file uploads
const attachments = await this.extractMediaAttachments(message.files);
// Detect reset command
if (text === '!reset') {
this.messageHandler(buildResetInboundMessage({
id: message.ts ?? '',
channel: 'slack',
senderId: peerId,
senderName,
attachments,
}));
return;
}
// Regular message
this.messageHandler({
id: message.ts ?? '',
channel: 'slack',
senderId: peerId,
senderName,
text,
timestamp: Date.now(),
...(attachments.length > 0 && { attachments }),
});
}
}