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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ data/whatsapp-session/
|
||||
# Config with secrets
|
||||
lettabot.yaml
|
||||
lettabot.yml
|
||||
bun.lock
|
||||
|
||||
@@ -50,3 +50,8 @@ features:
|
||||
heartbeat:
|
||||
enabled: false
|
||||
intervalMin: 30
|
||||
|
||||
# Attachment handling (defaults to 20MB if omitted)
|
||||
# attachments:
|
||||
# maxMB: 20
|
||||
# maxAgeDays: 14
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
61
src/channels/attachments.ts
Normal file
61
src/channels/attachments.ts
Normal 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';
|
||||
}
|
||||
@@ -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<typeof Client> | null = null;
|
||||
private config: DiscordConfig;
|
||||
private running = false;
|
||||
private attachmentsDir?: string;
|
||||
private attachmentsMaxBytes?: number;
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
onCommand?: (command: string) => Promise<string | null>;
|
||||
@@ -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<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;
|
||||
};
|
||||
|
||||
@@ -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<T> = {
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof App> | null = null;
|
||||
private config: SlackConfig;
|
||||
private running = false;
|
||||
private attachmentsDir?: string;
|
||||
private attachmentsMaxBytes?: number;
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
|
||||
constructor(config: SlackConfig) {
|
||||
this.config = config;
|
||||
this.attachmentsDir = config.attachmentsDir;
|
||||
this.attachmentsMaxBytes = config.attachmentsMaxBytes;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -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<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> {
|
||||
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<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> {
|
||||
// Slack doesn't have a typing indicator API for bots
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
onCommand?: (command: string) => Promise<string | null>;
|
||||
@@ -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<void> {
|
||||
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<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> {
|
||||
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<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);
|
||||
|
||||
@@ -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<AsyncIterable<Uint8Array>>;
|
||||
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<AsyncIterable<Uint8Array>>;
|
||||
|
||||
// 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<void> {
|
||||
// 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';
|
||||
}
|
||||
|
||||
@@ -158,6 +158,13 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface LettaBotConfig {
|
||||
|
||||
// Transcription (voice messages)
|
||||
transcription?: TranscriptionConfig;
|
||||
|
||||
// Attachment handling
|
||||
attachments?: {
|
||||
maxMB?: number;
|
||||
maxAgeDays?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TranscriptionConfig {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
107
src/main.ts
107
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<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)
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user