import type { Message as WAMessage } from 'whatsapp-web.js'; import { findUserByIntegrationConfig, readConfigValue } from 'officerdb'; import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await'; import { consumePairingCode } from '../pairing'; import { getWhatsAppClient } from './bot'; import { listPiModels } from '@@/api/pi/list-models'; import { enqueueJob } from '../../queue/init'; import { readJob } from '@@/queue/storage'; import { openEmailDb } from '@@/api/email/email-db'; import type { ModelInfo } from '@@/api/pi/types'; import { toShellUsername } from '@@/data-path'; const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/; const TYPING_INTERVAL_MS = 5_000; type SendFn = (text: string) => Promise; type AccessPolicy = { allowedModels: string[] }; const ACCESS_POLICY_KEY = 'pi-access-policy'; function extractPhone(waId: string): string { // WhatsApp ID format: 5511999999999@c.us → 5511999999999 return waId.split('@')[0]!; } async function getVisibleModels(): Promise { const allModels = await listPiModels(); const policy = await readConfigValue(ACCESS_POLICY_KEY, { allowedModels: [] }); const allowed = policy.allowedModels; if (allowed.length === 0) return allModels; const allowedSet = new Set(allowed); const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0])); return allModels.filter((m) => { const key = `${m.provider}:${m.id}`; const isExplicitlyAllowed = allowedSet.has(key); const isFromNewProvider = !allowedProviderSet.has(m.provider); return isExplicitlyAllowed || isFromNewProvider; }); } type CommandContext = { content: string; send: SendFn; userId: number; email: string; whatsappId: string; }; async function handleEmailSync(ctx: CommandContext): Promise { const { send, email } = ctx; let countBefore = 0; try { const db = openEmailDb(email); const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }; countBefore = row.count; db.close(); } catch { // DB might not exist yet } await send('Syncing emails...'); const job = await enqueueJob({ lane: 'google-api', type: 'gmail-sync', userId: email }); const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => { for (let i = 0; i < 120; i++) { await new Promise((r) => setTimeout(r, 3000)); const current = await readJob(job.id); if (!current) return 'failed'; if (current.status === 'completed' || current.status === 'failed' || current.status === 'cancelled') { return current.status; } } return 'failed'; }; const status = await poll(); if (status !== 'completed') { await send('Email sync failed. Check the queue dashboard for details.'); return; } try { const db = openEmailDb(email); const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }; const countAfter = row.count; const newCount = countAfter - countBefore; if (newCount <= 0) { db.close(); await send('Sync complete — no new emails.'); return; } const newest = db .query('SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?') .all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>; db.close(); const lines = newest.map((e) => { const sender = e.from_name || e.from_address; return `- *${sender}*: ${e.subject}`; }); let text = `Sync complete — *${newCount}* new email${newCount !== 1 ? 's' : ''}`; if (newCount > 20) text += ` (showing latest 20)`; text += `:\n\n${lines.join('\n')}`; await send(text); } catch (err) { await send(`Sync complete but failed to read results: ${err instanceof Error ? err.message : String(err)}`); } } async function handleCommand(ctx: CommandContext): Promise { const { content, send, whatsappId } = ctx; const lower = content.toLowerCase(); const helpSections: Record = { models: '*Models:*\n' + '`!model` — show current model\n' + '`!model ` — switch model\n' + '`!models` — list available models', email: '*Email:*\n' + '`!email sync` — sync Gmail and show new emails', }; if (lower === '!help' || lower.startsWith('!help ')) { const topic = content.slice('!help'.length).trim().toLowerCase(); if (topic && topic in helpSections) { await send(helpSections[topic]!); return true; } if (topic) { await send( `Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections) .map((k) => `\`${k}\``) .join(', ')}`, ); return true; } const full = Object.values(helpSections).join('\n\n'); await send(full + '\n\n`!help ` — show commands for a topic'); return true; } if (lower === '!models') { const models = await getVisibleModels(); if (models.length === 0) { await send('No models available.'); return true; } const current = getSessionModel('whatsapp', ctx.userId, whatsappId); const grouped = new Map(); for (const m of models) { const list = grouped.get(m.provider) ?? []; list.push(m.id === current ? `*${m.id}* (current)` : m.id); grouped.set(m.provider, list); } let text = '*Available models:*\n'; for (const [provider, ids] of grouped) { text += `\n_${provider}_\n${ids.map((id) => ` ${id}`).join('\n')}\n`; } text += '\nUse `!model ` to switch.'; await send(text); return true; } if (lower === '!model') { const current = getSessionModel('whatsapp', ctx.userId, whatsappId); await send( current ? `Current model: *${current}*` : 'No active session yet — the default model will be used on your next message.', ); return true; } if (lower.startsWith('!model ')) { const requested = content.slice('!model '.length).trim(); if (!requested) { const current = getSessionModel('whatsapp', ctx.userId, whatsappId); await send(current ? `Current model: *${current}*` : 'No active session yet.'); return true; } const models = await getVisibleModels(); const match = models.find((m) => m.id === requested || m.name === requested); if (!match) { await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`); return true; } setSessionModel('whatsapp', ctx.userId, whatsappId, match.id); await send(`Model switched to *${match.id}*. The new model will be used on your next message.`); return true; } if (lower === '!email sync') { await handleEmailSync(ctx); return true; } return false; } export async function handleWhatsAppMessage(msg: WAMessage): Promise { const waClient = getWhatsAppClient(); if (!waClient) return; // Ignore group chats, status broadcasts, own messages if (msg.from.endsWith('@g.us')) return; if (msg.from === 'status@broadcast') return; if (msg.fromMe) return; if (!msg.body) return; const phone = extractPhone(msg.from); const content = msg.body.trim(); if (!content) return; const send: SendFn = (text: string) => waClient.sendMessage(msg.from, text); // Look up linked Officer user const linked = await findUserByIntegrationConfig('whatsapp', 'whatsappId', phone); if (!linked) { if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) { const result = await consumePairingCode(content.toUpperCase(), phone); if (result) { await send('Account linked! You can now chat with me.'); return; } await send('Invalid or expired pairing code. Please generate a new one from Officer Settings.'); return; } await send( "I don't recognize your WhatsApp number. To link it:\n" + '1. Go to Officer Settings → Integrations → WhatsApp\n' + '2. Click "Link WhatsApp" to get a pairing code\n' + '3. Send the 6-character code to me here', ); return; } // Handle commands if (content.startsWith('!')) { const handled = await handleCommand({ content, send, userId: linked.user.id, email: linked.user.email, whatsappId: phone, }); if (handled) return; } // Typing indicator const sendTyping = async () => { try { const chat = await msg.getChat(); await chat.sendStateTyping(); } catch { // ignore } }; const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS); sendTyping(); try { const result = await sendAndAwait({ userId: linked.user.id, email: linked.user.email, username: toShellUsername(linked.user.username ?? '', linked.user.email), prompt: content, context: 'whatsapp', contextId: phone, }); clearInterval(typingInterval); // WhatsApp has 65k char limit — no chunking needed const signature = `\`${result.model}\`\n`; await send(signature + result.text); } catch (err) { clearInterval(typingInterval); console.error('[whatsapp] Error handling message:', err); await send('Sorry, something went wrong processing your message.').catch(() => {}); } }