diff --git a/hermes-k8s.yaml b/hermes-k8s.yaml index 62d6c63..a15a28e 100644 --- a/hermes-k8s.yaml +++ b/hermes-k8s.yaml @@ -22,7 +22,7 @@ spec: fsGroup: 1000 containers: - name: hermes-mcp - image: localhost:32000/hermes-mcp@sha256:e7ac49a82ec89af0d362de644a435ff717d22b1f177b16b82b375727d89bc2d1 + image: localhost:32000/hermes-mcp@sha256:4f91bf92c5498605a59d54b1a32789e9d125fcff0c0b1d00225fc2bae888f26d imagePullPolicy: Always securityContext: allowPrivilegeEscalation: false @@ -126,9 +126,19 @@ spec: - name: SLACK_DEFAULT_BOT_TOKEN value: "xoxb-11134355044818-11132324783798-shAhtLsZT2GpTUI0pJlYsmrE" - name: WHATSAPP_DEFAULT_PHONE_NUMBER_ID + value: "1075667822304648" + - name: WHATSAPP_DEFAULT_WABA_ID value: "996304809563412" + - name: WHATSAPP_DEFAULT_BUSINESS_ACCOUNT_ID + value: "996304809563412" + - name: TWILIO_ACCOUNT_SID + value: "AC2a5ef8e87800b4fde36b577b76a4f1bf" + - name: TWILIO_AUTH_TOKEN + value: "bf9285c863263d06efbb1b56827d351a" + - name: TWILIO_WHATSAPP_NUMBER + value: "+19547385805" - name: WHATSAPP_DEFAULT_ACCESS_TOKEN - value: "EAAYG3FLDWzMBRZAtaCNstacjwd2ZCaVWrWEM35c2VSUufKZCluhYyCYHsQGJOBRYF82AakhhcjBKOVPLUAPEGowkV7IIbPDBb6ZBRjnnsvIrPznQzhiLJFpv7U3LdhZCpV69mxtTrLPYCxtcUJxCFdG3wkW1ijT03KJexwUjG0hiRIrVxVBHAcpPRjYCIEAZDZD" + value: "EAAYG3FLDWzMBRV4qrRvksNnVzCI4wGUvF4R8jjy6pusWBxriRwP9B3ZCRcd3VpDsjoURhJMEQJiNZCcSIJZCcQGsusZANzTpQF9hWrhHgLXUU9tJZCuoEAWTUYA9C29JgQ9BPblpUxEQRKE3p9tZBsl9ChngJy45kXJ9apOYreJclyya0ebgCxZBmndBpCPuAZDZD" - name: WA_VERIFY_TOKEN value: "hermes-wa-2026" - name: SLACK_PILOT_WEBHOOK_URL diff --git a/src/clients/whatsapp.ts b/src/clients/whatsapp.ts index 18b9f43..36f5029 100644 --- a/src/clients/whatsapp.ts +++ b/src/clients/whatsapp.ts @@ -2,9 +2,65 @@ import type { Customer } from '../billing/middleware.js'; import type { WhatsAppCredentials } from '../multitenancy/credential-store.js'; import { createToolAudit } from '../multitenancy/audit-log.js'; -const WHATSAPP_API_VERSION = 'v18.0'; +const WHATSAPP_API_VERSION = 'v21.0'; const WHATSAPP_BASE_URL = process.env['WHATSAPP_API_BASE_URL'] ?? 'https://graph.facebook.com'; +const TWILIO_ACCOUNT_SID = process.env['TWILIO_ACCOUNT_SID'] ?? ''; +const TWILIO_AUTH_TOKEN = process.env['TWILIO_AUTH_TOKEN'] ?? ''; +const TWILIO_WA_NUMBER = process.env['TWILIO_WHATSAPP_NUMBER'] ?? '+19547385805'; + +function isTwilioAccount(account: string): boolean { + return !!(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN && account === 'default'); +} + +async function twilioSend(to: string, body: string): Promise<{ success: boolean; message_id: string }> { + const params = new URLSearchParams({ + From: `whatsapp:${TWILIO_WA_NUMBER}`, + To: `whatsapp:${to}`, + Body: body, + }); + const res = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`, + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + signal: AbortSignal.timeout(10_000), + } + ); + if (!res.ok) throw new Error(`Twilio WhatsApp error (${res.status}): ${await res.text()}`); + const data = await res.json() as { sid?: string }; + return { success: true, message_id: data.sid ?? '' }; +} + +async function resolveTemplateText( + wabaId: string, + accessToken: string, + templateName: string, + components: unknown[] +): Promise { + const url = `${WHATSAPP_BASE_URL}/${WHATSAPP_API_VERSION}/${wabaId}/message_templates?name=${encodeURIComponent(templateName)}&fields=components`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new Error(`Template lookup failed: ${await res.text()}`); + const data = await res.json() as { data: Array<{ components: Array<{ type: string; text?: string }> }> }; + const bodyComp = data.data?.[0]?.components?.find(c => c.type === 'BODY'); + let text = bodyComp?.text ?? templateName; + + // Substitute positional {{1}}, {{2}} ... from body component parameters + const bodyParams = (components as Array<{ type: string; parameters?: Array<{ text?: string }> }>) + .find(c => c.type === 'body')?.parameters ?? []; + bodyParams.forEach((p, i) => { + if (p.text) text = text.replace(new RegExp(`\\{\\{${i + 1}\\}\\}`, 'g'), p.text); + }); + // Named placeholder fallback {{}} + if (bodyParams[0]?.text) text = text.replace(/\{\{\}\}/g, bodyParams[0].text); + + return text; +} + function getEnvVar(account: string, key: string): string { const envKey = `WHATSAPP_${account.toUpperCase()}_${key}`; return process.env[envKey] ?? ''; @@ -94,9 +150,14 @@ export async function sendMessage( }; try { - const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); - const response = data as WhatsAppMessageResponse; - const result = { success: true, message_id: response.messages?.[0]?.id ?? '' }; + let result: { success: boolean; message_id: string }; + if (!customer && isTwilioAccount(args.account ?? 'default')) { + result = await twilioSend(args.to, args.message); + } else { + const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); + const response = data as WhatsAppMessageResponse; + result = { success: true, message_id: response.messages?.[0]?.id ?? '' }; + } if (audit) await audit.success(auditArgs); return result; } catch (err) { @@ -112,7 +173,7 @@ export async function sendTemplate( const audit = customer ? createToolAudit(customer.id, 'whatsapp:sendTemplate') : null; const auditArgs = { to: args.to, template_name: args.template_name }; - const { phoneId, accessToken } = await resolveWhatsAppCreds(args, customer); + const { phoneId, accessToken, businessAccountId } = await resolveWhatsAppCreds(args, customer); const body: Record = { messaging_product: 'whatsapp', @@ -129,9 +190,20 @@ export async function sendTemplate( } try { - const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); - const response = data as WhatsAppMessageResponse; - const result = { success: true, message_id: response.messages?.[0]?.id ?? '' }; + let result: { success: boolean; message_id: string }; + if (!customer && isTwilioAccount(args.account ?? 'default')) { + const text = await resolveTemplateText( + businessAccountId, + accessToken, + args.template_name, + args.components ?? [] + ); + result = await twilioSend(args.to, text); + } else { + const data = await whatsappApiRequest(phoneId, accessToken, 'messages', 'POST', body); + const response = data as WhatsAppMessageResponse; + result = { success: true, message_id: response.messages?.[0]?.id ?? '' }; + } if (audit) await audit.success(auditArgs); return result; } catch (err) {