mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(core): Handle inbound media handling for whatsapp (#39409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d6370a0002
commit
50c2ca0a27
@@ -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
|
||||
|
||||
|
||||
+52
-4
@@ -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;
|
||||
}
|
||||
|
||||
+105
-4
@@ -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,
|
||||
|
||||
+106
@@ -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;
|
||||
|
||||
+187
@@ -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()`
|
||||
|
||||
Reference in New Issue
Block a user