feat(v2/whatsapp): add file attachments, reactions, and inbound media
- Outbound files: images, videos, audio as native media messages; other types as documents. First file gets text as caption. - Reactions: send emoji reactions via Baileys react message type - Inbound media: download images, video, audio, documents from incoming messages and pass as attachments to the agent - Edit operations silently skipped (WhatsApp linked device limitation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,12 +20,13 @@ import {
|
|||||||
Browsers,
|
Browsers,
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
fetchLatestWaWebVersion,
|
fetchLatestWaWebVersion,
|
||||||
|
downloadMediaMessage,
|
||||||
makeCacheableSignalKeyStore,
|
makeCacheableSignalKeyStore,
|
||||||
normalizeMessageContent,
|
normalizeMessageContent,
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
proto,
|
proto,
|
||||||
} from '@whiskeysockets/baileys';
|
} from '@whiskeysockets/baileys';
|
||||||
import type { GroupMetadata, WAMessageKey, WASocket } from '@whiskeysockets/baileys';
|
import type { GroupMetadata, WAMessageKey, WAMessage, WASocket } from '@whiskeysockets/baileys';
|
||||||
|
|
||||||
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js';
|
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js';
|
||||||
import { readEnvFile } from '../env.js';
|
import { readEnvFile } from '../env.js';
|
||||||
@@ -121,9 +122,27 @@ function transformForWhatsApp(text: string): string {
|
|||||||
/** Convert Claude's markdown to WhatsApp-native formatting. */
|
/** Convert Claude's markdown to WhatsApp-native formatting. */
|
||||||
function formatWhatsApp(text: string): string {
|
function formatWhatsApp(text: string): string {
|
||||||
const segments = splitProtectedRegions(text);
|
const segments = splitProtectedRegions(text);
|
||||||
return segments
|
return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join('');
|
||||||
.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content)))
|
}
|
||||||
.join('');
|
|
||||||
|
/** Map file extension to Baileys media message type. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any {
|
||||||
|
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
const videoExts = ['.mp4', '.mov', '.avi', '.mkv'];
|
||||||
|
const audioExts = ['.mp3', '.ogg', '.m4a', '.wav', '.aac', '.opus'];
|
||||||
|
|
||||||
|
if (imageExts.includes(ext)) {
|
||||||
|
return { image: data, caption, mimetype: `image/${ext.slice(1) === 'jpg' ? 'jpeg' : ext.slice(1)}` };
|
||||||
|
}
|
||||||
|
if (videoExts.includes(ext)) {
|
||||||
|
return { video: data, caption, mimetype: `video/${ext.slice(1)}` };
|
||||||
|
}
|
||||||
|
if (audioExts.includes(ext)) {
|
||||||
|
return { audio: data, mimetype: `audio/${ext.slice(1) === 'mp3' ? 'mpeg' : ext.slice(1)}` };
|
||||||
|
}
|
||||||
|
// Default: send as document
|
||||||
|
return { document: data, fileName: filename, caption, mimetype: 'application/octet-stream' };
|
||||||
}
|
}
|
||||||
|
|
||||||
registerChannelAdapter('whatsapp', {
|
registerChannelAdapter('whatsapp', {
|
||||||
@@ -160,10 +179,13 @@ registerChannelAdapter('whatsapp', {
|
|||||||
|
|
||||||
// Pending questions: chatJid → { questionId, options }
|
// Pending questions: chatJid → { questionId, options }
|
||||||
// User replies with /approve, /reject, etc. to answer
|
// User replies with /approve, /reject, etc. to answer
|
||||||
const pendingQuestions = new Map<string, {
|
const pendingQuestions = new Map<
|
||||||
questionId: string;
|
string,
|
||||||
options: string[];
|
{
|
||||||
}>();
|
questionId: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
// Group sync tracking
|
// Group sync tracking
|
||||||
let lastGroupSync = 0;
|
let lastGroupSync = 0;
|
||||||
@@ -274,6 +296,35 @@ registerChannelAdapter('whatsapp', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download media from an inbound message, save to /workspace/attachments/. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function downloadInboundMedia(msg: WAMessage, normalized: any): Promise<Array<{ type: string; name: string; localPath: string }>> {
|
||||||
|
const mediaTypes: Array<{ key: string; type: string; ext: string }> = [
|
||||||
|
{ key: 'imageMessage', type: 'image', ext: '.jpg' },
|
||||||
|
{ key: 'videoMessage', type: 'video', ext: '.mp4' },
|
||||||
|
{ key: 'audioMessage', type: 'audio', ext: '.ogg' },
|
||||||
|
{ key: 'documentMessage', type: 'document', ext: '' },
|
||||||
|
];
|
||||||
|
const results: Array<{ type: string; name: string; localPath: string }> = [];
|
||||||
|
for (const { key, type, ext } of mediaTypes) {
|
||||||
|
if (!normalized[key]) continue;
|
||||||
|
try {
|
||||||
|
const buffer = await downloadMediaMessage(msg, 'buffer', {});
|
||||||
|
const docFilename = normalized[key].fileName;
|
||||||
|
const filename = docFilename || `${type}-${Date.now()}${ext}`;
|
||||||
|
const attachDir = path.join(DATA_DIR, 'attachments');
|
||||||
|
fs.mkdirSync(attachDir, { recursive: true });
|
||||||
|
const filePath = path.join(attachDir, filename);
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
results.push({ type, name: filename, localPath: `attachments/${filename}` });
|
||||||
|
log.info('Media downloaded', { type, filename });
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Failed to download media', { type, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
|
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
outgoingQueue.push({ jid, text });
|
outgoingQueue.push({ jid, text });
|
||||||
@@ -478,8 +529,11 @@ registerChannelAdapter('whatsapp', {
|
|||||||
content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`);
|
content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip empty protocol messages
|
// Download media attachments (images, video, audio, documents)
|
||||||
if (!content) continue;
|
const attachments = await downloadInboundMedia(msg, normalized);
|
||||||
|
|
||||||
|
// Skip empty protocol messages (no text and no attachments)
|
||||||
|
if (!content && attachments.length === 0) continue;
|
||||||
|
|
||||||
const sender = msg.key.participant || msg.key.remoteJid || '';
|
const sender = msg.key.participant || msg.key.remoteJid || '';
|
||||||
const senderName = msg.pushName || sender.split('@')[0];
|
const senderName = msg.pushName || sender.split('@')[0];
|
||||||
@@ -515,6 +569,7 @@ registerChannelAdapter('whatsapp', {
|
|||||||
text: content,
|
text: content,
|
||||||
sender,
|
sender,
|
||||||
senderName,
|
senderName,
|
||||||
|
...(attachments.length > 0 && { attachments }),
|
||||||
fromMe,
|
fromMe,
|
||||||
isBotMessage,
|
isBotMessage,
|
||||||
isGroup,
|
isGroup,
|
||||||
@@ -582,6 +637,21 @@ registerChannelAdapter('whatsapp', {
|
|||||||
return msgId;
|
return msgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reaction → emoji on a message
|
||||||
|
if (content.operation === 'reaction' && content.messageId && content.emoji) {
|
||||||
|
try {
|
||||||
|
await sock.sendMessage(platformId, {
|
||||||
|
react: {
|
||||||
|
text: content.emoji as string,
|
||||||
|
key: { remoteJid: platformId, id: content.messageId as string, fromMe: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.debug('Failed to send reaction', { platformId, err });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Credential request → text fallback (WhatsApp doesn't support modals)
|
// Credential request → text fallback (WhatsApp doesn't support modals)
|
||||||
if (content.type === 'credential_request' && content.credentialId) {
|
if (content.type === 'credential_request' && content.credentialId) {
|
||||||
const question = (content.question as string) || 'A credential has been requested.';
|
const question = (content.question as string) || 'A credential has been requested.';
|
||||||
@@ -590,14 +660,37 @@ registerChannelAdapter('whatsapp', {
|
|||||||
return sendRawMessage(platformId, prefixed);
|
return sendRawMessage(platformId, prefixed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal message
|
// Normal message (with optional file attachments)
|
||||||
const text = (content.markdown as string) || (content.text as string);
|
const text = (content.markdown as string) || (content.text as string);
|
||||||
if (!text) return;
|
const hasFiles = message.files && message.files.length > 0;
|
||||||
|
|
||||||
const formatted = formatWhatsApp(text);
|
if (!text && !hasFiles) return;
|
||||||
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`;
|
|
||||||
|
|
||||||
return sendRawMessage(platformId, prefixed);
|
// Send file attachments (first file gets the caption, rest are captionless)
|
||||||
|
if (hasFiles) {
|
||||||
|
let captionUsed = false;
|
||||||
|
for (const file of message.files!) {
|
||||||
|
try {
|
||||||
|
const ext = path.extname(file.filename).toLowerCase();
|
||||||
|
const caption = !captionUsed ? text : undefined;
|
||||||
|
const mediaMsg = buildMediaMessage(file.data, file.filename, ext, caption);
|
||||||
|
const sent = await sock.sendMessage(platformId, mediaMsg);
|
||||||
|
if (sent?.key?.id && sent.message) {
|
||||||
|
sentMessageCache.set(sent.key.id, sent.message);
|
||||||
|
}
|
||||||
|
if (caption) captionUsed = true;
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to send file', { platformId, filename: file.filename, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (captionUsed) return; // Text was sent as caption
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const formatted = formatWhatsApp(text);
|
||||||
|
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`;
|
||||||
|
return sendRawMessage(platformId, prefixed);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async setTyping(platformId: string) {
|
async setTyping(platformId: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user