384 lines
11 KiB
TypeScript
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 }),
|
|
});
|
|
}
|
|
}
|