From 67f0550bd3a3e1759229e1eb4760789991cf6085 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 1 Feb 2026 22:14:30 -0800 Subject: [PATCH] Add inbound attachment support with download, metadata, and pruning (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add inbound attachment handling and pruning * Add Signal attachment support and logging - Implement full Signal attachment collection (copies from signal-cli dir) - Add logging when attachments are saved to disk for all channels - Skip audio attachments in Signal (handled by voice transcription) Written by Cameron โ—ฏ Letta Code * Gitignore bun.lock Keep lockfile local, don't track in repo. Written by Cameron โ—ฏ Letta Code --------- Co-authored-by: Jason Carreira --- .gitignore | 1 + lettabot.example.yaml | 5 + package-lock.json | 30 ++++- package.json | 3 +- src/channels/attachments.ts | 61 +++++++++ src/channels/discord.ts | 62 ++++++++- src/channels/signal.ts | 87 ++++++++++++- src/channels/slack.ts | 162 +++++++++++++++++++++++- src/channels/telegram.ts | 244 +++++++++++++++++++++++++++++++++++- src/channels/whatsapp.ts | 208 ++++++++++++++++++++++++++---- src/config/io.ts | 7 ++ src/config/types.ts | 6 + src/core/formatter.ts | 38 +++++- src/core/types.ts | 22 ++++ src/main.ts | 107 +++++++++++++++- 15 files changed, 999 insertions(+), 44 deletions(-) create mode 100644 src/channels/attachments.ts diff --git a/.gitignore b/.gitignore index c43f483..bdf7380 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ data/whatsapp-session/ # Config with secrets lettabot.yaml lettabot.yml +bun.lock diff --git a/lettabot.example.yaml b/lettabot.example.yaml index fa15fa7..d35c463 100644 --- a/lettabot.example.yaml +++ b/lettabot.example.yaml @@ -50,3 +50,8 @@ features: heartbeat: enabled: false intervalMin: 30 + +# Attachment handling (defaults to 20MB if omitted) +# attachments: +# maxMB: 20 +# maxAgeDays: 14 diff --git a/package-lock.json b/package-lock.json index 730185d..14f42dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.7", - "@letta-ai/letta-code-sdk": "^0.0.3", + "@letta-ai/letta-code-sdk": "^0.0.4", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", @@ -23,6 +23,7 @@ "gray-matter": "^4.0.3", "node-schedule": "^2.1.1", "open": "^11.0.0", + "openai": "^6.17.0", "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", @@ -1288,9 +1289,9 @@ } }, "node_modules/@letta-ai/letta-code-sdk": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.0.3.tgz", - "integrity": "sha512-lal4bEGspmPcy0fxTNovgjyev5oOOdHEIkQXXLSzusVdi1yKOgYn3pyfRj/A/h+WgYjr3O/rWvp3yjOXRjf0TA==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.0.4.tgz", + "integrity": "sha512-ipNzKgZA0VF5npOBuQhL9wqQbvhzsEuSXhawqen/jdorSonIEnwFw7OvpVcVvxmah9+5yEk1KvD5ymrVJWu08A==", "license": "Apache-2.0", "dependencies": { "@letta-ai/letta-code": "latest" @@ -5496,6 +5497,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.17.0.tgz", + "integrity": "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", diff --git a/package.json b/package.json index b573940..c49b090 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.7", - "@letta-ai/letta-code-sdk": "^0.0.3", + "@letta-ai/letta-code-sdk": "^0.0.4", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", @@ -50,6 +50,7 @@ "gray-matter": "^4.0.3", "node-schedule": "^2.1.1", "open": "^11.0.0", + "openai": "^6.17.0", "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", diff --git a/src/channels/attachments.ts b/src/channels/attachments.ts new file mode 100644 index 0000000..c2d6941 --- /dev/null +++ b/src/channels/attachments.ts @@ -0,0 +1,61 @@ +import { createWriteStream, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +const SAFE_NAME_RE = /[^A-Za-z0-9._-]/g; + +export function sanitizeFilename(input: string): string { + const cleaned = input.replace(SAFE_NAME_RE, '_').replace(/^_+|_+$/g, ''); + return cleaned || 'attachment'; +} + +export function buildAttachmentPath( + baseDir: string, + channel: string, + chatId: string, + filename?: string +): string { + const safeChannel = sanitizeFilename(channel); + const safeChatId = sanitizeFilename(chatId); + const safeName = sanitizeFilename(filename || 'attachment'); + const dir = join(baseDir, safeChannel, safeChatId); + mkdirSync(dir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const token = randomUUID().slice(0, 8); + return join(dir, `${stamp}-${token}-${safeName}`); +} + +export async function downloadToFile( + url: string, + filePath: string, + headers?: Record +): Promise { + ensureParentDir(filePath); + const res = await fetch(url, { headers }); + if (!res.ok || !res.body) { + throw new Error(`Download failed (${res.status})`); + } + const stream = Readable.from(res.body as unknown as AsyncIterable); + await pipeline(stream, createWriteStream(filePath)); +} + +export async function writeStreamToFile( + stream: AsyncIterable | NodeJS.ReadableStream, + filePath: string +): Promise { + ensureParentDir(filePath); + const readable = isReadableStream(stream) ? stream : Readable.from(stream); + await pipeline(readable, createWriteStream(filePath)); +} + +function ensureParentDir(filePath: string): void { + mkdirSync(dirname(filePath), { recursive: true }); +} + +function isReadableStream( + stream: AsyncIterable | NodeJS.ReadableStream +): stream is NodeJS.ReadableStream { + return typeof (stream as NodeJS.ReadableStream).pipe === 'function'; +} diff --git a/src/channels/discord.ts b/src/channels/discord.ts index f4ce9ac..2704615 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -6,9 +6,10 @@ */ import type { ChannelAdapter } from './types.js'; -import type { InboundMessage, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; +import { buildAttachmentPath, downloadToFile } from './attachments.js'; // Dynamic import to avoid requiring Discord deps if not used let Client: typeof import('discord.js').Client; @@ -19,6 +20,8 @@ export interface DiscordConfig { token: string; dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' allowedUsers?: string[]; // Discord user IDs + attachmentsDir?: string; + attachmentsMaxBytes?: number; } export class DiscordAdapter implements ChannelAdapter { @@ -28,6 +31,8 @@ export class DiscordAdapter implements ChannelAdapter { private client: InstanceType | null = null; private config: DiscordConfig; private running = false; + private attachmentsDir?: string; + private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; onCommand?: (command: string) => Promise; @@ -37,6 +42,8 @@ export class DiscordAdapter implements ChannelAdapter { ...config, dmPolicy: config.dmPolicy || 'pairing', }; + this.attachmentsDir = config.attachmentsDir; + this.attachmentsMaxBytes = config.attachmentsMaxBytes; } /** @@ -190,7 +197,8 @@ Ask the bot owner to approve with: return; } - if (!content) return; + const attachments = await this.collectAttachments(message.attachments, message.channel.id); + if (!content && attachments.length === 0) return; if (content.startsWith('/')) { const command = content.slice(1).split(/\s+/)[0]?.toLowerCase(); @@ -224,10 +232,11 @@ Ask the bot owner to approve with: userName: displayName, userHandle: message.author.username, messageId: message.id, - text: content, + text: content || '', timestamp: message.createdAt, isGroup, groupName, + attachments, }); } }); @@ -291,4 +300,51 @@ Ask the bot owner to approve with: supportsEditing(): boolean { return true; } + + private async collectAttachments(attachments: unknown, channelId: string): Promise { + if (!attachments || typeof attachments !== 'object') return []; + const list = Array.from((attachments as { values: () => Iterable }).values?.() || []); + if (list.length === 0) return []; + const results: InboundAttachment[] = []; + for (const attachment of list) { + const name = attachment.name || attachment.id || 'attachment'; + const entry: InboundAttachment = { + id: attachment.id, + name, + mimeType: attachment.contentType || undefined, + size: attachment.size, + kind: attachment.contentType?.startsWith('image/') ? 'image' : 'file', + url: attachment.url, + }; + if (this.attachmentsDir && attachment.url) { + if (this.attachmentsMaxBytes === 0) { + results.push(entry); + continue; + } + if (this.attachmentsMaxBytes && attachment.size && attachment.size > this.attachmentsMaxBytes) { + console.warn(`[Discord] Attachment ${name} exceeds size limit, skipping download.`); + results.push(entry); + continue; + } + const target = buildAttachmentPath(this.attachmentsDir, 'discord', channelId, name); + try { + await downloadToFile(attachment.url, target); + entry.localPath = target; + console.log(`[Discord] Attachment saved to ${target}`); + } catch (err) { + console.warn('[Discord] Failed to download attachment:', err); + } + } + results.push(entry); + } + return results; + } } + +type DiscordAttachment = { + id?: string; + name?: string | null; + contentType?: string | null; + size?: number; + url?: string; +}; diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 1d52138..fefb8bc 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -6,14 +6,18 @@ */ import type { ChannelAdapter } from './types.js'; -import type { InboundMessage, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, upsertPairingRequest, } from '../pairing/store.js'; +import { buildAttachmentPath } from './attachments.js'; import { spawn, type ChildProcess } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { copyFile, stat } from 'node:fs/promises'; export interface SignalConfig { phoneNumber: string; // Bot's phone number (E.164 format, e.g., +15551234567) @@ -25,6 +29,8 @@ export interface SignalConfig { dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' allowedUsers?: string[]; // Phone numbers (config allowlist) selfChatMode?: boolean; // Respond to Note to Self (default: true) + attachmentsDir?: string; + attachmentsMaxBytes?: number; } type SignalRpcResponse = { @@ -50,6 +56,10 @@ type SignalSseEvent = { contentType?: string; filename?: string; id?: string; + size?: number; + width?: number; + height?: number; + caption?: string; }>; }; syncMessage?: { @@ -66,6 +76,10 @@ type SignalSseEvent = { contentType?: string; filename?: string; id?: string; + size?: number; + width?: number; + height?: number; + caption?: string; }>; }; }; @@ -547,13 +561,16 @@ This code expires in 1 hour.`; } } + // Collect non-voice attachments (images, files, etc.) + const collectedAttachments = await this.collectSignalAttachments(attachments, chatId); + // After processing attachments, check if we have any message content. // If this was a voice-only message and transcription failed/was disabled, // still forward a placeholder so the user knows we got it. if (!messageText && voiceAttachment?.id) { messageText = '[Voice message received]'; } - if (!messageText) { + if (!messageText && collectedAttachments.length === 0) { return; } @@ -609,10 +626,11 @@ This code expires in 1 hour.`; channel: 'signal', chatId, userId: source, - text: messageText, + text: messageText || '', timestamp: new Date(envelope.timestamp || Date.now()), isGroup, groupName: groupInfo?.groupName, + attachments: collectedAttachments.length > 0 ? collectedAttachments : undefined, }; this.onMessage?.(msg).catch((err) => { @@ -669,4 +687,67 @@ This code expires in 1 hour.`; return parsed.result as T; } + + /** + * Collect attachments from a Signal message + * Copies from signal-cli's attachments directory to our attachments directory + */ + private async collectSignalAttachments( + attachments: Array<{ contentType?: string; filename?: string; id?: string; size?: number; width?: number; height?: number; caption?: string }> | undefined, + chatId: string + ): Promise { + if (!attachments || attachments.length === 0) return []; + if (!this.config.attachmentsDir) return []; + + const results: InboundAttachment[] = []; + const signalAttachmentsDir = join(homedir(), '.local/share/signal-cli/attachments'); + + for (const attachment of attachments) { + // Skip voice attachments - handled separately by transcription + if (attachment.contentType?.startsWith('audio/')) continue; + + if (!attachment.id) continue; + + const sourcePath = join(signalAttachmentsDir, attachment.id); + const name = attachment.filename || attachment.id; + + const entry: InboundAttachment = { + id: attachment.id, + name, + mimeType: attachment.contentType, + size: attachment.size, + kind: attachment.contentType?.startsWith('image/') ? 'image' + : attachment.contentType?.startsWith('video/') ? 'video' + : 'file', + }; + + // Check size limit + if (this.config.attachmentsMaxBytes && this.config.attachmentsMaxBytes > 0) { + try { + const stats = await stat(sourcePath); + if (stats.size > this.config.attachmentsMaxBytes) { + console.warn(`[Signal] Attachment ${name} exceeds size limit, skipping download.`); + results.push(entry); + continue; + } + } catch { + // File might not exist + } + } + + // Copy to our attachments directory + const target = buildAttachmentPath(this.config.attachmentsDir, 'signal', chatId, name); + try { + await copyFile(sourcePath, target); + entry.localPath = target; + console.log(`[Signal] Attachment saved to ${target}`); + } catch (err) { + console.warn('[Signal] Failed to copy attachment:', err); + } + + results.push(entry); + } + + return results; + } } diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 2ed9337..b10d22c 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -5,7 +5,10 @@ */ import type { ChannelAdapter } from './types.js'; -import type { InboundMessage, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js'; +import { createReadStream } from 'node:fs'; +import { basename } from 'node:path'; +import { buildAttachmentPath, downloadToFile } from './attachments.js'; // Dynamic import to avoid requiring Slack deps if not used let App: typeof import('@slack/bolt').App; @@ -14,6 +17,8 @@ export interface SlackConfig { botToken: string; // xoxb-... appToken: string; // xapp-... (for Socket Mode) allowedUsers?: string[]; // Slack user IDs (e.g., U01234567) + attachmentsDir?: string; + attachmentsMaxBytes?: number; } export class SlackAdapter implements ChannelAdapter { @@ -23,11 +28,15 @@ export class SlackAdapter implements ChannelAdapter { private app: InstanceType | null = null; private config: SlackConfig; private running = false; + private attachmentsDir?: string; + private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; constructor(config: SlackConfig) { this.config = config; + this.attachmentsDir = config.attachmentsDir; + this.attachmentsMaxBytes = config.attachmentsMaxBytes; } async start(): Promise { @@ -91,6 +100,10 @@ export class SlackAdapter implements ChannelAdapter { } 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'); @@ -106,6 +119,7 @@ export class SlackAdapter implements ChannelAdapter { threadId: threadTs, isGroup, groupName: isGroup ? channelId : undefined, // Would need conversations.info for name + attachments, }); } }); @@ -125,6 +139,10 @@ export class SlackAdapter implements ChannelAdapter { } 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'); @@ -139,6 +157,7 @@ export class SlackAdapter implements ChannelAdapter { threadId: threadTs, isGroup, groupName: isGroup ? channelId : undefined, + attachments, }); } }); @@ -170,6 +189,27 @@ export class SlackAdapter implements ChannelAdapter { return { messageId: result.ts || '' }; } + + async sendFile(file: OutboundFile): Promise<{ messageId: string }> { + if (!this.app) throw new Error('Slack not started'); + + const basePayload = { + channels: file.chatId, + file: createReadStream(file.filePath), + filename: basename(file.filePath), + initial_comment: file.caption, + }; + 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> } | undefined)?.shares; + const ts = shares?.public?.[file.chatId]?.[0]?.ts + || shares?.private?.[file.chatId]?.[0]?.ts + || ''; + + return { messageId: ts }; + } async editMessage(chatId: string, messageId: string, text: string): Promise { if (!this.app) throw new Error('Slack not started'); @@ -180,9 +220,129 @@ export class SlackAdapter implements ChannelAdapter { text, }); } + + async addReaction(chatId: string, messageId: string, emoji: string): Promise { + 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, + }); + } async sendTypingIndicator(_chatId: string): Promise { // Slack doesn't have a typing indicator API for bots // This is a no-op } + + private async collectAttachments( + files: SlackFile[] | undefined, + channelId: string + ): Promise { + 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; +}; + +async function maybeDownloadSlackFile( + attachmentsDir: string | undefined, + attachmentsMaxBytes: number | undefined, + channelId: string, + file: SlackFile, + token: string +): Promise { + 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) { + console.warn(`[Slack] 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; + console.log(`[Slack] Attachment saved to ${target}`); + } catch (err) { + console.warn('[Slack] Failed to download attachment:', err); + } + return attachment; +} + +async function collectSlackAttachments( + attachmentsDir: string | undefined, + attachmentsMaxBytes: number | undefined, + channelId: string, + files: SlackFile[] | undefined, + token: string +): Promise { + 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 = { + eyes: '๐Ÿ‘€', + thumbsup: '๐Ÿ‘', + thumbs_up: '๐Ÿ‘', + '+1': '๐Ÿ‘', + heart: 'โค๏ธ', + fire: '๐Ÿ”ฅ', + smile: '๐Ÿ˜„', + laughing: '๐Ÿ˜†', + tada: '๐ŸŽ‰', + clap: '๐Ÿ‘', + ok_hand: '๐Ÿ‘Œ', +}; + +const UNICODE_TO_ALIAS = new Map( + 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; } diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index c5364a9..c83cee1 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -5,20 +5,24 @@ * Supports DM pairing for secure access control. */ -import { Bot } from 'grammy'; +import { Bot, InputFile } from 'grammy'; import type { ChannelAdapter } from './types.js'; -import type { InboundMessage, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, upsertPairingRequest, formatPairingMessage, } from '../pairing/store.js'; +import { basename } from 'node:path'; +import { buildAttachmentPath, downloadToFile } from './attachments.js'; export interface TelegramConfig { token: string; dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' allowedUsers?: number[]; // Telegram user IDs (config allowlist) + attachmentsDir?: string; + attachmentsMaxBytes?: number; } export class TelegramAdapter implements ChannelAdapter { @@ -28,6 +32,8 @@ export class TelegramAdapter implements ChannelAdapter { private bot: Bot; private config: TelegramConfig; private running = false; + private attachmentsDir?: string; + private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; onCommand?: (command: string) => Promise; @@ -38,6 +44,8 @@ export class TelegramAdapter implements ChannelAdapter { dmPolicy: config.dmPolicy || 'pairing', // Default to pairing }; this.bot = new Bot(config.token); + this.attachmentsDir = config.attachmentsDir; + this.attachmentsMaxBytes = config.attachmentsMaxBytes; this.setupHandlers(); } @@ -166,6 +174,30 @@ export class TelegramAdapter implements ChannelAdapter { }); } }); + + // Handle non-text messages with attachments + this.bot.on('message', async (ctx) => { + if (!ctx.message || ctx.message.text) return; + const userId = ctx.from?.id; + const chatId = ctx.chat.id; + if (!userId) return; + + const { attachments, caption } = await this.collectAttachments(ctx.message, String(chatId)); + if (attachments.length === 0 && !caption) return; + + if (this.onMessage) { + await this.onMessage({ + channel: 'telegram', + chatId: String(chatId), + userId: String(userId), + userName: ctx.from.username || ctx.from.first_name, + messageId: String(ctx.message.message_id), + text: caption || '', + timestamp: new Date(), + attachments, + }); + } + }); // Handle voice messages this.bot.on('message:voice', async (ctx) => { @@ -257,12 +289,35 @@ export class TelegramAdapter implements ChannelAdapter { }); return { messageId: String(result.message_id) }; } + + async sendFile(file: OutboundFile): Promise<{ messageId: string }> { + const input = new InputFile(file.filePath); + const caption = file.caption || undefined; + + if (file.kind === 'image') { + const result = await this.bot.api.sendPhoto(file.chatId, input, { caption }); + return { messageId: String(result.message_id) }; + } + + const result = await this.bot.api.sendDocument(file.chatId, input, { caption }); + return { messageId: String(result.message_id) }; + } async editMessage(chatId: string, messageId: string, text: string): Promise { const { markdownToTelegramV2 } = await import('./telegram-format.js'); const formatted = await markdownToTelegramV2(text); await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' }); } + + async addReaction(chatId: string, messageId: string, emoji: string): Promise { + const resolved = resolveTelegramEmoji(emoji); + if (!TELEGRAM_REACTION_SET.has(resolved)) { + throw new Error(`Unsupported Telegram reaction emoji: ${resolved}`); + } + await this.bot.api.setMessageReaction(chatId, Number(messageId), [ + { type: 'emoji', emoji: resolved as TelegramReactionEmoji }, + ]); + } async sendTypingIndicator(chatId: string): Promise { await this.bot.api.sendChatAction(chatId, 'typing'); @@ -274,4 +329,189 @@ export class TelegramAdapter implements ChannelAdapter { getBot(): Bot { return this.bot; } + + private async collectAttachments( + message: any, + chatId: string + ): Promise<{ attachments: InboundAttachment[]; caption?: string }> { + const attachments: InboundAttachment[] = []; + const caption = message.caption as string | undefined; + + if (message.photo && message.photo.length > 0) { + const photo = message.photo[message.photo.length - 1]; + const attachment = await this.fetchTelegramFile({ + fileId: photo.file_id, + fileName: `photo-${photo.file_unique_id}.jpg`, + mimeType: 'image/jpeg', + size: photo.file_size, + kind: 'image', + chatId, + }); + if (attachment) attachments.push(attachment); + } + + if (message.document) { + const doc = message.document; + const attachment = await this.fetchTelegramFile({ + fileId: doc.file_id, + fileName: doc.file_name, + mimeType: doc.mime_type, + size: doc.file_size, + kind: 'file', + chatId, + }); + if (attachment) attachments.push(attachment); + } + + if (message.video) { + const video = message.video; + const attachment = await this.fetchTelegramFile({ + fileId: video.file_id, + fileName: video.file_name || `video-${video.file_unique_id}.mp4`, + mimeType: video.mime_type, + size: video.file_size, + kind: 'video', + chatId, + }); + if (attachment) attachments.push(attachment); + } + + if (message.audio) { + const audio = message.audio; + const attachment = await this.fetchTelegramFile({ + fileId: audio.file_id, + fileName: audio.file_name || `audio-${audio.file_unique_id}.mp3`, + mimeType: audio.mime_type, + size: audio.file_size, + kind: 'audio', + chatId, + }); + if (attachment) attachments.push(attachment); + } + + if (message.voice) { + const voice = message.voice; + const attachment = await this.fetchTelegramFile({ + fileId: voice.file_id, + fileName: `voice-${voice.file_unique_id}.ogg`, + mimeType: voice.mime_type, + size: voice.file_size, + kind: 'audio', + chatId, + }); + if (attachment) attachments.push(attachment); + } + + if (message.animation) { + const animation = message.animation; + const attachment = await this.fetchTelegramFile({ + fileId: animation.file_id, + fileName: animation.file_name || `animation-${animation.file_unique_id}.mp4`, + mimeType: animation.mime_type, + size: animation.file_size, + kind: 'video', + chatId, + }); + if (attachment) attachments.push(attachment); + } + + if (message.sticker) { + const sticker = message.sticker; + const attachment = await this.fetchTelegramFile({ + fileId: sticker.file_id, + fileName: `sticker-${sticker.file_unique_id}.${sticker.is_animated ? 'tgs' : 'webp'}`, + mimeType: sticker.mime_type, + size: sticker.file_size, + kind: 'image', + chatId, + }); + if (attachment) attachments.push(attachment); + } + + return { attachments, caption }; + } + + private async fetchTelegramFile(options: { + fileId: string; + fileName?: string; + mimeType?: string; + size?: number; + kind?: InboundAttachment['kind']; + chatId: string; + }): Promise { + const { fileId, fileName, mimeType, size, kind, chatId } = options; + const attachment: InboundAttachment = { + id: fileId, + name: fileName, + mimeType, + size, + kind, + }; + + if (!this.attachmentsDir) { + return attachment; + } + if (this.attachmentsMaxBytes === 0) { + return attachment; + } + if (this.attachmentsMaxBytes && size && size > this.attachmentsMaxBytes) { + console.warn(`[Telegram] Attachment ${fileName || fileId} exceeds size limit, skipping download.`); + return attachment; + } + + try { + const file = await this.bot.api.getFile(fileId); + const remotePath = file.file_path; + if (!remotePath) return attachment; + const resolvedName = fileName || basename(remotePath) || fileId; + const target = buildAttachmentPath(this.attachmentsDir, 'telegram', chatId, resolvedName); + const url = `https://api.telegram.org/file/bot${this.config.token}/${remotePath}`; + await downloadToFile(url, target); + attachment.localPath = target; + console.log(`[Telegram] Attachment saved to ${target}`); + } catch (err) { + console.warn('[Telegram] Failed to download attachment:', err); + } + return attachment; + } } + +const TELEGRAM_EMOJI_ALIAS_TO_UNICODE: Record = { + eyes: '๐Ÿ‘€', + thumbsup: '๐Ÿ‘', + thumbs_up: '๐Ÿ‘', + '+1': '๐Ÿ‘', + heart: 'โค๏ธ', + fire: '๐Ÿ”ฅ', + smile: '๐Ÿ˜„', + laughing: '๐Ÿ˜†', + tada: '๐ŸŽ‰', + clap: '๐Ÿ‘', + ok_hand: '๐Ÿ‘Œ', +}; + +function resolveTelegramEmoji(input: string): string { + const match = input.match(/^:([^:]+):$/); + const alias = match ? match[1] : null; + if (alias && TELEGRAM_EMOJI_ALIAS_TO_UNICODE[alias]) { + return TELEGRAM_EMOJI_ALIAS_TO_UNICODE[alias]; + } + if (TELEGRAM_EMOJI_ALIAS_TO_UNICODE[input]) { + return TELEGRAM_EMOJI_ALIAS_TO_UNICODE[input]; + } + return input; +} + +const TELEGRAM_REACTION_EMOJIS = [ + '๐Ÿ‘', '๐Ÿ‘Ž', 'โค', '๐Ÿ”ฅ', '๐Ÿฅฐ', '๐Ÿ‘', '๐Ÿ˜', '๐Ÿค”', '๐Ÿคฏ', '๐Ÿ˜ฑ', '๐Ÿคฌ', '๐Ÿ˜ข', + '๐ŸŽ‰', '๐Ÿคฉ', '๐Ÿคฎ', '๐Ÿ’ฉ', '๐Ÿ™', '๐Ÿ‘Œ', '๐Ÿ•Š', '๐Ÿคก', '๐Ÿฅฑ', '๐Ÿฅด', '๐Ÿ˜', '๐Ÿณ', + 'โคโ€๐Ÿ”ฅ', '๐ŸŒš', '๐ŸŒญ', '๐Ÿ’ฏ', '๐Ÿคฃ', 'โšก', '๐ŸŒ', '๐Ÿ†', '๐Ÿ’”', '๐Ÿคจ', '๐Ÿ˜', '๐Ÿ“', + '๐Ÿพ', '๐Ÿ’‹', '๐Ÿ–•', '๐Ÿ˜ˆ', '๐Ÿ˜ด', '๐Ÿ˜ญ', '๐Ÿค“', '๐Ÿ‘ป', '๐Ÿ‘จโ€๐Ÿ’ป', '๐Ÿ‘€', '๐ŸŽƒ', '๐Ÿ™ˆ', + '๐Ÿ˜‡', '๐Ÿ˜จ', '๐Ÿค', 'โœ', '๐Ÿค—', '๐Ÿซก', '๐ŸŽ…', '๐ŸŽ„', 'โ˜ƒ', '๐Ÿ’…', '๐Ÿคช', '๐Ÿ—ฟ', + '๐Ÿ†’', '๐Ÿ’˜', '๐Ÿ™‰', '๐Ÿฆ„', '๐Ÿ˜˜', '๐Ÿ’Š', '๐Ÿ™Š', '๐Ÿ˜Ž', '๐Ÿ‘พ', '๐Ÿคทโ€โ™‚', '๐Ÿคท', + '๐Ÿคทโ€โ™€', '๐Ÿ˜ก', +] as const; + +type TelegramReactionEmoji = typeof TELEGRAM_REACTION_EMOJIS[number]; + +const TELEGRAM_REACTION_SET = new Set(TELEGRAM_REACTION_EMOJIS); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index fddd387..3dedbe3 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -6,7 +6,7 @@ */ import type { ChannelAdapter } from './types.js'; -import type { InboundMessage, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, @@ -15,14 +15,17 @@ import { } from '../pairing/store.js'; import { normalizePhoneForStorage } from '../utils/phone.js'; import { existsSync, mkdirSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { basename, resolve } from 'node:path'; import qrcode from 'qrcode-terminal'; +import { buildAttachmentPath, writeStreamToFile } from './attachments.js'; export interface WhatsAppConfig { sessionPath?: string; // Where to store auth state dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' allowedUsers?: string[]; // Phone numbers (e.g., +15551234567) selfChatMode?: boolean; // Respond to "message yourself" (for personal number use) + attachmentsDir?: string; + attachmentsMaxBytes?: number; } export class WhatsAppAdapter implements ChannelAdapter { @@ -33,6 +36,9 @@ export class WhatsAppAdapter implements ChannelAdapter { private config: WhatsAppConfig; private running = false; private sessionPath: string; + private attachmentsDir?: string; + private attachmentsMaxBytes?: number; + private downloadContentFromMessage?: (message: any, type: string) => Promise>; private myJid: string = ''; // Bot's own JID (for selfChatMode) private myNumber: string = ''; // Bot's phone number private selfChatLid: string = ''; // Self-chat LID (for selfChatMode conversion) @@ -48,6 +54,8 @@ export class WhatsAppAdapter implements ChannelAdapter { dmPolicy: config.dmPolicy || 'pairing', // Default to pairing }; this.sessionPath = resolve(config.sessionPath || './data/whatsapp-session'); + this.attachmentsDir = config.attachmentsDir; + this.attachmentsMaxBytes = config.attachmentsMaxBytes; } /** @@ -144,6 +152,7 @@ Ask the bot owner to approve with: fetchLatestBaileysVersion, makeCacheableSignalKeyStore, downloadMediaMessage, + downloadContentFromMessage, } = await import('@whiskeysockets/baileys'); // Load auth state @@ -177,6 +186,10 @@ Ask the bot owner to approve with: markOnlineOnConnect: false, logger: silentLogger as any, }); + this.downloadContentFromMessage = downloadContentFromMessage as unknown as ( + message: any, + type: string + ) => Promise>; // Save credentials when updated this.sock.ev.on('creds.update', saveCreds); @@ -255,13 +268,14 @@ Ask the bot owner to approve with: this.lidToJid.set(remoteJid, (m.key as any).senderPn); } - // Get message text or audio - let text = m.message?.conversation || - m.message?.extendedTextMessage?.text || + // Unwrap message content (handles ephemeral/viewOnce messages) + const messageContent = this.unwrapMessageContent(m.message); + let text = messageContent?.conversation || + messageContent?.extendedTextMessage?.text || ''; - // Handle audio/voice messages - const audioMessage = m.message?.audioMessage; + // Handle audio/voice messages - transcribe if configured + const audioMessage = messageContent?.audioMessage; if (audioMessage) { try { const { loadConfig } = await import('../config/index.js'); @@ -288,7 +302,11 @@ Ask the bot owner to approve with: } } - if (!text) continue; + // Detect other media (images, videos, documents) + const preview = this.extractMediaPreview(messageContent); + const resolvedText = text || preview.caption || ''; + + if (!resolvedText && !preview.hasMedia) continue; const userId = normalizePhoneForStorage(remoteJid); const isGroup = remoteJid.endsWith('@g.us'); @@ -332,17 +350,22 @@ Ask the bot owner to approve with: } if (this.onMessage) { + const attachments = preview.hasMedia + ? (await this.collectAttachments(messageContent, remoteJid, messageId)).attachments + : []; + const finalText = text || preview.caption || ''; await this.onMessage({ channel: 'whatsapp', chatId: remoteJid, userId, userName: pushName || undefined, messageId: m.key?.id || undefined, - text, + text: finalText, timestamp: new Date(m.messageTimestamp * 1000), isGroup, // Group name would require additional API call to get chat metadata // For now, we don't have it readily available from the message + attachments, }); } } @@ -361,22 +384,8 @@ Ask the bot owner to approve with: async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> { if (!this.sock) throw new Error('WhatsApp not connected'); - - // Convert LID to proper JID for sending - let targetJid = msg.chatId; - if (targetJid.includes('@lid')) { - if (targetJid === this.selfChatLid && this.myNumber) { - // Self-chat LID -> our own number - targetJid = `${this.myNumber}@s.whatsapp.net`; - } else if (this.lidToJid.has(targetJid)) { - // Friend LID -> their real JID from senderPn - targetJid = this.lidToJid.get(targetJid)!; - } else { - // FAIL SAFE: Don't send to unknown LID - could go to wrong person - console.error(`[WhatsApp] Cannot send to unknown LID: ${targetJid}`); - throw new Error(`Cannot send to unknown LID - no mapping found`); - } - } + + const targetJid = this.resolveTargetJid(msg.chatId); try { const result = await this.sock.sendMessage(targetJid, { text: msg.text }); @@ -395,6 +404,29 @@ Ask the bot owner to approve with: throw error; } } + + async sendFile(file: OutboundFile): Promise<{ messageId: string }> { + if (!this.sock) throw new Error('WhatsApp not connected'); + + const targetJid = this.resolveTargetJid(file.chatId); + const caption = file.caption || undefined; + const fileName = basename(file.filePath); + const payload = file.kind === 'image' + ? { image: { url: file.filePath }, caption } + : { document: { url: file.filePath }, caption, fileName }; + + const result = await this.sock.sendMessage(targetJid, payload); + const messageId = result?.key?.id || ''; + if (messageId) { + this.sentMessageIds.add(messageId); + setTimeout(() => this.sentMessageIds.delete(messageId), 60000); + } + return { messageId }; + } + + async addReaction(_chatId: string, _messageId: string, _emoji: string): Promise { + // WhatsApp reactions via Baileys are not supported here yet. + } supportsEditing(): boolean { return false; @@ -408,4 +440,130 @@ Ask the bot owner to approve with: if (!this.sock) return; await this.sock.sendPresenceUpdate('composing', chatId); } + + private unwrapMessageContent(message: any): any { + if (!message) return null; + if (message.ephemeralMessage?.message) return message.ephemeralMessage.message; + if (message.viewOnceMessage?.message) return message.viewOnceMessage.message; + if (message.viewOnceMessageV2?.message) return message.viewOnceMessageV2.message; + return message; + } + + private extractMediaPreview(messageContent: any): { hasMedia: boolean; caption?: string } { + if (!messageContent) return { hasMedia: false }; + const mediaMessage = messageContent.imageMessage + || messageContent.videoMessage + || messageContent.audioMessage + || messageContent.documentMessage + || messageContent.stickerMessage; + if (!mediaMessage) return { hasMedia: false }; + return { hasMedia: true, caption: mediaMessage.caption as string | undefined }; + } + + private async collectAttachments( + messageContent: any, + chatId: string, + messageId: string + ): Promise<{ attachments: InboundAttachment[]; caption?: string }> { + const attachments: InboundAttachment[] = []; + if (!messageContent) return { attachments }; + if (!this.downloadContentFromMessage) return { attachments }; + + let mediaMessage: any; + let mediaType: 'image' | 'video' | 'audio' | 'document' | 'sticker' | null = null; + let kind: InboundAttachment['kind'] = 'file'; + + if (messageContent.imageMessage) { + mediaMessage = messageContent.imageMessage; + mediaType = 'image'; + kind = 'image'; + } else if (messageContent.videoMessage) { + mediaMessage = messageContent.videoMessage; + mediaType = 'video'; + kind = 'video'; + } else if (messageContent.audioMessage) { + mediaMessage = messageContent.audioMessage; + mediaType = 'audio'; + kind = 'audio'; + } else if (messageContent.documentMessage) { + mediaMessage = messageContent.documentMessage; + mediaType = 'document'; + kind = 'file'; + } else if (messageContent.stickerMessage) { + mediaMessage = messageContent.stickerMessage; + mediaType = 'sticker'; + kind = 'image'; + } + + if (!mediaMessage || !mediaType) return { attachments }; + + const mimeType = mediaMessage.mimetype as string | undefined; + const fileLength = mediaMessage.fileLength; + const size = typeof fileLength === 'number' + ? fileLength + : typeof fileLength?.toNumber === 'function' + ? fileLength.toNumber() + : undefined; + const ext = extensionFromMime(mimeType); + const defaultName = `whatsapp-${messageId}.${ext}`; + const name = mediaMessage.fileName || defaultName; + + const attachment: InboundAttachment = { + name, + mimeType, + size, + kind, + }; + + if (this.attachmentsDir) { + if (this.attachmentsMaxBytes === 0) { + attachments.push(attachment); + const caption = mediaMessage.caption as string | undefined; + return { attachments, caption }; + } + if (this.attachmentsMaxBytes && size && size > this.attachmentsMaxBytes) { + console.warn(`[WhatsApp] Attachment ${name} exceeds size limit, skipping download.`); + attachments.push(attachment); + const caption = mediaMessage.caption as string | undefined; + return { attachments, caption }; + } + const target = buildAttachmentPath(this.attachmentsDir, 'whatsapp', chatId, name); + try { + const stream = await this.downloadContentFromMessage(mediaMessage, mediaType); + await writeStreamToFile(stream, target); + attachment.localPath = target; + console.log(`[WhatsApp] Attachment saved to ${target}`); + } catch (err) { + console.warn('[WhatsApp] Failed to download attachment:', err); + } + } + + attachments.push(attachment); + const caption = mediaMessage.caption as string | undefined; + return { attachments, caption }; + } + + private resolveTargetJid(chatId: string): string { + let targetJid = chatId; + if (targetJid.includes('@lid')) { + if (targetJid === this.selfChatLid && this.myNumber) { + targetJid = `${this.myNumber}@s.whatsapp.net`; + } else if (this.lidToJid.has(targetJid)) { + targetJid = this.lidToJid.get(targetJid)!; + } else { + console.error(`[WhatsApp] Cannot send to unknown LID: ${targetJid}`); + throw new Error('Cannot send to unknown LID - no mapping found'); + } + } + return targetJid; + } +} + +function extensionFromMime(mimeType?: string): string { + if (!mimeType) return 'bin'; + const clean = mimeType.split(';')[0] || ''; + const parts = clean.split('/'); + if (parts.length < 2) return 'bin'; + const ext = parts[1].trim(); + return ext || 'bin'; } diff --git a/src/config/io.ts b/src/config/io.ts index 9c357f5..06bc6f2 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -158,6 +158,13 @@ export function configToEnv(config: LettaBotConfig): Record { if (config.integrations?.google?.enabled && config.integrations.google.account) { env.GMAIL_ACCOUNT = config.integrations.google.account; } + + if (config.attachments?.maxMB !== undefined) { + env.ATTACHMENTS_MAX_MB = String(config.attachments.maxMB); + } + if (config.attachments?.maxAgeDays !== undefined) { + env.ATTACHMENTS_MAX_AGE_DAYS = String(config.attachments.maxAgeDays); + } return env; } diff --git a/src/config/types.ts b/src/config/types.ts index 1ee7077..e5442c3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -52,6 +52,12 @@ export interface LettaBotConfig { // Transcription (voice messages) transcription?: TranscriptionConfig; + + // Attachment handling + attachments?: { + maxMB?: number; + maxAgeDays?: number; + }; } export interface TranscriptionConfig { diff --git a/src/core/formatter.ts b/src/core/formatter.ts index 8efd2bb..9239920 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -140,6 +140,34 @@ function formatTimestamp(date: Date, options: EnvelopeOptions): string { return parts.join(', '); } +function formatBytes(size?: number): string | null { + if (!size || size < 0) return null; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; + return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function formatAttachments(msg: InboundMessage): string { + if (!msg.attachments || msg.attachments.length === 0) return ''; + const lines = msg.attachments.map((attachment) => { + const name = attachment.name || attachment.id || 'attachment'; + const details: string[] = []; + if (attachment.mimeType) details.push(attachment.mimeType); + const size = formatBytes(attachment.size); + if (size) details.push(size); + const detailText = details.length > 0 ? ` (${details.join(', ')})` : ''; + if (attachment.localPath) { + return `- ${name}${detailText} saved to ${attachment.localPath}`; + } + if (attachment.url) { + return `- ${name}${detailText} ${attachment.url}`; + } + return `- ${name}${detailText}`; + }); + return `Attachments:\n${lines.join('\n')}`; +} + /** * Format a message with metadata envelope * @@ -187,10 +215,14 @@ export function formatMessageEnvelope( // Build envelope const envelope = `[${parts.join(' ')}]`; - + // Add format hint so agent knows what formatting syntax to use const formatHint = CHANNEL_FORMATS[msg.channel]; const hint = formatHint ? `\n(Format: ${formatHint})` : ''; - - return `${envelope} ${msg.text}${hint}`; + + const attachmentBlock = formatAttachments(msg); + const bodyParts = [msg.text, attachmentBlock].filter((part) => part && part.trim()); + const body = bodyParts.join('\n'); + const spacer = body ? ` ${body}` : ''; + return `${envelope}${spacer}${hint}`; } diff --git a/src/core/types.ts b/src/core/types.ts index 446ad98..86eea12 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -45,6 +45,16 @@ export interface TriggerContext { export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal' | 'discord'; +export interface InboundAttachment { + id?: string; + name?: string; + mimeType?: string; + size?: number; + url?: string; + localPath?: string; + kind?: 'image' | 'file' | 'audio' | 'video'; +} + /** * Inbound message from any channel */ @@ -60,6 +70,7 @@ export interface InboundMessage { threadId?: string; // Slack thread_ts isGroup?: boolean; // Is this from a group chat? groupName?: string; // Group/channel name if applicable + attachments?: InboundAttachment[]; } /** @@ -72,6 +83,17 @@ export interface OutboundMessage { threadId?: string; // Slack thread_ts } +/** + * Outbound file/image to any channel. + */ +export interface OutboundFile { + chatId: string; + filePath: string; + caption?: string; + threadId?: string; + kind?: 'image' | 'file'; +} + /** * Bot configuration */ diff --git a/src/main.ts b/src/main.ts index 7fada86..0e54631 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,8 +6,8 @@ */ import { createServer } from 'node:http'; -import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, readdirSync, promises as fs } from 'node:fs'; +import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; // Load YAML config and apply to process.env (overrides .env values) @@ -135,6 +135,84 @@ function parseHeartbeatTarget(raw?: string): { channel: string; chatId: string } return { channel: channel.toLowerCase(), chatId }; } +const DEFAULT_ATTACHMENTS_MAX_MB = 20; +const DEFAULT_ATTACHMENTS_MAX_AGE_DAYS = 14; +const ATTACHMENTS_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000; + +function resolveAttachmentsMaxBytes(): number { + const rawBytes = Number(process.env.ATTACHMENTS_MAX_BYTES); + if (Number.isFinite(rawBytes) && rawBytes >= 0) { + return rawBytes; + } + const rawMb = Number(process.env.ATTACHMENTS_MAX_MB); + if (Number.isFinite(rawMb) && rawMb >= 0) { + return Math.round(rawMb * 1024 * 1024); + } + return DEFAULT_ATTACHMENTS_MAX_MB * 1024 * 1024; +} + +function resolveAttachmentsMaxAgeDays(): number { + const raw = Number(process.env.ATTACHMENTS_MAX_AGE_DAYS); + if (Number.isFinite(raw) && raw >= 0) { + return raw; + } + return DEFAULT_ATTACHMENTS_MAX_AGE_DAYS; +} + +async function pruneAttachmentsDir(baseDir: string, maxAgeDays: number): Promise { + if (maxAgeDays <= 0) return; + if (!existsSync(baseDir)) return; + const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; + let deleted = 0; + + const walk = async (dir: string): Promise => { + let entries: Array; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return true; + } + let hasEntries = false; + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + const childHasEntries = await walk(fullPath); + if (!childHasEntries) { + try { + await fs.rmdir(fullPath); + } catch { + hasEntries = true; + } + } else { + hasEntries = true; + } + continue; + } + if (entry.isFile()) { + try { + const stats = await fs.stat(fullPath); + if (stats.mtimeMs < cutoff) { + await fs.rm(fullPath, { force: true }); + deleted += 1; + } else { + hasEntries = true; + } + } catch { + hasEntries = true; + } + continue; + } + hasEntries = true; + } + return hasEntries; + }; + + await walk(baseDir); + if (deleted > 0) { + console.log(`[Attachments] Pruned ${deleted} file(s) older than ${maxAgeDays} days.`); + } +} + // Skills are installed to agent-scoped directory when agent is created (see core/bot.ts) // Configuration from environment @@ -142,6 +220,8 @@ const config = { workingDir: process.env.WORKING_DIR || '/tmp/lettabot', model: process.env.MODEL, // e.g., 'claude-sonnet-4-20250514' allowedTools: (process.env.ALLOWED_TOOLS || 'Bash,Read,Edit,Write,Glob,Grep,Task,web_search,conversation_search').split(','), + attachmentsMaxBytes: resolveAttachmentsMaxBytes(), + attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(), // Channel configs telegram: { @@ -234,6 +314,19 @@ async function main() { agentName: process.env.AGENT_NAME || 'LettaBot', allowedTools: config.allowedTools, }); + + const attachmentsDir = resolve(config.workingDir, 'attachments'); + pruneAttachmentsDir(attachmentsDir, config.attachmentsMaxAgeDays).catch((err) => { + console.warn('[Attachments] Prune failed:', err); + }); + if (config.attachmentsMaxAgeDays > 0) { + const timer = setInterval(() => { + pruneAttachmentsDir(attachmentsDir, config.attachmentsMaxAgeDays).catch((err) => { + console.warn('[Attachments] Prune failed:', err); + }); + }, ATTACHMENTS_PRUNE_INTERVAL_MS); + timer.unref?.(); + } // Verify agent exists (clear stale ID if deleted) let initialStatus = bot.getStatus(); @@ -257,6 +350,8 @@ async function main() { token: config.telegram.token, dmPolicy: config.telegram.dmPolicy, allowedUsers: config.telegram.allowedUsers.length > 0 ? config.telegram.allowedUsers : undefined, + attachmentsDir, + attachmentsMaxBytes: config.attachmentsMaxBytes, }); bot.registerChannel(telegram); } @@ -266,6 +361,8 @@ async function main() { botToken: config.slack.botToken, appToken: config.slack.appToken, allowedUsers: config.slack.allowedUsers.length > 0 ? config.slack.allowedUsers : undefined, + attachmentsDir, + attachmentsMaxBytes: config.attachmentsMaxBytes, }); bot.registerChannel(slack); } @@ -276,6 +373,8 @@ async function main() { dmPolicy: config.whatsapp.dmPolicy, allowedUsers: config.whatsapp.allowedUsers.length > 0 ? config.whatsapp.allowedUsers : undefined, selfChatMode: config.whatsapp.selfChatMode, + attachmentsDir, + attachmentsMaxBytes: config.attachmentsMaxBytes, }); bot.registerChannel(whatsapp); } @@ -289,6 +388,8 @@ async function main() { dmPolicy: config.signal.dmPolicy, allowedUsers: config.signal.allowedUsers.length > 0 ? config.signal.allowedUsers : undefined, selfChatMode: config.signal.selfChatMode, + attachmentsDir, + attachmentsMaxBytes: config.attachmentsMaxBytes, }); bot.registerChannel(signal); } @@ -298,6 +399,8 @@ async function main() { token: config.discord.token, dmPolicy: config.discord.dmPolicy, allowedUsers: config.discord.allowedUsers.length > 0 ? config.discord.allowedUsers : undefined, + attachmentsDir, + attachmentsMaxBytes: config.attachmentsMaxBytes, }); bot.registerChannel(discord); }