feat(whatsapp): Twilio send path for default account + API v21

The SquareMCP number (+19547385805) is registered under Twilio's BSP,
so direct Meta API sends were blocked with #200 permission errors.
Default account now routes through Twilio's REST API; customer-owned
accounts still use Meta's Cloud API directly.

- twilioSend(): POST to api.twilio.com with Basic auth
- resolveTemplateText(): fetches template body from Meta, substitutes
  {{1}}/{{}} parameters, so templates render correctly via Twilio
- Bumped WHATSAPP_API_VERSION to v21.0
- Added TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_NUMBER,
  WHATSAPP_DEFAULT_BUSINESS_ACCOUNT_ID to K8s deployment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-16 21:34:42 -04:00
parent dcc1c39754
commit b67146dfc8
2 changed files with 92 additions and 10 deletions

View File

@@ -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

View File

@@ -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<string> {
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<string, unknown> = {
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) {