feat(core): Handle inbound media handling for whatsapp (#39409)

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Toni Devine
2026-10-09 15:24:15 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d6370a0002
commit 50c2ca0a27
6 changed files with 500 additions and 9 deletions
@@ -34,6 +34,7 @@ Each platform adapter uses a different HTTP client, so the interception mechanis
| Telegram | native `fetch` | replace `globalThis.fetch` | `installFetchStub` (replay-test-helpers) |
| Slack | `@slack/web-api` (axios) | `nock` at the HTTP layer | inline in slack `replay-test-context` |
| Linear | `@linear/sdk` (GraphQL over fetch) | replace `globalThis.fetch` | `installFetchStub` |
| WhatsApp | native `fetch` | replace `globalThis.fetch` | `installWhatsAppApiStub` (whatsapp `replay-test-context`) |
| Teams | `@microsoft/teams.*` (axios) + `jwks-rsa` (node `https`) | `nock` at the HTTP layer | inline in teams `replay-test-context` |
Responses are answered from two sources, in order of preference:
@@ -73,6 +74,17 @@ so response stubs only need to be valid enough for the real adapter to proceed.
(e.g. a `Comment` needs `reactions: []`; an `AgentActivity` references `agentSession`/`sourceComment`
by id). Linear's "mention" is an **agent-session** event, not a comment — see the contract note
below.
- **WhatsApp** — inbound media (`fetchData`) triggers `downloadMedia`'s two plain GETs (a metadata
lookup, then the CDN url it returns), neither of which carries a JSON body — the stub branches on
`httpMethod === 'GET'` before the send-message logic, which assumes a POST. Testing that
media-download path meaningfully needs *real* bytes: `resolveInboundMimeType` sniffs magic bytes
before trusting a declared type, so `StubResponse` supports an optional `rawResponseBody` (raw
bytes, no `JSON.stringify`) alongside the normal JSON `responseBody` — see
`WHATSAPP_MEDIA_CONTENT` in whatsapp's `replay-test-context` for genuine minimal file content per
media kind. Attachments also need an `attachmentService` passed to `createReplayContextSetup`
(opt-in; `createMockAttachmentService()` echoes back a plausible stored record) — without it,
`AgentChatBridge`'s attachment pipeline silently no-ops for every platform, not just WhatsApp.
It is not the default because other platforms' replay tests do not stub their file downloads.
#### Teams setup, outside the adapter
@@ -5,6 +5,7 @@ import { deepCopy, type Logger } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { AgentChatAttachmentService } from '../../../agent-chat-attachment.service';
import { AgentChatBridge } from '../../agent-chat-bridge';
import { ChatIntegrationRegistry, type AgentChatIntegration } from '../../agent-chat-integration';
import type { ChatIntegrationService, ChatInstance } from '../../chat-integration.service';
@@ -126,8 +127,18 @@ export interface StubbedRequest {
export interface StubResponse {
/** Recorded call exposed to assertions (platform method name + request body). */
apiCall: ReplayApiCall;
/** JSON body returned to the real adapter so it proceeds without live network I/O. */
responseBody: unknown;
/**
* JSON body returned to the real adapter so it proceeds without live
* network I/O. Ignored when `rawResponseBody` is set.
*/
responseBody?: unknown;
/**
* Non-JSON bytes returned as-is (no `JSON.stringify`) — for endpoints a
* real adapter reads as a binary file, such as WhatsApp's media download,
* where sniffing/parsing needs genuine magic bytes, not a JSON-escaped
* stand-in that would corrupt them.
*/
rawResponseBody?: Buffer | Uint8Array;
status?: number;
}
@@ -171,9 +182,17 @@ export function installFetchStub(options: {
}
}
const { apiCall, responseBody, status } = options.onRequest({ httpMethod, url, body, rawBody });
const { apiCall, responseBody, rawResponseBody, status } = options.onRequest({
httpMethod,
url,
body,
rawBody,
});
apiCalls.push(apiCall);
if (rawResponseBody) {
return new Response(new Uint8Array(rawResponseBody), { status: status ?? 200 });
}
return new Response(JSON.stringify(responseBody), {
status: status ?? 200,
headers: { 'content-type': 'application/json' },
@@ -200,6 +219,7 @@ export interface ReplayContextSetup<TChat extends ChatInstance = ChatInstance> {
descriptor: ReturnType<typeof getIntegrationToolConnectionDescriptors>[number];
integration: AgentIntegrationConfig;
messageContextStore: MemoryMessageContextStore;
attachmentService?: AgentChatAttachmentService;
latestContext: () => IntegrationMessageContext | undefined;
latestThreadId: () => string | undefined;
nextStream: (chunks: StreamChunk[]) => void;
@@ -213,6 +233,13 @@ export function createReplayContextSetup<TChat extends ChatInstance>(params: {
integration: AgentIntegrationConfig;
componentMapper?: ComponentMapper;
stream?: StreamChunk[];
/**
* Without this, `AgentChatBridge`'s attachment pipeline silently no-ops
* (see its `!this.attachmentService` guard). Pass
* `createMockAttachmentService()` when a platform's tests cover inbound
* attachments and stub the platform's file download.
*/
attachmentService?: AgentChatAttachmentService;
// Shared with the integration impl when its adapter also needs to record or
// check the connection cooldown itself (e.g. WhatsApp's automatic replies —
// see `withWhatsAppRateLimitBackoff`), so both paths agree on one connection's state.
@@ -248,6 +275,7 @@ export function createReplayContextSetup<TChat extends ChatInstance>(params: {
isResumable: vi.fn(async () => true),
};
const messageContextStore = new MemoryMessageContextStore();
const { attachmentService } = params;
const pending: Array<{ payload: QueuedIntegrationMessage; threadId: string }> = [];
const queue = mock<AgentMessageQueueService>();
queue.enqueue.mockImplementation(async ({ payload, threadId }) => {
@@ -265,7 +293,7 @@ export function createReplayContextSetup<TChat extends ChatInstance>(params: {
'project-1',
params.integration,
messageContextStore as unknown as IntegrationMessageContextService,
undefined,
attachmentService,
undefined,
queue,
);
@@ -288,6 +316,7 @@ export function createReplayContextSetup<TChat extends ChatInstance>(params: {
descriptor,
integration: params.integration,
messageContextStore,
attachmentService,
latestContext: () => selectedContext,
latestThreadId: () => selectedThreadId,
nextStream: (chunks: StreamChunk[]) => {
@@ -313,3 +342,22 @@ export function createReplayContextSetup<TChat extends ChatInstance>(params: {
},
};
}
/**
* Echoes back a plausible stored record instead of hitting real binary
* storage — good enough for asserting a platform's attachment made it
* through the bridge's pipeline with the right name/type/size, without
* needing a real `BinaryDataService`.
*/
export function createMockAttachmentService(): AgentChatAttachmentService {
const service = mock<AgentChatAttachmentService>();
service.storeInbound.mockImplementation(async (params) =>
mock<Awaited<ReturnType<AgentChatAttachmentService['storeInbound']>>>({
id: `attachment-${params.fileName}`,
fileName: params.fileName,
mimeType: params.mimeType,
fileSizeBytes: params.data.byteLength,
}),
);
return service;
}
@@ -1,5 +1,5 @@
import type { StreamChunk } from '@n8n/agents';
import type { AgentIntegrationConfig } from '@n8n/api-types';
import { MAX_AGENT_CHAT_ATTACHMENT_SIZE_BYTES, type AgentIntegrationConfig } from '@n8n/api-types';
import type { Logger as BackendLogger } from '@n8n/backend-common';
import type { WhatsAppAdapter } from '@chat-adapter/whatsapp';
import { createHmac } from 'crypto';
@@ -7,6 +7,7 @@ import type { InstanceSettings } from 'n8n-core';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { AgentChatAttachmentService } from '../../../../agent-chat-attachment.service';
import type { AgentChatIntegrationContext } from '../../../agent-chat-integration';
import { ChannelRateLimitGuard } from '../../../channel-rate-limit.guard';
import type { ChatInstance } from '../../../chat-integration.service';
@@ -19,6 +20,7 @@ import type {
import type { AgentRepository } from '../../../../repositories/agent.repository';
import { WhatsAppIntegration } from '../../../platforms/whatsapp-integration';
import {
createMockAttachmentService,
createReplayContextSetup,
installFetchStub,
type MemoryMessageContextStore,
@@ -36,7 +38,18 @@ export interface WhatsAppInboundMessageFixture {
from: string;
id: string;
timestamp: string;
type: 'text' | 'interactive' | 'button' | 'reaction';
type:
| 'text'
| 'interactive'
| 'button'
| 'reaction'
| 'image'
| 'document'
| 'audio'
| 'video'
| 'sticker'
| 'location'
| 'contacts';
text?: { body: string };
interactive?: {
type: 'button_reply' | 'list_reply';
@@ -45,6 +58,22 @@ export interface WhatsAppInboundMessageFixture {
};
button?: { payload: string; text: string };
reaction?: { emoji: string; message_id: string };
image?: { id: string; mime_type: string; sha256: string; caption?: string };
document?: {
id: string;
mime_type: string;
sha256: string;
filename?: string;
caption?: string;
};
audio?: { id: string; mime_type: string; sha256: string; voice?: boolean };
video?: { id: string; mime_type: string; sha256: string; caption?: string };
sticker?: { id: string; mime_type: string; sha256: string; animated: boolean };
location?: { latitude: number; longitude: number; name?: string; address?: string; url?: string };
contacts?: Array<{
name?: { formatted_name: string; first_name?: string; last_name?: string };
phones?: Array<{ phone: string; wa_id?: string; type?: string }>;
}>;
}
export interface WhatsAppWebhookFixture {
@@ -72,8 +101,10 @@ export interface WhatsAppReplayFixtures {
followUp: WhatsAppWebhookFixture;
}
export interface WhatsAppReplayContext extends Omit<ReplayContextSetup, 'nextStream' | 'chat'> {
export interface WhatsAppReplayContext
extends Omit<ReplayContextSetup, 'nextStream' | 'chat' | 'attachmentService'> {
chat: ChatInstance;
attachmentService: AgentChatAttachmentService;
agentExecutor: {
executeForChatPublished: Mock;
resumeForChat: Mock;
@@ -119,6 +150,49 @@ export function createWhatsAppIntegration(
);
}
/**
* Real, minimal-but-valid file content per media kind — genuine magic bytes,
* not arbitrary filler — so `resolveInboundMimeType`'s magic-byte sniffing
* (see `agent-chat-bridge.ts`) resolves them the same way it would for a real
* download, rather than falling back to `application/octet-stream`. Keyed by
* the same string each fixture's fake media `id` embeds (e.g. `media-image-1`
* contains `"image"`), so the stub below can serve the right bytes for
* whichever media a test's fixture declares.
*/
const WHATSAPP_MEDIA_CONTENT: Record<string, Buffer> = {
image: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]),
document: Buffer.from('%PDF-1.4\nsynthetic test content padding padding padding'),
audio: Buffer.from([0xff, 0xfb, 0x90, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
// A minimal real OGG page (see https://www.rfc-editor.org/rfc/rfc3533)
// wrapping a complete 19-byte Opus ID header (RFC 7845 §5.1), so it sniffs
// as audio/ogg like a real WhatsApp voice note would.
voice: Buffer.concat([
Buffer.from('OggS'),
Buffer.from([0x00]), // version
Buffer.from([0x02]), // header_type: beginning of stream
Buffer.alloc(8), // granule position
Buffer.from([0x01, 0x02, 0x03, 0x04]), // serial number
Buffer.alloc(4), // page sequence number
Buffer.alloc(4), // CRC checksum
Buffer.from([0x01]), // page_segments: one lacing value follows
Buffer.from([19]), // segment_table: one 19-byte segment
Buffer.from('OpusHead'),
Buffer.from([0x01]), // version
Buffer.from([0x01]), // channel count
Buffer.from([0x38, 0x01]), // pre-skip (312, little-endian)
Buffer.from([0x80, 0xbb, 0x00, 0x00]), // input sample rate (48000, little-endian)
Buffer.from([0x00, 0x00]), // output gain
Buffer.from([0x00]), // channel mapping family
]),
video: Buffer.from([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32]),
sticker: Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('WEBPVP8 ')]),
// One byte over the shared cap: WhatsApp attachments never carry a
// declared `size` (see `WhatsAppInboundMessage`), so the bridge's size
// check only ever fires after this downloads — there's nothing to
// pre-empt it on, unlike a platform that reports size upfront.
oversized: Buffer.alloc(MAX_AGENT_CHAT_ATTACHMENT_SIZE_BYTES + 1, 0x41),
};
/**
* A fixed number of leading sends fail with the given Meta error before the
* stub starts succeeding — for exercising `withWhatsAppRateLimitBackoff`'s
@@ -145,8 +219,32 @@ function installWhatsAppApiStub(
let failuresLeft = failureSequence?.count ?? 0;
return installFetchStub({
match: /graph\.facebook\.com/,
onRequest: ({ url, body }) => {
onRequest: ({ httpMethod, url, body }) => {
const path = url.split('?')[0];
// `downloadMedia(mediaId)` (used for every inbound attachment) makes two
// plain GETs with no JSON body — a metadata lookup, then the CDN url it
// returns — so they need to be branched on method+path before the
// send-message logic below, which assumes a POST with a JSON body.
if (httpMethod === 'GET') {
if (path.includes('/media-download/')) {
const mediaId = path.slice(path.lastIndexOf('/') + 1);
// Fixtures' media ids embed their kind (e.g. `media-image-1`), so
// real magic-byte content can be matched to whichever media the
// test's fixture actually declared.
const kind = Object.keys(WHATSAPP_MEDIA_CONTENT).find((key) => mediaId.includes(key));
return {
apiCall: { method: 'media-download', body: {} },
rawResponseBody: kind ? WHATSAPP_MEDIA_CONTENT[kind] : Buffer.from('unknown-media'),
};
}
const mediaId = path.slice(path.lastIndexOf('/') + 1);
return {
apiCall: { method: 'media-metadata', body: {} },
responseBody: { url: `https://graph.facebook.com/media-download/${mediaId}` },
};
}
const method = path.slice(path.lastIndexOf('/') + 1);
const type = typeof body.type === 'string' ? body.type : undefined;
if (type && failedTypes.includes(type)) {
@@ -236,12 +334,14 @@ export async function createWhatsAppReplayContext(
state: createMemoryState(),
});
const attachmentService = createMockAttachmentService();
const setup = createReplayContextSetup({
chat: chat as never,
integrationImpl,
integration,
componentMapper: new ComponentMapper(),
stream: options.stream,
attachmentService,
channelRateLimitGuard,
});
@@ -270,6 +370,7 @@ export async function createWhatsAppReplayContext(
return {
...setup,
attachmentService,
chat: chat as unknown as ChatInstance,
adapter,
apiCalls: stub.apiCalls,
@@ -33,6 +33,112 @@ export const whatsAppInboundTextMessage = (
...overrides,
});
/** Shared base fields every media fixture below overrides on top of. */
const mediaMessageBase = (
overrides: Partial<WhatsAppInboundMessageFixture>,
): Pick<WhatsAppInboundMessageFixture, 'from' | 'id' | 'timestamp'> => ({
from: DEFAULT_USER_WA_ID,
id: 'wamid.TEST_INBOUND_MEDIA',
timestamp: String(Math.floor(Date.now() / 1000)),
...overrides,
});
export const whatsAppInboundImageMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'image',
image: { id: 'media-image-1', mime_type: 'image/jpeg', sha256: 'test-sha256-image' },
...overrides,
});
export const whatsAppInboundDocumentMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'document',
document: {
id: 'media-document-1',
mime_type: 'application/pdf',
sha256: 'test-sha256-document',
filename: 'invoice.pdf',
},
...overrides,
});
export const whatsAppInboundAudioMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'audio',
audio: { id: 'media-audio-1', mime_type: 'audio/mpeg', sha256: 'test-sha256-audio' },
...overrides,
});
/**
* A recorded voice note. Meta sends these as `type: "audio"` with
* `audio.voice: true` and an Opus/OGG payload, not as `type: "voice"`.
*/
export const whatsAppInboundVoiceMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'audio',
audio: {
id: 'media-voice-1',
mime_type: 'audio/ogg; codecs=opus',
sha256: 'test-sha256-voice',
voice: true,
},
...overrides,
});
export const whatsAppInboundVideoMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'video',
video: { id: 'media-video-1', mime_type: 'video/mp4', sha256: 'test-sha256-video' },
...overrides,
});
export const whatsAppInboundStickerMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'sticker',
sticker: {
id: 'media-sticker-1',
mime_type: 'image/webp',
sha256: 'test-sha256-sticker',
animated: false,
},
...overrides,
});
export const whatsAppInboundLocationMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'location',
location: { latitude: 51.5072, longitude: -0.1276, name: 'London' },
...overrides,
});
export const whatsAppInboundContactsMessage = (
overrides: Partial<WhatsAppInboundMessageFixture> = {},
): WhatsAppInboundMessageFixture => ({
...mediaMessageBase(overrides),
type: 'contacts',
contacts: [
{
name: { formatted_name: 'Jane Doe', first_name: 'Jane', last_name: 'Doe' },
phones: [{ phone: '+44 7700 900123', wa_id: '447700900123', type: 'CELL' }],
},
],
...overrides,
});
export const whatsAppWebhook = (options: {
phoneNumberId?: string;
contact?: WhatsAppContactFixture;
@@ -8,10 +8,19 @@ import {
createWhatsAppIntegration,
createWhatsAppReplayContext,
whatsAppThreadId,
type WhatsAppInboundMessageFixture,
} from '../../../__tests__/helpers/whatsapp/replay-test-context';
import {
whatsAppContact,
whatsAppInboundAudioMessage,
whatsAppInboundContactsMessage,
whatsAppInboundDocumentMessage,
whatsAppInboundImageMessage,
whatsAppInboundLocationMessage,
whatsAppInboundStickerMessage,
whatsAppInboundTextMessage,
whatsAppInboundVideoMessage,
whatsAppInboundVoiceMessage,
whatsAppReplayFixtures,
whatsAppWebhook,
} from '../../../__tests__/helpers/whatsapp/synthetic-fixtures';
@@ -84,6 +93,184 @@ describe('WhatsApp Cloud API integration scenarios', () => {
}
});
describe('inbound media', () => {
it.each([
['image', whatsAppInboundImageMessage, 'image/jpeg'],
['document', whatsAppInboundDocumentMessage, 'application/pdf'],
['audio', whatsAppInboundAudioMessage, 'audio/mpeg'],
// Real voice-note shape: `type: "audio"` with `audio.voice: true`. The
// MIME type is sniffed from the Opus/OGG bytes in WHATSAPP_MEDIA_CONTENT.
['voice', whatsAppInboundVoiceMessage, 'audio/ogg; codecs=opus'],
['video', whatsAppInboundVideoMessage, 'video/mp4'],
['sticker', whatsAppInboundStickerMessage, 'image/webp'],
] as const)(
'stores a %s attachment and passes it to the agent executor',
async (_label, buildMessage, expectedMimeType) => {
const fixtures = whatsAppReplayFixtures();
const ctx = await createWhatsAppReplayContext(fixtures);
try {
await ctx.sendWebhook(
whatsAppWebhook({
phoneNumberId: fixtures.phoneNumberId,
contact: fixtures.contact,
message: buildMessage({ from: fixtures.contact.wa_id }),
}),
);
expect(ctx.attachmentService.storeInbound).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
source: 'whatsapp',
mimeType: expectedMimeType,
data: expect.any(Buffer),
}),
);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
attachments: [expect.objectContaining({ mimeType: expectedMimeType })],
}),
);
} finally {
await ctx.shutdown();
}
},
);
it('degrades to a text note instead of storing an attachment over the size cap', async () => {
// WhatsApp's inbound media messages never carry a declared size (see
// `WhatsAppInboundMessage`), unlike some other platforms — so this can
// only ever be caught after downloading, never pre-empted upfront.
const fixtures = whatsAppReplayFixtures();
const ctx = await createWhatsAppReplayContext(fixtures);
try {
await ctx.sendWebhook(
whatsAppWebhook({
phoneNumberId: fixtures.phoneNumberId,
contact: fixtures.contact,
message: whatsAppInboundDocumentMessage({
from: fixtures.contact.wa_id,
document: {
id: 'media-oversized-1',
mime_type: 'application/pdf',
sha256: 'test-sha256-oversized',
filename: 'big.pdf',
},
}),
}),
);
expect(ctx.attachmentService.storeInbound).not.toHaveBeenCalled();
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ message: expect.stringContaining('MB') }),
);
} finally {
await ctx.shutdown();
}
});
it('passes a location to the agent as a text summary only', async () => {
// The adapter also adds the location as a URL-only pseudo-file (a Google
// Maps link with no bytes). The bridge cannot download it, so the
// integration drops it. Otherwise the agent gets a misleading
// "could not be downloaded" note next to the summary.
const fixtures = whatsAppReplayFixtures();
const ctx = await createWhatsAppReplayContext(fixtures);
try {
await ctx.sendWebhook(
whatsAppWebhook({
phoneNumberId: fixtures.phoneNumberId,
contact: fixtures.contact,
message: whatsAppInboundLocationMessage({ from: fixtures.contact.wa_id }),
}),
);
expect(ctx.attachmentService.storeInbound).not.toHaveBeenCalled();
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ message: '[Location: London]' }),
);
} finally {
await ctx.shutdown();
}
});
it('passes a shared contact to the agent as a text summary', async () => {
// The adapter has no handling for `contacts` and drops the message, so
// the integration's adapter subclass summarizes it as text instead.
const fixtures = whatsAppReplayFixtures();
const ctx = await createWhatsAppReplayContext(fixtures);
try {
await ctx.sendWebhook(
whatsAppWebhook({
phoneNumberId: fixtures.phoneNumberId,
contact: fixtures.contact,
message: whatsAppInboundContactsMessage({ from: fixtures.contact.wa_id }),
}),
);
expect(ctx.attachmentService.storeInbound).not.toHaveBeenCalled();
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ message: '[Contact: Jane Doe - +44 7700 900123]' }),
);
} finally {
await ctx.shutdown();
}
});
it.each([
[
'a contact with no name',
[{ phones: [{ phone: '+44 7700 900123' }] }],
'[Contact: +44 7700 900123]',
],
[
'a contact with no phone numbers',
[{ name: { formatted_name: 'Jane Doe' } }],
'[Contact: Jane Doe]',
],
[
'a contact with several phone numbers',
[
{
name: { formatted_name: 'Jane Doe' },
phones: [{ phone: '+44 7700 900123' }, { phone: '+44 20 7946 0000' }],
},
],
'[Contact: Jane Doe - +44 7700 900123 - +44 20 7946 0000]',
],
[
'several contacts',
[
{ name: { formatted_name: 'Jane Doe' }, phones: [{ phone: '+44 7700 900123' }] },
{ name: { formatted_name: 'John Smith' } },
],
'[Contact: Jane Doe - +44 7700 900123]\n[Contact: John Smith]',
],
['a contact with no name or phone numbers', [{}], '[Contact]'],
['an empty contacts list', [], '[Contact]'],
['no contacts field', undefined, '[Contact]'],
] as const)('summarizes %s', async (_label, contacts, expectedMessage) => {
const fixtures = whatsAppReplayFixtures();
const ctx = await createWhatsAppReplayContext(fixtures);
try {
await ctx.sendWebhook(
whatsAppWebhook({
phoneNumberId: fixtures.phoneNumberId,
contact: fixtures.contact,
message: whatsAppInboundContactsMessage({
from: fixtures.contact.wa_id,
contacts: contacts as WhatsAppInboundMessageFixture['contacts'],
}),
}),
);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ message: expectedMessage }),
);
} finally {
await ctx.shutdown();
}
});
});
it('persists current message context for the integration context tool', async () => {
const fixtures = whatsAppReplayFixtures();
const ctx = await createWhatsAppReplayContext(fixtures);
@@ -27,7 +27,7 @@ import {
} from '../component-mapper';
import { assertCredentialNotClaimed } from '../credential-claim';
import { loadChatSdk, loadWhatsAppAdapter } from '../esm-loader';
import { deriveWhatsAppVerifyToken, stringValue } from '../integration-helpers';
import { deriveWhatsAppVerifyToken, stringProperty, stringValue } from '../integration-helpers';
import { resolveIntegrationActionDefinitions } from '../integration-tool-definitions';
import { buildIntegrationConnectionId } from '../integration-tool-factory';
@@ -262,6 +262,23 @@ export class WhatsAppIntegration extends AgentChatIntegration {
{ guard: channelRateLimitGuard, connectionId, recipient },
);
}
// The adapter returns null for `contacts`, which drops the message.
// Summarize shared contact cards as text so the agent still gets them.
protected override extractTextContent(message: WhatsAppRawMessage['message']) {
if (message.type === 'contacts') return formatSharedContacts(message);
return super.extractTextContent(message);
}
// The adapter adds a location as a URL-only pseudo-file (a Maps link with
// no bytes). The bridge cannot download it, and the text summary already
// carries the location, so drop it.
protected override buildAttachments(inbound: WhatsAppRawMessage['message']) {
const attachments = super.buildAttachments(inbound);
return inbound.type === 'location'
? attachments.filter((attachment) => attachment.fetchData !== undefined)
: attachments;
}
}
return new ConversationWindowGuardedAdapter(config);
@@ -395,6 +412,26 @@ export class WhatsAppIntegration extends AgentChatIntegration {
}
}
/**
* Summarize a `contacts` message in the same bracketed style the adapter uses
* for other non-text types (e.g. `[Location: ...]`). The adapter's inbound
* type does not declare the `contacts` field, so read it defensively.
* @see https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples#contacts-messages
*/
function formatSharedContacts(message: unknown): string {
const contacts = isRecord(message) && Array.isArray(message.contacts) ? message.contacts : [];
const lines = contacts.map((contact: unknown) => {
const name = isRecord(contact) ? stringProperty(contact.name, 'formatted_name') : undefined;
const phones = isRecord(contact) && Array.isArray(contact.phones) ? contact.phones : [];
const phoneNumbers = phones
.map((phone: unknown) => stringProperty(phone, 'phone'))
.filter((phone): phone is string => phone !== undefined);
const parts = name ? [name, ...phoneNumbers] : phoneNumbers;
return parts.length > 0 ? `[Contact: ${parts.join(' - ')}]` : '[Contact]';
});
return lines.length > 0 ? lines.join('\n') : '[Contact]';
}
/**
* Reject a send once more than 24 hours have passed since the customer's
* last inbound message. `chat` is `null` before the adapter's `initialize()`