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
|
# Config with secrets
|
||||||
lettabot.yaml
|
lettabot.yaml
|
||||||
lettabot.yml
|
lettabot.yml
|
||||||
|
bun.lock
|
||||||
|
|||||||
@@ -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
30
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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 { 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
107
src/main.ts
107
src/main.ts
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user