Add inbound attachment support with download, metadata, and pruning (#64)

* 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 <jason@visotrust.com>
This commit is contained in:
Cameron
2026-02-01 22:14:30 -08:00
committed by GitHub
parent 7f12cbdc60
commit 67f0550bd3
15 changed files with 999 additions and 44 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ data/whatsapp-session/
# Config with secrets # Config with secrets
lettabot.yaml lettabot.yaml
lettabot.yml lettabot.yml
bun.lock

View File

@@ -50,3 +50,8 @@ features:
heartbeat: heartbeat:
enabled: false enabled: false
intervalMin: 30 intervalMin: 30
# Attachment handling (defaults to 20MB if omitted)
# attachments:
# maxMB: 20
# maxAgeDays: 14

30
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1", "@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.7", "@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/express": "^5.0.6",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",
@@ -23,6 +23,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"open": "^11.0.0", "open": "^11.0.0",
"openai": "^6.17.0",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"telegram-markdown-v2": "^0.0.4", "telegram-markdown-v2": "^0.0.4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@@ -1288,9 +1289,9 @@
} }
}, },
"node_modules/@letta-ai/letta-code-sdk": { "node_modules/@letta-ai/letta-code-sdk": {
"version": "0.0.3", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.0.3.tgz", "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.0.4.tgz",
"integrity": "sha512-lal4bEGspmPcy0fxTNovgjyev5oOOdHEIkQXXLSzusVdi1yKOgYn3pyfRj/A/h+WgYjr3O/rWvp3yjOXRjf0TA==", "integrity": "sha512-ipNzKgZA0VF5npOBuQhL9wqQbvhzsEuSXhawqen/jdorSonIEnwFw7OvpVcVvxmah9+5yEk1KvD5ymrVJWu08A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@letta-ai/letta-code": "latest" "@letta-ai/letta-code": "latest"
@@ -5496,6 +5497,27 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/p-finally": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",

View File

@@ -39,7 +39,7 @@
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1", "@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.7", "@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/express": "^5.0.6",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",
@@ -50,6 +50,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"open": "^11.0.0", "open": "^11.0.0",
"openai": "^6.17.0",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"telegram-markdown-v2": "^0.0.4", "telegram-markdown-v2": "^0.0.4",
"tsx": "^4.21.0", "tsx": "^4.21.0",

View File

@@ -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<string, string>
): Promise<void> {
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<Uint8Array>);
await pipeline(stream, createWriteStream(filePath));
}
export async function writeStreamToFile(
stream: AsyncIterable<Uint8Array> | NodeJS.ReadableStream,
filePath: string
): Promise<void> {
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<Uint8Array> | NodeJS.ReadableStream
): stream is NodeJS.ReadableStream {
return typeof (stream as NodeJS.ReadableStream).pipe === 'function';
}

View File

@@ -6,9 +6,10 @@
*/ */
import type { ChannelAdapter } from './types.js'; 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 type { DmPolicy } from '../pairing/types.js';
import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
// Dynamic import to avoid requiring Discord deps if not used // Dynamic import to avoid requiring Discord deps if not used
let Client: typeof import('discord.js').Client; let Client: typeof import('discord.js').Client;
@@ -19,6 +20,8 @@ export interface DiscordConfig {
token: string; token: string;
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: string[]; // Discord user IDs allowedUsers?: string[]; // Discord user IDs
attachmentsDir?: string;
attachmentsMaxBytes?: number;
} }
export class DiscordAdapter implements ChannelAdapter { export class DiscordAdapter implements ChannelAdapter {
@@ -28,6 +31,8 @@ export class DiscordAdapter implements ChannelAdapter {
private client: InstanceType<typeof Client> | null = null; private client: InstanceType<typeof Client> | null = null;
private config: DiscordConfig; private config: DiscordConfig;
private running = false; private running = false;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>; onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>; onCommand?: (command: string) => Promise<string | null>;
@@ -37,6 +42,8 @@ export class DiscordAdapter implements ChannelAdapter {
...config, ...config,
dmPolicy: config.dmPolicy || 'pairing', dmPolicy: config.dmPolicy || 'pairing',
}; };
this.attachmentsDir = config.attachmentsDir;
this.attachmentsMaxBytes = config.attachmentsMaxBytes;
} }
/** /**
@@ -190,7 +197,8 @@ Ask the bot owner to approve with:
return; return;
} }
if (!content) return; const attachments = await this.collectAttachments(message.attachments, message.channel.id);
if (!content && attachments.length === 0) return;
if (content.startsWith('/')) { if (content.startsWith('/')) {
const command = content.slice(1).split(/\s+/)[0]?.toLowerCase(); const command = content.slice(1).split(/\s+/)[0]?.toLowerCase();
@@ -224,10 +232,11 @@ Ask the bot owner to approve with:
userName: displayName, userName: displayName,
userHandle: message.author.username, userHandle: message.author.username,
messageId: message.id, messageId: message.id,
text: content, text: content || '',
timestamp: message.createdAt, timestamp: message.createdAt,
isGroup, isGroup,
groupName, groupName,
attachments,
}); });
} }
}); });
@@ -291,4 +300,51 @@ Ask the bot owner to approve with:
supportsEditing(): boolean { supportsEditing(): boolean {
return true; return true;
} }
private async collectAttachments(attachments: unknown, channelId: string): Promise<InboundAttachment[]> {
if (!attachments || typeof attachments !== 'object') return [];
const list = Array.from((attachments as { values: () => Iterable<DiscordAttachment> }).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;
};

View File

@@ -6,14 +6,18 @@
*/ */
import type { ChannelAdapter } from './types.js'; 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 type { DmPolicy } from '../pairing/types.js';
import { import {
isUserAllowed, isUserAllowed,
upsertPairingRequest, upsertPairingRequest,
} from '../pairing/store.js'; } from '../pairing/store.js';
import { buildAttachmentPath } from './attachments.js';
import { spawn, type ChildProcess } from 'node:child_process'; import { spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto'; 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 { export interface SignalConfig {
phoneNumber: string; // Bot's phone number (E.164 format, e.g., +15551234567) 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' dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: string[]; // Phone numbers (config allowlist) allowedUsers?: string[]; // Phone numbers (config allowlist)
selfChatMode?: boolean; // Respond to Note to Self (default: true) selfChatMode?: boolean; // Respond to Note to Self (default: true)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
} }
type SignalRpcResponse<T> = { type SignalRpcResponse<T> = {
@@ -50,6 +56,10 @@ type SignalSseEvent = {
contentType?: string; contentType?: string;
filename?: string; filename?: string;
id?: string; id?: string;
size?: number;
width?: number;
height?: number;
caption?: string;
}>; }>;
}; };
syncMessage?: { syncMessage?: {
@@ -66,6 +76,10 @@ type SignalSseEvent = {
contentType?: string; contentType?: string;
filename?: string; filename?: string;
id?: 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. // After processing attachments, check if we have any message content.
// If this was a voice-only message and transcription failed/was disabled, // If this was a voice-only message and transcription failed/was disabled,
// still forward a placeholder so the user knows we got it. // still forward a placeholder so the user knows we got it.
if (!messageText && voiceAttachment?.id) { if (!messageText && voiceAttachment?.id) {
messageText = '[Voice message received]'; messageText = '[Voice message received]';
} }
if (!messageText) { if (!messageText && collectedAttachments.length === 0) {
return; return;
} }
@@ -609,10 +626,11 @@ This code expires in 1 hour.`;
channel: 'signal', channel: 'signal',
chatId, chatId,
userId: source, userId: source,
text: messageText, text: messageText || '',
timestamp: new Date(envelope.timestamp || Date.now()), timestamp: new Date(envelope.timestamp || Date.now()),
isGroup, isGroup,
groupName: groupInfo?.groupName, groupName: groupInfo?.groupName,
attachments: collectedAttachments.length > 0 ? collectedAttachments : undefined,
}; };
this.onMessage?.(msg).catch((err) => { this.onMessage?.(msg).catch((err) => {
@@ -669,4 +687,67 @@ This code expires in 1 hour.`;
return parsed.result as T; 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<InboundAttachment[]> {
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;
}
} }

View File

@@ -5,7 +5,10 @@
*/ */
import type { ChannelAdapter } from './types.js'; 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 // Dynamic import to avoid requiring Slack deps if not used
let App: typeof import('@slack/bolt').App; let App: typeof import('@slack/bolt').App;
@@ -14,6 +17,8 @@ export interface SlackConfig {
botToken: string; // xoxb-... botToken: string; // xoxb-...
appToken: string; // xapp-... (for Socket Mode) appToken: string; // xapp-... (for Socket Mode)
allowedUsers?: string[]; // Slack user IDs (e.g., U01234567) allowedUsers?: string[]; // Slack user IDs (e.g., U01234567)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
} }
export class SlackAdapter implements ChannelAdapter { export class SlackAdapter implements ChannelAdapter {
@@ -23,11 +28,15 @@ export class SlackAdapter implements ChannelAdapter {
private app: InstanceType<typeof App> | null = null; private app: InstanceType<typeof App> | null = null;
private config: SlackConfig; private config: SlackConfig;
private running = false; private running = false;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>; onMessage?: (msg: InboundMessage) => Promise<void>;
constructor(config: SlackConfig) { constructor(config: SlackConfig) {
this.config = config; this.config = config;
this.attachmentsDir = config.attachmentsDir;
this.attachmentsMaxBytes = config.attachmentsMaxBytes;
} }
async start(): Promise<void> { async start(): Promise<void> {
@@ -91,6 +100,10 @@ export class SlackAdapter implements ChannelAdapter {
} }
if (this.onMessage) { if (this.onMessage) {
const attachments = await this.collectAttachments(
(message as { files?: SlackFile[] }).files,
channelId
);
// Determine if this is a group/channel (not a DM) // Determine if this is a group/channel (not a DM)
// DMs have channel IDs starting with 'D', channels start with 'C' // DMs have channel IDs starting with 'D', channels start with 'C'
const isGroup = !channelId.startsWith('D'); const isGroup = !channelId.startsWith('D');
@@ -106,6 +119,7 @@ export class SlackAdapter implements ChannelAdapter {
threadId: threadTs, threadId: threadTs,
isGroup, isGroup,
groupName: isGroup ? channelId : undefined, // Would need conversations.info for name groupName: isGroup ? channelId : undefined, // Would need conversations.info for name
attachments,
}); });
} }
}); });
@@ -125,6 +139,10 @@ export class SlackAdapter implements ChannelAdapter {
} }
if (this.onMessage) { if (this.onMessage) {
const attachments = await this.collectAttachments(
(event as { files?: SlackFile[] }).files,
channelId
);
// app_mention is always in a channel (group) // app_mention is always in a channel (group)
const isGroup = !channelId.startsWith('D'); const isGroup = !channelId.startsWith('D');
@@ -139,6 +157,7 @@ export class SlackAdapter implements ChannelAdapter {
threadId: threadTs, threadId: threadTs,
isGroup, isGroup,
groupName: isGroup ? channelId : undefined, groupName: isGroup ? channelId : undefined,
attachments,
}); });
} }
}); });
@@ -170,6 +189,27 @@ export class SlackAdapter implements ChannelAdapter {
return { messageId: result.ts || '' }; 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<string, Record<string, { ts?: string }[]>> } | 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<void> { async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
if (!this.app) throw new Error('Slack not started'); if (!this.app) throw new Error('Slack not started');
@@ -180,9 +220,129 @@ export class SlackAdapter implements ChannelAdapter {
text, text,
}); });
} }
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,
});
}
async sendTypingIndicator(_chatId: string): Promise<void> { async sendTypingIndicator(_chatId: string): Promise<void> {
// Slack doesn't have a typing indicator API for bots // Slack doesn't have a typing indicator API for bots
// This is a no-op // This is a no-op
} }
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;
};
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) {
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<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;
} }

View File

@@ -5,20 +5,24 @@
* Supports DM pairing for secure access control. * Supports DM pairing for secure access control.
*/ */
import { Bot } from 'grammy'; import { Bot, InputFile } from 'grammy';
import type { ChannelAdapter } from './types.js'; 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 type { DmPolicy } from '../pairing/types.js';
import { import {
isUserAllowed, isUserAllowed,
upsertPairingRequest, upsertPairingRequest,
formatPairingMessage, formatPairingMessage,
} from '../pairing/store.js'; } from '../pairing/store.js';
import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
export interface TelegramConfig { export interface TelegramConfig {
token: string; token: string;
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: number[]; // Telegram user IDs (config allowlist) allowedUsers?: number[]; // Telegram user IDs (config allowlist)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
} }
export class TelegramAdapter implements ChannelAdapter { export class TelegramAdapter implements ChannelAdapter {
@@ -28,6 +32,8 @@ export class TelegramAdapter implements ChannelAdapter {
private bot: Bot; private bot: Bot;
private config: TelegramConfig; private config: TelegramConfig;
private running = false; private running = false;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>; onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>; onCommand?: (command: string) => Promise<string | null>;
@@ -38,6 +44,8 @@ export class TelegramAdapter implements ChannelAdapter {
dmPolicy: config.dmPolicy || 'pairing', // Default to pairing dmPolicy: config.dmPolicy || 'pairing', // Default to pairing
}; };
this.bot = new Bot(config.token); this.bot = new Bot(config.token);
this.attachmentsDir = config.attachmentsDir;
this.attachmentsMaxBytes = config.attachmentsMaxBytes;
this.setupHandlers(); 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 // Handle voice messages
this.bot.on('message:voice', async (ctx) => { this.bot.on('message:voice', async (ctx) => {
@@ -257,12 +289,35 @@ export class TelegramAdapter implements ChannelAdapter {
}); });
return { messageId: String(result.message_id) }; 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<void> { async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
const { markdownToTelegramV2 } = await import('./telegram-format.js'); const { markdownToTelegramV2 } = await import('./telegram-format.js');
const formatted = await markdownToTelegramV2(text); const formatted = await markdownToTelegramV2(text);
await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' }); await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' });
} }
async addReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
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<void> { async sendTypingIndicator(chatId: string): Promise<void> {
await this.bot.api.sendChatAction(chatId, 'typing'); await this.bot.api.sendChatAction(chatId, 'typing');
@@ -274,4 +329,189 @@ export class TelegramAdapter implements ChannelAdapter {
getBot(): Bot { getBot(): Bot {
return this.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<InboundAttachment | null> {
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<string, string> = {
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<string>(TELEGRAM_REACTION_EMOJIS);

View File

@@ -6,7 +6,7 @@
*/ */
import type { ChannelAdapter } from './types.js'; 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 type { DmPolicy } from '../pairing/types.js';
import { import {
isUserAllowed, isUserAllowed,
@@ -15,14 +15,17 @@ import {
} from '../pairing/store.js'; } from '../pairing/store.js';
import { normalizePhoneForStorage } from '../utils/phone.js'; import { normalizePhoneForStorage } from '../utils/phone.js';
import { existsSync, mkdirSync } from 'node:fs'; import { existsSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path'; import { basename, resolve } from 'node:path';
import qrcode from 'qrcode-terminal'; import qrcode from 'qrcode-terminal';
import { buildAttachmentPath, writeStreamToFile } from './attachments.js';
export interface WhatsAppConfig { export interface WhatsAppConfig {
sessionPath?: string; // Where to store auth state sessionPath?: string; // Where to store auth state
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: string[]; // Phone numbers (e.g., +15551234567) allowedUsers?: string[]; // Phone numbers (e.g., +15551234567)
selfChatMode?: boolean; // Respond to "message yourself" (for personal number use) selfChatMode?: boolean; // Respond to "message yourself" (for personal number use)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
} }
export class WhatsAppAdapter implements ChannelAdapter { export class WhatsAppAdapter implements ChannelAdapter {
@@ -33,6 +36,9 @@ export class WhatsAppAdapter implements ChannelAdapter {
private config: WhatsAppConfig; private config: WhatsAppConfig;
private running = false; private running = false;
private sessionPath: string; private sessionPath: string;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
private downloadContentFromMessage?: (message: any, type: string) => Promise<AsyncIterable<Uint8Array>>;
private myJid: string = ''; // Bot's own JID (for selfChatMode) private myJid: string = ''; // Bot's own JID (for selfChatMode)
private myNumber: string = ''; // Bot's phone number private myNumber: string = ''; // Bot's phone number
private selfChatLid: string = ''; // Self-chat LID (for selfChatMode conversion) 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 dmPolicy: config.dmPolicy || 'pairing', // Default to pairing
}; };
this.sessionPath = resolve(config.sessionPath || './data/whatsapp-session'); 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, fetchLatestBaileysVersion,
makeCacheableSignalKeyStore, makeCacheableSignalKeyStore,
downloadMediaMessage, downloadMediaMessage,
downloadContentFromMessage,
} = await import('@whiskeysockets/baileys'); } = await import('@whiskeysockets/baileys');
// Load auth state // Load auth state
@@ -177,6 +186,10 @@ Ask the bot owner to approve with:
markOnlineOnConnect: false, markOnlineOnConnect: false,
logger: silentLogger as any, logger: silentLogger as any,
}); });
this.downloadContentFromMessage = downloadContentFromMessage as unknown as (
message: any,
type: string
) => Promise<AsyncIterable<Uint8Array>>;
// Save credentials when updated // Save credentials when updated
this.sock.ev.on('creds.update', saveCreds); 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); this.lidToJid.set(remoteJid, (m.key as any).senderPn);
} }
// Get message text or audio // Unwrap message content (handles ephemeral/viewOnce messages)
let text = m.message?.conversation || const messageContent = this.unwrapMessageContent(m.message);
m.message?.extendedTextMessage?.text || let text = messageContent?.conversation ||
messageContent?.extendedTextMessage?.text ||
''; '';
// Handle audio/voice messages // Handle audio/voice messages - transcribe if configured
const audioMessage = m.message?.audioMessage; const audioMessage = messageContent?.audioMessage;
if (audioMessage) { if (audioMessage) {
try { try {
const { loadConfig } = await import('../config/index.js'); 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 userId = normalizePhoneForStorage(remoteJid);
const isGroup = remoteJid.endsWith('@g.us'); const isGroup = remoteJid.endsWith('@g.us');
@@ -332,17 +350,22 @@ Ask the bot owner to approve with:
} }
if (this.onMessage) { if (this.onMessage) {
const attachments = preview.hasMedia
? (await this.collectAttachments(messageContent, remoteJid, messageId)).attachments
: [];
const finalText = text || preview.caption || '';
await this.onMessage({ await this.onMessage({
channel: 'whatsapp', channel: 'whatsapp',
chatId: remoteJid, chatId: remoteJid,
userId, userId,
userName: pushName || undefined, userName: pushName || undefined,
messageId: m.key?.id || undefined, messageId: m.key?.id || undefined,
text, text: finalText,
timestamp: new Date(m.messageTimestamp * 1000), timestamp: new Date(m.messageTimestamp * 1000),
isGroup, isGroup,
// Group name would require additional API call to get chat metadata // Group name would require additional API call to get chat metadata
// For now, we don't have it readily available from the message // 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 }> { async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
if (!this.sock) throw new Error('WhatsApp not connected'); if (!this.sock) throw new Error('WhatsApp not connected');
// Convert LID to proper JID for sending const targetJid = this.resolveTargetJid(msg.chatId);
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`);
}
}
try { try {
const result = await this.sock.sendMessage(targetJid, { text: msg.text }); const result = await this.sock.sendMessage(targetJid, { text: msg.text });
@@ -395,6 +404,29 @@ Ask the bot owner to approve with:
throw error; 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<void> {
// WhatsApp reactions via Baileys are not supported here yet.
}
supportsEditing(): boolean { supportsEditing(): boolean {
return false; return false;
@@ -408,4 +440,130 @@ Ask the bot owner to approve with:
if (!this.sock) return; if (!this.sock) return;
await this.sock.sendPresenceUpdate('composing', chatId); 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';
} }

View File

@@ -158,6 +158,13 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
if (config.integrations?.google?.enabled && config.integrations.google.account) { if (config.integrations?.google?.enabled && config.integrations.google.account) {
env.GMAIL_ACCOUNT = 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; return env;
} }

View File

@@ -52,6 +52,12 @@ export interface LettaBotConfig {
// Transcription (voice messages) // Transcription (voice messages)
transcription?: TranscriptionConfig; transcription?: TranscriptionConfig;
// Attachment handling
attachments?: {
maxMB?: number;
maxAgeDays?: number;
};
} }
export interface TranscriptionConfig { export interface TranscriptionConfig {

View File

@@ -140,6 +140,34 @@ function formatTimestamp(date: Date, options: EnvelopeOptions): string {
return parts.join(', '); 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 * Format a message with metadata envelope
* *
@@ -187,10 +215,14 @@ export function formatMessageEnvelope(
// Build envelope // Build envelope
const envelope = `[${parts.join(' ')}]`; const envelope = `[${parts.join(' ')}]`;
// Add format hint so agent knows what formatting syntax to use // Add format hint so agent knows what formatting syntax to use
const formatHint = CHANNEL_FORMATS[msg.channel]; const formatHint = CHANNEL_FORMATS[msg.channel];
const hint = formatHint ? `\n(Format: ${formatHint})` : ''; 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}`;
} }

View File

@@ -45,6 +45,16 @@ export interface TriggerContext {
export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal' | 'discord'; 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 * Inbound message from any channel
*/ */
@@ -60,6 +70,7 @@ export interface InboundMessage {
threadId?: string; // Slack thread_ts threadId?: string; // Slack thread_ts
isGroup?: boolean; // Is this from a group chat? isGroup?: boolean; // Is this from a group chat?
groupName?: string; // Group/channel name if applicable groupName?: string; // Group/channel name if applicable
attachments?: InboundAttachment[];
} }
/** /**
@@ -72,6 +83,17 @@ export interface OutboundMessage {
threadId?: string; // Slack thread_ts 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 * Bot configuration
*/ */

View File

@@ -6,8 +6,8 @@
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, readdirSync, promises as fs } from 'node:fs';
import { resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
// Load YAML config and apply to process.env (overrides .env values) // 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 }; 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<void> {
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<boolean> => {
let entries: Array<import('node:fs').Dirent>;
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) // Skills are installed to agent-scoped directory when agent is created (see core/bot.ts)
// Configuration from environment // Configuration from environment
@@ -142,6 +220,8 @@ const config = {
workingDir: process.env.WORKING_DIR || '/tmp/lettabot', workingDir: process.env.WORKING_DIR || '/tmp/lettabot',
model: process.env.MODEL, // e.g., 'claude-sonnet-4-20250514' 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(','), allowedTools: (process.env.ALLOWED_TOOLS || 'Bash,Read,Edit,Write,Glob,Grep,Task,web_search,conversation_search').split(','),
attachmentsMaxBytes: resolveAttachmentsMaxBytes(),
attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(),
// Channel configs // Channel configs
telegram: { telegram: {
@@ -234,6 +314,19 @@ async function main() {
agentName: process.env.AGENT_NAME || 'LettaBot', agentName: process.env.AGENT_NAME || 'LettaBot',
allowedTools: config.allowedTools, 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) // Verify agent exists (clear stale ID if deleted)
let initialStatus = bot.getStatus(); let initialStatus = bot.getStatus();
@@ -257,6 +350,8 @@ async function main() {
token: config.telegram.token, token: config.telegram.token,
dmPolicy: config.telegram.dmPolicy, dmPolicy: config.telegram.dmPolicy,
allowedUsers: config.telegram.allowedUsers.length > 0 ? config.telegram.allowedUsers : undefined, allowedUsers: config.telegram.allowedUsers.length > 0 ? config.telegram.allowedUsers : undefined,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
}); });
bot.registerChannel(telegram); bot.registerChannel(telegram);
} }
@@ -266,6 +361,8 @@ async function main() {
botToken: config.slack.botToken, botToken: config.slack.botToken,
appToken: config.slack.appToken, appToken: config.slack.appToken,
allowedUsers: config.slack.allowedUsers.length > 0 ? config.slack.allowedUsers : undefined, allowedUsers: config.slack.allowedUsers.length > 0 ? config.slack.allowedUsers : undefined,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
}); });
bot.registerChannel(slack); bot.registerChannel(slack);
} }
@@ -276,6 +373,8 @@ async function main() {
dmPolicy: config.whatsapp.dmPolicy, dmPolicy: config.whatsapp.dmPolicy,
allowedUsers: config.whatsapp.allowedUsers.length > 0 ? config.whatsapp.allowedUsers : undefined, allowedUsers: config.whatsapp.allowedUsers.length > 0 ? config.whatsapp.allowedUsers : undefined,
selfChatMode: config.whatsapp.selfChatMode, selfChatMode: config.whatsapp.selfChatMode,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
}); });
bot.registerChannel(whatsapp); bot.registerChannel(whatsapp);
} }
@@ -289,6 +388,8 @@ async function main() {
dmPolicy: config.signal.dmPolicy, dmPolicy: config.signal.dmPolicy,
allowedUsers: config.signal.allowedUsers.length > 0 ? config.signal.allowedUsers : undefined, allowedUsers: config.signal.allowedUsers.length > 0 ? config.signal.allowedUsers : undefined,
selfChatMode: config.signal.selfChatMode, selfChatMode: config.signal.selfChatMode,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
}); });
bot.registerChannel(signal); bot.registerChannel(signal);
} }
@@ -298,6 +399,8 @@ async function main() {
token: config.discord.token, token: config.discord.token,
dmPolicy: config.discord.dmPolicy, dmPolicy: config.discord.dmPolicy,
allowedUsers: config.discord.allowedUsers.length > 0 ? config.discord.allowedUsers : undefined, allowedUsers: config.discord.allowedUsers.length > 0 ? config.discord.allowedUsers : undefined,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
}); });
bot.registerChannel(discord); bot.registerChannel(discord);
} }