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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user