Files
lettabot/src/channels/slack.ts

536 lines
18 KiB
TypeScript

/**
* Slack Channel Adapter
*
* Uses @slack/bolt for Slack API with Socket Mode.
*/
import type { ChannelAdapter } from './types.js';
import type { InboundAttachment, InboundMessage, InboundReaction, OutboundFile, OutboundMessage } from '../core/types.js';
import { createReadStream } from 'node:fs';
import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
import { parseCommand, HELP_TEXT } from '../core/commands.js';
import { markdownToSlackMrkdwn } from './slack-format.js';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js';
import { createLogger } from '../logger.js';
const log = createLogger('Slack');
// Dynamic import to avoid requiring Slack deps if not used
let App: typeof import('@slack/bolt').App;
export interface SlackConfig {
botToken: string; // xoxb-...
appToken: string; // xapp-... (for Socket Mode)
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[]; // Slack user IDs (e.g., U01234567)
streaming?: boolean; // Stream responses via progressive message edits (default: false)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
groups?: Record<string, GroupModeConfig>; // Per-channel settings
}
export class SlackAdapter implements ChannelAdapter {
readonly id = 'slack' as const;
readonly name = 'Slack';
private app: InstanceType<typeof App> | null = null;
private config: SlackConfig;
private running = false;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
constructor(config: SlackConfig) {
this.config = config;
this.attachmentsDir = config.attachmentsDir;
this.attachmentsMaxBytes = config.attachmentsMaxBytes;
}
async start(): Promise<void> {
if (this.running) return;
// Dynamic import
const bolt = await import('@slack/bolt');
App = bolt.App;
this.app = new App({
token: this.config.botToken,
appToken: this.config.appToken,
socketMode: true,
});
// Handle messages
this.app.message(async ({ message, say, client }) => {
// Type guard for regular messages (allow file_share for voice messages)
if (message.subtype !== undefined && message.subtype !== 'file_share') return;
if (!('user' in message)) return;
const userId = message.user;
let text = message.text || '';
const channelId = message.channel;
const threadTs = message.thread_ts || message.ts; // Reply in thread if applicable
// Handle audio file attachments
const files = (message as any).files as Array<{ mimetype?: string; url_private_download?: string; name?: string }> | undefined;
const audioFile = files?.find(f => f.mimetype?.startsWith('audio/'));
if (audioFile?.url_private_download) {
try {
const { isTranscriptionConfigured } = await import('../transcription/index.js');
if (!isTranscriptionConfigured()) {
await say('Voice messages require a transcription API key. See: https://github.com/letta-ai/lettabot#voice-messages');
} else {
// Download file (requires bot token for auth)
const response = await fetch(audioFile.url_private_download, {
headers: { 'Authorization': `Bearer ${this.config.botToken}` }
});
const buffer = Buffer.from(await response.arrayBuffer());
const { transcribeAudio } = await import('../transcription/index.js');
const ext = audioFile.mimetype?.split('/')[1] || 'mp3';
const result = await transcribeAudio(buffer, audioFile.name || `audio.${ext}`);
if (result.success && result.text) {
log.info(`Transcribed audio: "${result.text.slice(0, 50)}..."`);
text = (text ? text + '\n' : '') + `[Voice message]: ${result.text}`;
} else {
log.error(`Transcription failed: ${result.error}`);
text = (text ? text + '\n' : '') + `[Voice message - transcription failed: ${result.error}]`;
}
}
} catch (error) {
log.error('Error transcribing audio:', error);
text = (text ? text + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`;
}
}
// Check allowed users
if (this.config.allowedUsers && this.config.allowedUsers.length > 0) {
if (!this.config.allowedUsers.includes(userId)) {
await say("Sorry, you're not authorized to use this bot.");
return;
}
}
// Handle slash commands
const parsed = parseCommand(text);
if (parsed) {
if (parsed.command === 'help' || parsed.command === 'start') {
await say(await markdownToSlackMrkdwn(HELP_TEXT));
} else if (this.onCommand) {
const result = await this.onCommand(parsed.command, channelId, parsed.args || undefined);
if (result) await say(await markdownToSlackMrkdwn(result));
}
return; // Don't pass commands to agent
}
if (this.onMessage) {
const attachments = await this.collectAttachments(
(message as { files?: SlackFile[] }).files,
channelId
);
// Determine if this is a group/channel (not a DM)
// DMs have channel IDs starting with 'D', channels start with 'C'
const isGroup = !channelId.startsWith('D');
let mode: GroupMode = 'open';
// Group gating: config-based allowlist + mode
if (isGroup) {
if (!this.isChannelAllowed(channelId)) {
return; // Channel not in allowlist -- silent drop
}
if (!isGroupUserAllowed(this.config.groups, [channelId], userId || '')) {
return; // User not in group allowedUsers -- silent drop
}
mode = this.resolveChannelMode(channelId);
if (mode === 'disabled') {
return; // Groups disabled for this channel -- silent drop
}
if (mode === 'mention-only') {
// Non-mention message in channel that requires mentions.
// The app_mention handler will process actual @mentions.
return;
}
}
await this.onMessage({
channel: 'slack',
chatId: channelId,
userId: userId || '',
userHandle: userId || '', // Slack user ID serves as handle
messageId: message.ts || undefined,
text: text || '',
timestamp: new Date(Number(message.ts) * 1000),
threadId: threadTs,
isGroup,
groupName: isGroup ? channelId : undefined, // Would need conversations.info for name
wasMentioned: false, // Regular messages; app_mention handles mentions
isListeningMode: mode === 'listen',
attachments,
});
}
});
// Handle app mentions (@bot)
this.app.event('app_mention', async ({ event }) => {
const userId = event.user || '';
let text = (event.text || '').replace(/<@[A-Z0-9]+>/g, '').trim(); // Remove mention
const channelId = event.channel;
const threadTs = event.thread_ts || event.ts; // Reply in thread, or start new thread from the mention
// Handle audio file attachments
const files = (event as any).files as Array<{ mimetype?: string; url_private_download?: string; name?: string }> | undefined;
const audioFile = files?.find(f => f.mimetype?.startsWith('audio/'));
if (audioFile?.url_private_download) {
try {
const { isTranscriptionConfigured } = await import('../transcription/index.js');
if (!isTranscriptionConfigured()) {
await this.sendMessage({ chatId: channelId, text: 'Voice messages require a transcription API key. See: https://github.com/letta-ai/lettabot#voice-messages', threadId: threadTs });
return;
}
// Download file (requires bot token for auth)
const response = await fetch(audioFile.url_private_download, {
headers: { 'Authorization': `Bearer ${this.config.botToken}` }
});
const buffer = Buffer.from(await response.arrayBuffer());
const { transcribeAudio } = await import('../transcription/index.js');
const ext = audioFile.mimetype?.split('/')[1] || 'mp3';
const result = await transcribeAudio(buffer, audioFile.name || `audio.${ext}`);
if (result.success && result.text) {
log.info(`Transcribed audio: "${result.text.slice(0, 50)}..."`);
text = (text ? text + '\n' : '') + `[Voice message]: ${result.text}`;
} else {
log.error(`Transcription failed: ${result.error}`);
text = (text ? text + '\n' : '') + `[Voice message - transcription failed: ${result.error}]`;
}
} catch (error) {
log.error('Error transcribing audio:', error);
text = (text ? text + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`;
}
}
if (this.config.allowedUsers && this.config.allowedUsers.length > 0) {
if (!userId || !this.config.allowedUsers.includes(userId)) {
// Can't use say() in app_mention event the same way
return;
}
}
// Group gating: allowlist + mode + user check (mention already satisfied by app_mention)
if (this.config.groups && !this.isChannelAllowed(channelId)) {
return; // Channel not in allowlist -- silent drop
}
if (this.resolveChannelMode(channelId) === 'disabled') {
return; // Groups disabled for this channel -- silent drop
}
if (!isGroupUserAllowed(this.config.groups, [channelId], userId)) {
return; // User not in group allowedUsers -- silent drop
}
// Handle slash commands
const parsed = parseCommand(text);
if (parsed) {
if (parsed.command === 'help' || parsed.command === 'start') {
await this.sendMessage({ chatId: channelId, text: HELP_TEXT, threadId: threadTs });
} else if (this.onCommand) {
const result = await this.onCommand(parsed.command, channelId, parsed.args || undefined);
if (result) await this.sendMessage({ chatId: channelId, text: result, threadId: threadTs });
}
return; // Don't pass commands to agent
}
if (this.onMessage) {
const attachments = await this.collectAttachments(
(event as { files?: SlackFile[] }).files,
channelId
);
// app_mention is always in a channel (group)
const isGroup = !channelId.startsWith('D');
await this.onMessage({
channel: 'slack',
chatId: channelId,
userId: userId || '',
userHandle: userId || '', // Slack user ID serves as handle
messageId: event.ts || undefined,
text: text || '',
timestamp: new Date(Number(event.ts) * 1000),
threadId: threadTs,
isGroup,
groupName: isGroup ? channelId : undefined,
wasMentioned: true, // app_mention is always a mention
attachments,
});
}
});
this.app.event('reaction_added', async ({ event }) => {
await this.handleReactionEvent(event as SlackReactionEvent, 'added');
});
this.app.event('reaction_removed', async ({ event }) => {
await this.handleReactionEvent(event as SlackReactionEvent, 'removed');
});
log.info('Connecting via Socket Mode...');
await this.app.start();
log.info('Bot started in Socket Mode');
this.running = true;
}
async stop(): Promise<void> {
if (!this.running || !this.app) return;
await this.app.stop();
this.running = false;
}
isRunning(): boolean {
return this.running;
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
if (!this.app) throw new Error('Slack not started');
const formatted = await markdownToSlackMrkdwn(msg.text);
const result = await this.app.client.chat.postMessage({
channel: msg.chatId,
text: formatted,
thread_ts: msg.threadId,
});
return { messageId: result.ts || '' };
}
async sendFile(file: OutboundFile): Promise<{ messageId: string }> {
if (!this.app) throw new Error('Slack not started');
const initialComment = file.caption ? await markdownToSlackMrkdwn(file.caption) : undefined;
const basePayload = {
channels: file.chatId,
file: createReadStream(file.filePath),
filename: basename(file.filePath),
initial_comment: initialComment,
};
const result = file.threadId
? await this.app.client.files.upload({ ...basePayload, thread_ts: file.threadId })
: await this.app.client.files.upload(basePayload);
const shares = (result.file as { shares?: Record<string, Record<string, { ts?: string }[]>> } | undefined)?.shares;
const ts = shares?.public?.[file.chatId]?.[0]?.ts
|| shares?.private?.[file.chatId]?.[0]?.ts
|| '';
return { messageId: ts };
}
supportsEditing(): boolean {
return this.config.streaming ?? false;
}
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
if (!this.app) throw new Error('Slack not started');
const formatted = await markdownToSlackMrkdwn(text);
await this.app.client.chat.update({
channel: chatId,
ts: messageId,
text: formatted,
});
}
async addReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
if (!this.app) throw new Error('Slack not started');
const name = resolveSlackEmojiName(emoji);
if (!name) {
throw new Error('Unknown emoji alias for Slack');
}
await this.app.client.reactions.add({
channel: chatId,
name,
timestamp: messageId,
});
}
getDmPolicy(): string {
return this.config.dmPolicy || 'pairing';
}
/** Check if a channel is allowed by the groups config allowlist */
private isChannelAllowed(channelId: string): boolean {
return isGroupAllowed(this.config.groups, [channelId]);
}
/** Resolve group mode for a channel (specific > wildcard > open). */
private resolveChannelMode(channelId: string): GroupMode {
return resolveGroupMode(this.config.groups, [channelId], 'open');
}
async sendTypingIndicator(_chatId: string): Promise<void> {
// Slack doesn't have a typing indicator API for bots
// This is a no-op
}
private async handleReactionEvent(
event: SlackReactionEvent,
action: InboundReaction['action']
): Promise<void> {
const userId = event.user || '';
if (!userId) return;
if (this.config.allowedUsers && this.config.allowedUsers.length > 0) {
if (!this.config.allowedUsers.includes(userId)) {
return;
}
}
const channelId = event.item?.channel;
const messageId = event.item?.ts;
if (!channelId || !messageId) return;
const emoji = event.reaction ? `:${event.reaction}:` : '';
if (!emoji) return;
const isGroup = !channelId.startsWith('D');
const eventTs = Number(event.event_ts);
const timestamp = Number.isFinite(eventTs) ? new Date(eventTs * 1000) : new Date();
await this.onMessage?.({
channel: 'slack',
chatId: channelId,
userId,
userHandle: userId,
messageId,
text: '',
timestamp,
isGroup,
groupName: isGroup ? channelId : undefined,
reaction: {
emoji,
messageId,
action,
},
});
}
private async collectAttachments(
files: SlackFile[] | undefined,
channelId: string
): Promise<InboundAttachment[]> {
return collectSlackAttachments(
this.attachmentsDir,
this.attachmentsMaxBytes,
channelId,
files,
this.config.botToken
);
}
}
type SlackFile = {
id?: string;
name?: string;
mimetype?: string;
size?: number;
url_private?: string;
url_private_download?: string;
};
type SlackReactionEvent = {
user?: string;
reaction?: string;
item?: {
channel?: string;
ts?: string;
};
event_ts?: string;
};
async function maybeDownloadSlackFile(
attachmentsDir: string | undefined,
attachmentsMaxBytes: number | undefined,
channelId: string,
file: SlackFile,
token: string
): Promise<InboundAttachment> {
const name = file.name || file.id || 'attachment';
const url = file.url_private_download || file.url_private;
const attachment: InboundAttachment = {
id: file.id,
name,
mimeType: file.mimetype,
size: file.size,
kind: file.mimetype?.startsWith('image/') ? 'image' : 'file',
url,
};
if (!attachmentsDir) {
return attachment;
}
if (attachmentsMaxBytes === 0) {
return attachment;
}
if (attachmentsMaxBytes && file.size && file.size > attachmentsMaxBytes) {
log.warn(`Attachment ${name} exceeds size limit, skipping download.`);
return attachment;
}
if (!url) {
return attachment;
}
const target = buildAttachmentPath(attachmentsDir, 'slack', channelId, name);
try {
await downloadToFile(url, target, { Authorization: `Bearer ${token}` });
attachment.localPath = target;
log.info(`Attachment saved to ${target}`);
} catch (err) {
log.warn('Failed to download attachment:', err);
}
return attachment;
}
async function collectSlackAttachments(
attachmentsDir: string | undefined,
attachmentsMaxBytes: number | undefined,
channelId: string,
files: SlackFile[] | undefined,
token: string
): Promise<InboundAttachment[]> {
if (!files || files.length === 0) return [];
const attachments: InboundAttachment[] = [];
for (const file of files) {
attachments.push(await maybeDownloadSlackFile(attachmentsDir, attachmentsMaxBytes, channelId, file, token));
}
return attachments;
}
const EMOJI_ALIAS_TO_UNICODE: Record<string, string> = {
eyes: '👀',
thumbsup: '👍',
thumbs_up: '👍',
'+1': '👍',
heart: '❤️',
fire: '🔥',
smile: '😄',
laughing: '😆',
tada: '🎉',
clap: '👏',
ok_hand: '👌',
};
const UNICODE_TO_ALIAS = new Map<string, string>(
Object.entries(EMOJI_ALIAS_TO_UNICODE).map(([name, value]) => [value, name])
);
function resolveSlackEmojiName(input: string): string | null {
const aliasMatch = input.match(/^:([^:]+):$/);
if (aliasMatch) {
return aliasMatch[1];
}
if (EMOJI_ALIAS_TO_UNICODE[input]) {
return input;
}
return UNICODE_TO_ALIAS.get(input) || null;
}