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>
750 lines
28 KiB
TypeScript
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;
|
|
},
|
|
});
|