Files
nanoclaw/src/channels/whatsapp.ts
gavrielc e55ed0f4e8 fix(whatsapp): upgrade Baileys 6.7→6.17, fix proto import and 515 restart
Baileys 6.7.21 silently failed the pairing handshake. Upgrade to 6.17.16
which fixes this. Three related issues:

1. proto is no longer a named ESM export in 6.17.x — use createRequire
   to import via CJS (matching the proven v1 pattern).
2. Setup auth script didn't handle the 515 stream restart that WhatsApp
   sends after successful pairing. Refactored to reconnect (matching v1's
   connectSocket(isReconnect) pattern) instead of hanging until timeout.
3. Added succeeded guard and process.exit(0) to prevent timeout race
   after successful auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:01:55 +03:00

750 lines
28 KiB
TypeScript

/**
* WhatsApp channel adapter (v2) — native Baileys v6 implementation.
*
* Implements ChannelAdapter directly (no Chat SDK bridge) using
* @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure:
* getMessage fallback, outgoing queue, group metadata cache, LID mapping,
* reconnection with backoff.
*
* Auth credentials persist in store/auth/. On first run:
* - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log)
* - Otherwise → QR code (printed to log)
* Subsequent restarts reuse the saved session automatically.
*/
import fs from 'fs';
import path from 'path';
import pino from 'pino';
import {
makeWASocket,
Browsers,
DisconnectReason,
fetchLatestWaWebVersion,
downloadMediaMessage,
makeCacheableSignalKeyStore,
normalizeMessageContent,
useMultiFileAuthState,
} 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 { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { registerChannelAdapter } from './channel-registry.js';
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
import type {
ChannelAdapter,
ChannelSetup,
ConversationConfig,
ConversationInfo,
InboundMessage,
OutboundMessage,
} from './adapter.js';
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
// Fixed in Baileys 7.x but not backported. Without this, pairing codes fail with
// "couldn't link device" because WhatsApp receives an invalid platform ID.
// Must use createRequire — ESM `import *` creates a read-only namespace.
// proto is not available as a named ESM export — use createRequire (same as v1)
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
try {
const _generics = _require('@whiskeysockets/baileys/lib/Utils/generics') as Record<string, unknown>;
_generics.getPlatformId = (browser: string): string => {
const platformType =
proto.DeviceProps.PlatformType[browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType];
return platformType ? platformType.toString() : '1';
};
} catch {
// If CJS require fails (Node version mismatch), pairing codes may not work
// but QR auth will still function fine.
log.warn('Could not patch getPlatformId — pairing code auth may fail');
}
const baileysLogger = pino({ level: 'silent' });
const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends
const SENT_MESSAGE_CACHE_MAX = 256;
const RECONNECT_DELAY_MS = 5000;
const PENDING_QUESTIONS_MAX = 64;
/** Normalize an option label to a slash command: "Approve" → "/approve" */
function optionToCommand(option: string): string {
return '/' + option.toLowerCase().replace(/\s+/g, '-');
}
// --- Markdown → WhatsApp formatting ---
interface TextSegment {
content: string;
isProtected: boolean;
}
/** Split text into code-block-protected and unprotected regions. */
function splitProtectedRegions(text: string): TextSegment[] {
const segments: TextSegment[] = [];
const codeBlockRegex = /```[\s\S]*?```|`[^`\n]+`/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ content: text.slice(lastIndex, match.index), isProtected: false });
}
segments.push({ content: match[0], isProtected: true });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ content: text.slice(lastIndex), isProtected: false });
}
return segments;
}
/** Apply WhatsApp-native formatting to an unprotected text segment. */
function transformForWhatsApp(text: string): string {
// Order matters: italic before bold to avoid **bold** → *bold* → _bold_
// 1. Italic: *text* (not **) → _text_
text = text.replace(/(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*)/g, '_$1_');
// 2. Bold: **text** → *text*
text = text.replace(/\*\*(?=[^\s*])([^*]+?)(?<=[^\s*])\*\*/g, '*$1*');
// 3. Headings: ## Title → *Title*
text = text.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
// 4. Links: [text](url) → text (url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
// 5. Horizontal rules: --- / *** / ___ → stripped
text = text.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '');
return text;
}
/** Convert Claude's markdown to WhatsApp-native formatting. */
function formatWhatsApp(text: string): string {
const segments = splitProtectedRegions(text);
return segments.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', {
factory: () => {
const env = readEnvFile(['WHATSAPP_PHONE_NUMBER', 'WHATSAPP_ENABLED']);
const phoneNumber = env.WHATSAPP_PHONE_NUMBER;
const authDir = AUTH_DIR;
// Skip if no existing auth, no phone number for pairing, and not explicitly enabled (QR mode)
const hasAuth = fs.existsSync(path.join(authDir, 'creds.json'));
if (!hasAuth && !phoneNumber && !env.WHATSAPP_ENABLED) return null;
fs.mkdirSync(authDir, { recursive: true });
// State
let sock: WASocket;
let connected = false;
let setupConfig: ChannelSetup;
let conversations: Map<string, ConversationConfig>;
// LID → phone JID mapping (WhatsApp's new ID system)
const lidToPhoneMap: Record<string, string> = {};
let botLidUser: string | undefined;
// Outgoing queue for messages sent while disconnected
const outgoingQueue: Array<{ jid: string; text: string }> = [];
let flushing = false;
// Sent message cache for retry/re-encrypt requests
const sentMessageCache = new Map<string, any>();
// Group metadata cache with TTL
const groupMetadataCache = new Map<string, { metadata: GroupMetadata; expiresAt: number }>();
// Pending questions: chatJid → { questionId, options }
// User replies with /approve, /reject, etc. to answer
const pendingQuestions = new Map<
string,
{
questionId: string;
options: NormalizedOption[];
}
>();
// Group sync tracking
let lastGroupSync = 0;
let groupSyncTimerStarted = false;
// First-connect promise
let resolveFirstOpen: (() => void) | undefined;
let rejectFirstOpen: ((err: Error) => void) | undefined;
// Pairing code file for the setup skill to poll
const pairingCodeFile = path.join(process.cwd(), 'store', 'pairing-code.txt');
// --- Helpers ---
function buildConversationMap(configs: ConversationConfig[]): Map<string, ConversationConfig> {
const map = new Map<string, ConversationConfig>();
for (const conv of configs) map.set(conv.platformId, conv);
return map;
}
function setLidPhoneMapping(lidUser: string, phoneJid: string): void {
if (lidToPhoneMap[lidUser] === phoneJid) return;
lidToPhoneMap[lidUser] = phoneJid;
// Cached group metadata depends on participant IDs — invalidate
groupMetadataCache.clear();
}
async function translateJid(jid: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
const cached = lidToPhoneMap[lidUser];
if (cached) return cached;
// Query Baileys' signal repository
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = await (sock.signalRepository as any)?.lidMapping?.getPNForLID(jid);
if (pn) {
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
setLidPhoneMapping(lidUser, phoneJid);
log.info('Translated LID to phone JID', { lidJid: jid, phoneJid });
return phoneJid;
}
} catch (err) {
log.debug('Failed to resolve LID via signalRepository', { jid, err });
}
return jid;
}
async function getNormalizedGroupMetadata(jid: string): Promise<GroupMetadata | undefined> {
if (!jid.endsWith('@g.us')) return undefined;
const cached = groupMetadataCache.get(jid);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
const metadata = await sock.groupMetadata(jid);
const participants = await Promise.all(
metadata.participants.map(async (p) => ({
...p,
id: await translateJid(p.id),
})),
);
const normalized = { ...metadata, participants };
groupMetadataCache.set(jid, {
metadata: normalized,
expiresAt: Date.now() + GROUP_METADATA_CACHE_TTL_MS,
});
return normalized;
}
async function syncGroupMetadata(force = false): Promise<void> {
if (!force && lastGroupSync && Date.now() - lastGroupSync < GROUP_SYNC_INTERVAL_MS) {
return;
}
try {
log.info('Syncing group metadata from WhatsApp...');
const groups = await sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
setupConfig.onMetadata(jid, metadata.subject, true);
count++;
}
}
lastGroupSync = Date.now();
log.info('Group metadata synced', { count });
} catch (err) {
log.error('Failed to sync group metadata', { err });
}
}
async function flushOutgoingQueue(): Promise<void> {
if (flushing || outgoingQueue.length === 0) return;
flushing = true;
try {
log.info('Flushing outgoing message queue', { count: outgoingQueue.length });
while (outgoingQueue.length > 0) {
const item = outgoingQueue.shift()!;
const sent = await sock.sendMessage(item.jid, { text: item.text });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
}
}
} finally {
flushing = false;
}
}
/** 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> {
if (!connected) {
outgoingQueue.push({ jid, text });
log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length });
return;
}
try {
const sent = await sock.sendMessage(jid, { text });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
const oldest = sentMessageCache.keys().next().value!;
sentMessageCache.delete(oldest);
}
}
return sent?.key?.id ?? undefined;
} catch (err) {
outgoingQueue.push({ jid, text });
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
return undefined;
}
}
// --- Socket creation ---
async function connectSocket(): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
log.warn('Failed to fetch latest WA Web version, using default', { err });
return { version: undefined };
});
sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, baileysLogger),
},
printQRInTerminal: false,
logger: baileysLogger,
browser: Browsers.macOS('Chrome'),
cachedGroupMetadata: async (jid: string) => getNormalizedGroupMetadata(jid),
getMessage: async (key: WAMessageKey) => {
// Check in-memory cache first (recently sent messages)
const cached = sentMessageCache.get(key.id || '');
if (cached) return cached;
// Return empty message to prevent indefinite "waiting for this message"
return proto.Message.fromObject({});
},
});
// Request pairing code if phone number is set and not yet registered
if (phoneNumber && !state.creds.registered) {
setTimeout(async () => {
try {
const code = await sock.requestPairingCode(phoneNumber);
log.info(`WhatsApp pairing code: ${code}`);
log.info('Enter in WhatsApp > Linked Devices > Link with phone number');
fs.writeFileSync(pairingCodeFile, code, 'utf-8');
} catch (err) {
log.error('Failed to request pairing code', { err });
}
}, 3000);
}
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr && !phoneNumber) {
// QR code auth — print to terminal
(async () => {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(qr, { type: 'terminal' });
log.info('WhatsApp QR code — scan with WhatsApp > Linked Devices:\n' + qrText);
} catch {
log.info('WhatsApp QR code (raw)', { qr });
}
})();
}
if (connection === 'close') {
connected = false;
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
log.info('WhatsApp connection closed', { reason, shouldReconnect });
if (shouldReconnect) {
log.info('Reconnecting...');
connectSocket().catch((err) => {
log.error('Failed to reconnect, retrying in 5s', { err });
setTimeout(() => {
connectSocket().catch((err2) => {
log.error('Reconnection retry failed', { err: err2 });
});
}, RECONNECT_DELAY_MS);
});
} else {
log.info('WhatsApp logged out');
if (rejectFirstOpen) {
rejectFirstOpen(new Error('WhatsApp logged out'));
rejectFirstOpen = undefined;
resolveFirstOpen = undefined;
}
}
} else if (connection === 'open') {
connected = true;
log.info('Connected to WhatsApp');
// Clean up pairing code file after successful connection
try {
if (fs.existsSync(pairingCodeFile)) fs.unlinkSync(pairingCodeFile);
} catch {
/* ignore */
}
// Announce availability for presence updates
sock.sendPresenceUpdate('available').catch((err) => {
log.warn('Failed to send presence update', { err });
});
// Build LID → phone mapping from auth state
if (sock.user) {
const phoneUser = sock.user.id.split(':')[0];
const lidUser = sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
setLidPhoneMapping(lidUser, `${phoneUser}@s.whatsapp.net`);
botLidUser = lidUser;
}
}
// Flush queued messages
flushOutgoingQueue().catch((err) => log.error('Failed to flush outgoing queue', { err }));
// Group sync
syncGroupMetadata().catch((err) => log.error('Initial group sync failed', { err }));
if (!groupSyncTimerStarted) {
groupSyncTimerStarted = true;
setInterval(() => {
syncGroupMetadata().catch((err) => log.error('Periodic group sync failed', { err }));
}, GROUP_SYNC_INTERVAL_MS);
}
// Signal first open
if (resolveFirstOpen) {
resolveFirstOpen();
resolveFirstOpen = undefined;
rejectFirstOpen = undefined;
}
}
});
sock.ev.on('creds.update', saveCreds);
// Phone number sharing events — update LID mapping
sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => {
const lidUser = lid?.split('@')[0].split(':')[0];
if (lidUser && jid) setLidPhoneMapping(lidUser, jid);
});
// Inbound messages
sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
try {
if (!msg.message) continue;
const normalized = normalizeMessageContent(msg.message);
if (!normalized) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID → phone JID
let chatJid = await translateJid(rawJid);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (chatJid.endsWith('@lid') && (msg.key as any).senderPn) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = (msg.key as any).senderPn as string;
const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`;
setLidPhoneMapping(rawJid.split('@')[0].split(':')[0], phoneJid);
chatJid = phoneJid;
}
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
const isGroup = chatJid.endsWith('@g.us');
// Notify metadata for group discovery
setupConfig.onMetadata(chatJid, undefined, isGroup);
// Only forward messages for registered conversations
if (!conversations.has(chatJid)) continue;
let content =
normalized.conversation ||
normalized.extendedTextMessage?.text ||
normalized.imageMessage?.caption ||
normalized.videoMessage?.caption ||
'';
// Normalize bot LID mention → assistant name for trigger matching
if (botLidUser && content.includes(`@${botLidUser}`)) {
content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`);
}
// Download media attachments (images, video, audio, documents)
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 senderName = msg.pushName || sender.split('@')[0];
const fromMe = msg.key.fromMe || false;
// Filter bot's own messages to prevent echo loops.
// fromMe is always true for messages sent from this linked device,
// regardless of ASSISTANT_HAS_OWN_NUMBER mode.
if (fromMe) continue;
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`);
// Check if this reply answers a pending question via slash command
const pending = pendingQuestions.get(chatJid);
if (pending && content.startsWith('/')) {
const cmd = content.trim().toLowerCase();
const matched = pending.options.find((o) => optionToCommand(o.label) === cmd);
if (matched) {
const voterName = msg.pushName || sender.split('@')[0];
setupConfig.onAction(pending.questionId, matched.value, sender);
pendingQuestions.delete(chatJid);
await sendRawMessage(chatJid, `${matched.selectedLabel} by ${voterName}`);
log.info('Question answered', {
questionId: pending.questionId,
value: matched.value,
voterName,
});
continue; // Don't forward this reply to the agent
}
}
const inbound: InboundMessage = {
id: msg.key.id || `wa-${Date.now()}`,
kind: 'chat',
content: {
text: content,
sender,
senderName,
...(attachments.length > 0 && { attachments }),
fromMe,
isBotMessage,
isGroup,
chatJid,
},
timestamp,
};
// WhatsApp doesn't use threads — threadId is null
setupConfig.onInbound(chatJid, null, inbound);
} catch (err) {
log.error('Error processing incoming WhatsApp message', {
err,
remoteJid: msg.key?.remoteJid,
});
}
}
});
}
// --- ChannelAdapter implementation ---
const adapter: ChannelAdapter = {
name: 'whatsapp',
channelType: 'whatsapp',
supportsThreads: false,
async setup(hostConfig: ChannelSetup) {
setupConfig = hostConfig;
conversations = buildConversationMap(hostConfig.conversations);
// Connect and wait for first open
await new Promise<void>((resolve, reject) => {
resolveFirstOpen = resolve;
rejectFirstOpen = reject;
connectSocket().catch(reject);
});
log.info('WhatsApp adapter initialized');
},
async deliver(
platformId: string,
_threadId: string | null,
message: OutboundMessage,
): Promise<string | undefined> {
const content = message.content as Record<string, unknown>;
// Ask question → text with slash command replies
if (content.type === 'ask_question' && content.questionId && content.options) {
const questionId = content.questionId as string;
const title = content.title as string;
const question = content.question as string;
if (!title) {
log.error('ask_question missing required title — skipping delivery', { questionId });
return;
}
const options: NormalizedOption[] = normalizeOptions(content.options as never);
const optionLines = options.map((o) => ` ${optionToCommand(o.label)}`).join('\n');
const text = `*${title}*\n\n${question}\n\nReply with:\n${optionLines}`;
const msgId = await sendRawMessage(platformId, text);
if (msgId) {
pendingQuestions.set(platformId, { questionId, options });
if (pendingQuestions.size > PENDING_QUESTIONS_MAX) {
const oldest = pendingQuestions.keys().next().value!;
pendingQuestions.delete(oldest);
}
}
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)
if (content.type === 'credential_request' && content.credentialId) {
const question = (content.question as string) || 'A credential has been requested.';
const text = `Credential request: ${question}\n\nPlease provide this credential through a secure channel (e.g. Discord or Slack).`;
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? text : `${ASSISTANT_NAME}: ${text}`;
return sendRawMessage(platformId, prefixed);
}
// Normal message (with optional file attachments)
const text = (content.markdown as string) || (content.text as string);
const hasFiles = message.files && message.files.length > 0;
if (!text && !hasFiles) return;
// 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) {
try {
await sock.sendPresenceUpdate('composing', platformId);
} catch (err) {
log.debug('Failed to update typing status', { jid: platformId, err });
}
},
async teardown() {
connected = false;
sock?.end(undefined);
log.info('WhatsApp adapter shut down');
},
isConnected() {
return connected;
},
async syncConversations(): Promise<ConversationInfo[]> {
try {
const groups = await sock.groupFetchAllParticipating();
return Object.entries(groups)
.filter(([, m]) => m.subject)
.map(([jid, m]) => ({
platformId: jid,
name: m.subject,
isGroup: true,
}));
} catch (err) {
log.error('Failed to sync WhatsApp conversations', { err });
return [];
}
},
updateConversations(configs: ConversationConfig[]) {
conversations = buildConversationMap(configs);
},
};
return adapter;
},
});