@copilotkit/channels-whatsapp 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/dist/adapter.d.ts +57 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +242 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +137 -0
- package/dist/built-in-context.d.ts +7 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +19 -0
- package/dist/built-in-context.test.d.ts +2 -0
- package/dist/built-in-context.test.d.ts.map +1 -0
- package/dist/built-in-context.test.js +17 -0
- package/dist/built-in-tools.d.ts +8 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +6 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +108 -0
- package/dist/client.test.d.ts +2 -0
- package/dist/client.test.d.ts.map +1 -0
- package/dist/client.test.js +108 -0
- package/dist/conversation-store.d.ts +23 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +41 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +40 -0
- package/dist/download-files.d.ts +44 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +66 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +34 -0
- package/dist/event-renderer.d.ts +18 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +110 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +68 -0
- package/dist/history-store.d.ts +36 -0
- package/dist/history-store.d.ts.map +1 -0
- package/dist/history-store.js +17 -0
- package/dist/history-store.test.d.ts +2 -0
- package/dist/history-store.test.d.ts.map +1 -0
- package/dist/history-store.test.js +24 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/interaction.d.ts +19 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +56 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +67 -0
- package/dist/markdown-to-wa.d.ts +15 -0
- package/dist/markdown-to-wa.d.ts.map +1 -0
- package/dist/markdown-to-wa.js +56 -0
- package/dist/markdown-to-wa.test.d.ts +2 -0
- package/dist/markdown-to-wa.test.d.ts.map +1 -0
- package/dist/markdown-to-wa.test.js +29 -0
- package/dist/render/budget.d.ts +34 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +40 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +24 -0
- package/dist/render/message.d.ts +74 -0
- package/dist/render/message.d.ts.map +1 -0
- package/dist/render/message.js +247 -0
- package/dist/render/message.test.d.ts +2 -0
- package/dist/render/message.test.d.ts.map +1 -0
- package/dist/render/message.test.js +184 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/webhook-listener.d.ts +16 -0
- package/dist/webhook-listener.d.ts.map +1 -0
- package/dist/webhook-listener.js +128 -0
- package/dist/webhook-listener.test.d.ts +2 -0
- package/dist/webhook-listener.test.d.ts.map +1 -0
- package/dist/webhook-listener.test.js +204 -0
- package/dist/webhook-server.d.ts +23 -0
- package/dist/webhook-server.d.ts.map +1 -0
- package/dist/webhook-server.js +99 -0
- package/dist/webhook-server.test.d.ts +2 -0
- package/dist/webhook-server.test.d.ts.map +1 -0
- package/dist/webhook-server.test.js +150 -0
- package/package.json +55 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderWhatsAppMessage } from "./message.js";
|
|
3
|
+
const node = (type, props = {}) => ({
|
|
4
|
+
type,
|
|
5
|
+
props,
|
|
6
|
+
});
|
|
7
|
+
describe("renderWhatsAppMessage", () => {
|
|
8
|
+
it("renders plain text/section as a text payload", () => {
|
|
9
|
+
const out = renderWhatsAppMessage([
|
|
10
|
+
node("section", { children: "Hello **world**" }),
|
|
11
|
+
]);
|
|
12
|
+
expect(out).toEqual([
|
|
13
|
+
{ type: "text", text: { body: "Hello *world*", preview_url: false } },
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
it("renders <=3 buttons as an interactive button message (value encoded in id)", () => {
|
|
17
|
+
const out = renderWhatsAppMessage([
|
|
18
|
+
node("section", { children: "Pick one" }),
|
|
19
|
+
node("button", { children: "Yes", value: "y", onClick: { id: "ck:1" } }),
|
|
20
|
+
node("button", { children: "No", value: "n", onClick: { id: "ck:2" } }),
|
|
21
|
+
]);
|
|
22
|
+
expect(out).toHaveLength(1);
|
|
23
|
+
const m = out[0];
|
|
24
|
+
expect(m.type).toBe("interactive");
|
|
25
|
+
expect(m.interactive.type).toBe("button");
|
|
26
|
+
expect(m.interactive.body.text).toBe("Pick one");
|
|
27
|
+
expect(m.interactive.action.buttons).toEqual([
|
|
28
|
+
{ type: "reply", reply: { id: 'ck:1::"y"', title: "Yes" } },
|
|
29
|
+
{ type: "reply", reply: { id: 'ck:2::"n"', title: "No" } },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
it("omits the value suffix when a button has no value", () => {
|
|
33
|
+
const out = renderWhatsAppMessage([
|
|
34
|
+
node("section", { children: "x" }),
|
|
35
|
+
node("button", { children: "Go", onClick: { id: "ck:9" } }),
|
|
36
|
+
]);
|
|
37
|
+
const m = out[0];
|
|
38
|
+
expect(m.interactive.action.buttons[0].reply.id).toBe("ck:9");
|
|
39
|
+
});
|
|
40
|
+
it("renders >3 buttons as an interactive list message", () => {
|
|
41
|
+
const buttons = ["a", "b", "c", "d"].map((t, i) => node("button", { children: t, onClick: { id: `ck:${i}` } }));
|
|
42
|
+
const out = renderWhatsAppMessage([
|
|
43
|
+
node("section", { children: "Choose" }),
|
|
44
|
+
...buttons,
|
|
45
|
+
]);
|
|
46
|
+
const m = out[0];
|
|
47
|
+
expect(m.interactive.type).toBe("list");
|
|
48
|
+
expect(m.interactive.action.sections[0].rows).toHaveLength(4);
|
|
49
|
+
expect(m.interactive.action.sections[0].rows[0]).toEqual({
|
|
50
|
+
id: "ck:0",
|
|
51
|
+
title: "a",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it("renders a select as a list message (option value encoded per row)", () => {
|
|
55
|
+
const out = renderWhatsAppMessage([
|
|
56
|
+
node("select", {
|
|
57
|
+
placeholder: "Region",
|
|
58
|
+
options: [
|
|
59
|
+
{ label: "US", value: "us" },
|
|
60
|
+
{ label: "EU", value: "eu" },
|
|
61
|
+
],
|
|
62
|
+
onSelect: { id: "ck:sel" },
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
const m = out[0];
|
|
66
|
+
expect(m.interactive.type).toBe("list");
|
|
67
|
+
expect(m.interactive.action.sections[0].rows).toEqual([
|
|
68
|
+
{ id: 'ck:sel::"us"', title: "US" },
|
|
69
|
+
{ id: 'ck:sel::"eu"', title: "EU" },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
it("clamps button titles to 20 chars", () => {
|
|
73
|
+
const out = renderWhatsAppMessage([
|
|
74
|
+
node("section", { children: "x" }),
|
|
75
|
+
node("button", {
|
|
76
|
+
children: "a".repeat(40),
|
|
77
|
+
value: "v",
|
|
78
|
+
onClick: { id: "ck:1" },
|
|
79
|
+
}),
|
|
80
|
+
]);
|
|
81
|
+
const m = out[0];
|
|
82
|
+
expect(m.interactive.action.buttons[0].reply.title.length).toBe(20);
|
|
83
|
+
});
|
|
84
|
+
it("renders an image as an image payload", () => {
|
|
85
|
+
const out = renderWhatsAppMessage([
|
|
86
|
+
node("image", { url: "https://x/i.png", alt: "pic" }),
|
|
87
|
+
]);
|
|
88
|
+
expect(out).toContainEqual({
|
|
89
|
+
type: "image",
|
|
90
|
+
image: { link: "https://x/i.png", caption: "pic" },
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
it("falls back to a numbered text menu beyond 10 options", () => {
|
|
94
|
+
const buttons = Array.from({ length: 12 }, (_, i) => node("button", { children: `opt${i}`, onClick: { id: `ck:${i}` } }));
|
|
95
|
+
const out = renderWhatsAppMessage([
|
|
96
|
+
node("section", { children: "Many" }),
|
|
97
|
+
...buttons,
|
|
98
|
+
]);
|
|
99
|
+
const m = out[0];
|
|
100
|
+
expect(m.type).toBe("text");
|
|
101
|
+
expect(m.text.body).toContain("1. opt0");
|
|
102
|
+
expect(m.text.body).toContain("12. opt11");
|
|
103
|
+
});
|
|
104
|
+
it("throws when an encoded button value exceeds the WhatsApp id limit", () => {
|
|
105
|
+
const huge = "x".repeat(300);
|
|
106
|
+
expect(() => renderWhatsAppMessage([
|
|
107
|
+
node("section", { children: "x" }),
|
|
108
|
+
node("button", {
|
|
109
|
+
children: "Go",
|
|
110
|
+
value: huge,
|
|
111
|
+
onClick: { id: "ck:1" },
|
|
112
|
+
}),
|
|
113
|
+
])).toThrow(/too large to round-trip/);
|
|
114
|
+
});
|
|
115
|
+
it("renders value-only buttons (HITL confirm/cancel) encoding the value in the id", () => {
|
|
116
|
+
const out = renderWhatsAppMessage([
|
|
117
|
+
node("section", { children: "Create issue?" }),
|
|
118
|
+
node("button", { children: "Confirm", value: { confirmed: true } }),
|
|
119
|
+
node("button", { children: "Cancel", value: { confirmed: false } }),
|
|
120
|
+
]);
|
|
121
|
+
const m = out[0];
|
|
122
|
+
expect(m.type).toBe("interactive");
|
|
123
|
+
expect(m.interactive.type).toBe("button");
|
|
124
|
+
expect(m.interactive.action.buttons.map((b) => b.reply)).toEqual([
|
|
125
|
+
{ id: 'wa:choice::{"confirmed":true}', title: "Confirm" },
|
|
126
|
+
{ id: 'wa:choice::{"confirmed":false}', title: "Cancel" },
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
// --- renderToIR-shaped IR (the real shape thread.post produces) ---
|
|
130
|
+
// renderToIR lowers text into `{ type: "text", props: { value } }` leaves and
|
|
131
|
+
// nests controls inside containers (message > actions > button). These guard
|
|
132
|
+
// against the shape the flat hand-built fixtures above don't exercise.
|
|
133
|
+
const text = (value) => ({ type: "text", props: { value } });
|
|
134
|
+
it("renders a message>header>section tree with text-value leaves (issue_list regression)", () => {
|
|
135
|
+
const ir = [
|
|
136
|
+
node("message", {
|
|
137
|
+
children: [
|
|
138
|
+
node("header", { children: [text("Open CPK issues")] }),
|
|
139
|
+
node("section", { children: [text("*CPK-1* — A\n*CPK-2* — B")] }),
|
|
140
|
+
],
|
|
141
|
+
}),
|
|
142
|
+
];
|
|
143
|
+
const out = renderWhatsAppMessage(ir);
|
|
144
|
+
expect(out).toHaveLength(1);
|
|
145
|
+
const m = out[0];
|
|
146
|
+
expect(m.type).toBe("text");
|
|
147
|
+
expect(m.text.body).toContain("Open CPK issues");
|
|
148
|
+
expect(m.text.body).toContain("CPK-1");
|
|
149
|
+
expect(m.text.body).toContain("CPK-2");
|
|
150
|
+
});
|
|
151
|
+
it("finds buttons nested in message>actions with text-value titles (show_incident regression)", () => {
|
|
152
|
+
const ir = [
|
|
153
|
+
node("message", {
|
|
154
|
+
children: [
|
|
155
|
+
node("section", { children: [text("An incident needs attention")] }),
|
|
156
|
+
node("actions", {
|
|
157
|
+
children: [
|
|
158
|
+
node("button", {
|
|
159
|
+
children: [text("Acknowledge")],
|
|
160
|
+
value: "ack",
|
|
161
|
+
onClick: { id: "ck:a" },
|
|
162
|
+
}),
|
|
163
|
+
node("button", {
|
|
164
|
+
children: [text("Escalate")],
|
|
165
|
+
value: "esc",
|
|
166
|
+
onClick: { id: "ck:e" },
|
|
167
|
+
}),
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
];
|
|
173
|
+
const out = renderWhatsAppMessage(ir);
|
|
174
|
+
const m = out[0];
|
|
175
|
+
expect(m.type).toBe("interactive");
|
|
176
|
+
expect(m.interactive.type).toBe("button");
|
|
177
|
+
expect(m.interactive.body.text).toContain("An incident");
|
|
178
|
+
expect(m.interactive.action.buttons.map((b) => b.reply.title)).toEqual(["Acknowledge", "Escalate"]);
|
|
179
|
+
expect(m.interactive.action.buttons.map((b) => b.reply.id)).toEqual([
|
|
180
|
+
'ck:a::"ack"',
|
|
181
|
+
'ck:e::"esc"',
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { HistoryStore } from "./history-store.js";
|
|
2
|
+
import type { FileDeliveryConfig } from "./download-files.js";
|
|
3
|
+
/** Where a reply goes: a WhatsApp user (wa_id) reachable via a business number. */
|
|
4
|
+
export interface ReplyTarget {
|
|
5
|
+
/** Recipient wa_id (the user's phone number in E.164 without '+'). */
|
|
6
|
+
to: string;
|
|
7
|
+
/** The business phone-number id that sends the message. */
|
|
8
|
+
phoneNumberId: string;
|
|
9
|
+
}
|
|
10
|
+
/** A WhatsApp message ref (the Cloud API message id, `wamid.*`). */
|
|
11
|
+
export interface WhatsAppMessageRef {
|
|
12
|
+
id: string;
|
|
13
|
+
to: string;
|
|
14
|
+
phoneNumberId: string;
|
|
15
|
+
[k: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
export interface WhatsAppAdapterOptions {
|
|
18
|
+
/** Cloud API access token (Bearer). */
|
|
19
|
+
accessToken: string;
|
|
20
|
+
/** Business phone-number id that sends messages. */
|
|
21
|
+
phoneNumberId: string;
|
|
22
|
+
/** App secret used to validate X-Hub-Signature-256 on inbound POSTs. */
|
|
23
|
+
appSecret: string;
|
|
24
|
+
/** Token echoed during the GET verification handshake (hub.verify_token). */
|
|
25
|
+
verifyToken: string;
|
|
26
|
+
/** HTTP server port (default 3000). */
|
|
27
|
+
port?: number;
|
|
28
|
+
/** Webhook path (default "/webhook"). */
|
|
29
|
+
path?: string;
|
|
30
|
+
/** Graph API version (default "v21.0"). */
|
|
31
|
+
apiVersion?: string;
|
|
32
|
+
/** Graph API base origin (default "https://graph.facebook.com"). Overridable for tests. */
|
|
33
|
+
graphBaseUrl?: string;
|
|
34
|
+
/** Custom-event names treated as interrupts by the run renderer. */
|
|
35
|
+
interruptEventNames?: ReadonlySet<string>;
|
|
36
|
+
/** Prefix for leading-keyword command matching (default "/"). */
|
|
37
|
+
commandPrefix?: string;
|
|
38
|
+
/** Pluggable conversation-history persistence (default InMemoryHistoryStore). */
|
|
39
|
+
historyStore?: HistoryStore;
|
|
40
|
+
/** Inbound media handling config. */
|
|
41
|
+
files?: FileDeliveryConfig;
|
|
42
|
+
}
|
|
43
|
+
/** A single inbound message object from the Cloud API webhook. */
|
|
44
|
+
export interface InboundMessage {
|
|
45
|
+
from: string;
|
|
46
|
+
id: string;
|
|
47
|
+
timestamp?: string;
|
|
48
|
+
type: string;
|
|
49
|
+
/** Present when this message quote-replies another; `id` is the quoted message's wamid. */
|
|
50
|
+
context?: {
|
|
51
|
+
id?: string;
|
|
52
|
+
from?: string;
|
|
53
|
+
};
|
|
54
|
+
text?: {
|
|
55
|
+
body: string;
|
|
56
|
+
};
|
|
57
|
+
interactive?: {
|
|
58
|
+
type: "button_reply" | "list_reply";
|
|
59
|
+
button_reply?: {
|
|
60
|
+
id: string;
|
|
61
|
+
title: string;
|
|
62
|
+
};
|
|
63
|
+
list_reply?: {
|
|
64
|
+
id: string;
|
|
65
|
+
title: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
image?: InboundMedia;
|
|
70
|
+
audio?: InboundMedia;
|
|
71
|
+
video?: InboundMedia;
|
|
72
|
+
document?: InboundMedia;
|
|
73
|
+
}
|
|
74
|
+
export interface InboundMedia {
|
|
75
|
+
id: string;
|
|
76
|
+
mime_type?: string;
|
|
77
|
+
sha256?: string;
|
|
78
|
+
filename?: string;
|
|
79
|
+
caption?: string;
|
|
80
|
+
}
|
|
81
|
+
/** The `value` object inside `entry[].changes[]`. */
|
|
82
|
+
export interface ChangeValue {
|
|
83
|
+
messaging_product?: string;
|
|
84
|
+
metadata?: {
|
|
85
|
+
display_phone_number?: string;
|
|
86
|
+
phone_number_id?: string;
|
|
87
|
+
};
|
|
88
|
+
contacts?: Array<{
|
|
89
|
+
wa_id?: string;
|
|
90
|
+
profile?: {
|
|
91
|
+
name?: string;
|
|
92
|
+
};
|
|
93
|
+
}>;
|
|
94
|
+
messages?: InboundMessage[];
|
|
95
|
+
statuses?: unknown[];
|
|
96
|
+
}
|
|
97
|
+
/** Top-level webhook POST body. */
|
|
98
|
+
export interface WebhookBody {
|
|
99
|
+
object?: string;
|
|
100
|
+
entry?: Array<{
|
|
101
|
+
id?: string;
|
|
102
|
+
changes?: Array<{
|
|
103
|
+
value?: ChangeValue;
|
|
104
|
+
}>;
|
|
105
|
+
}>;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { IngressSink } from "@copilotkit/channels";
|
|
2
|
+
import type { ChangeValue } from "./types.js";
|
|
3
|
+
import type { HistoryStore } from "./history-store.js";
|
|
4
|
+
import type { WhatsAppClient } from "./client.js";
|
|
5
|
+
import type { FileDeliveryConfig } from "./download-files.js";
|
|
6
|
+
export interface WebhookListenerArgs {
|
|
7
|
+
sink: IngressSink;
|
|
8
|
+
history: HistoryStore;
|
|
9
|
+
phoneNumberId: string;
|
|
10
|
+
commandPrefix: string;
|
|
11
|
+
client: Pick<WhatsAppClient, "downloadMedia" | "sendReadReceipt">;
|
|
12
|
+
files: FileDeliveryConfig;
|
|
13
|
+
}
|
|
14
|
+
/** Process one webhook `value` object: classify each message and emit to the sink. */
|
|
15
|
+
export declare function handleWebhookValue(value: ChangeValue, args: WebhookListenerArgs): Promise<void>;
|
|
16
|
+
//# sourceMappingURL=webhook-listener.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-listener.d.ts","sourceRoot":"","sources":["../src/webhook-listener.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,YAAY,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,kBAAkB,EAAoB,MAAM,qBAAqB,CAAC;AAKhF,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,YAAY,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,IAAI,CAAC,cAAc,EAAE,eAAe,GAAG,iBAAiB,CAAC,CAAC;IAClE,KAAK,EAAE,kBAAkB,CAAC;CAC3B;AAID,sFAAsF;AACtF,wBAAsB,kBAAkB,CACtC,KAAK,EAAE,WAAW,EAClB,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,IAAI,CAAC,CAoIf"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { buildFileContentParts } from "./download-files.js";
|
|
2
|
+
import { conversationKeyOf, decodeInteraction } from "./interaction.js";
|
|
3
|
+
const MEDIA_TYPES = ["image", "audio", "video", "document"];
|
|
4
|
+
/** Process one webhook `value` object: classify each message and emit to the sink. */
|
|
5
|
+
export async function handleWebhookValue(value, args) {
|
|
6
|
+
if (!value.messages || value.messages.length === 0)
|
|
7
|
+
return; // statuses-only or empty
|
|
8
|
+
const nameByWaId = new Map();
|
|
9
|
+
for (const c of value.contacts ?? []) {
|
|
10
|
+
if (c.wa_id && c.profile?.name)
|
|
11
|
+
nameByWaId.set(c.wa_id, c.profile.name);
|
|
12
|
+
}
|
|
13
|
+
for (const msg of value.messages) {
|
|
14
|
+
const replyTarget = {
|
|
15
|
+
to: msg.from,
|
|
16
|
+
phoneNumberId: args.phoneNumberId,
|
|
17
|
+
};
|
|
18
|
+
const user = { id: msg.from };
|
|
19
|
+
const name = nameByWaId.get(msg.from);
|
|
20
|
+
if (name)
|
|
21
|
+
user.name = name;
|
|
22
|
+
// Acknowledge immediately: mark read + show a typing indicator so the user
|
|
23
|
+
// sees activity during the (non-streaming) agent run. Best-effort — a
|
|
24
|
+
// failure here must never block or fail the turn.
|
|
25
|
+
if (msg.id) {
|
|
26
|
+
void args.client
|
|
27
|
+
.sendReadReceipt(msg.id, { typing: true })
|
|
28
|
+
.catch((err) => console.warn("[whatsapp] read/typing failed:", err));
|
|
29
|
+
}
|
|
30
|
+
// 1. Interactive reply → interaction.
|
|
31
|
+
if (msg.type === "interactive") {
|
|
32
|
+
const evt = decodeInteraction(msg, replyTarget);
|
|
33
|
+
if (evt) {
|
|
34
|
+
if (name)
|
|
35
|
+
evt.user = { id: msg.from, name };
|
|
36
|
+
await args.sink.onInteraction(evt);
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// 2. Text → command or turn.
|
|
41
|
+
if (msg.type === "text" && msg.text?.body) {
|
|
42
|
+
const body = msg.text.body;
|
|
43
|
+
const conversationKey = conversationKeyOf(msg.from);
|
|
44
|
+
if (body.startsWith(args.commandPrefix)) {
|
|
45
|
+
const rest = body.slice(args.commandPrefix.length);
|
|
46
|
+
const space = rest.indexOf(" ");
|
|
47
|
+
const command = space === -1 ? rest : rest.slice(0, space);
|
|
48
|
+
const text = space === -1 ? "" : rest.slice(space + 1).trim();
|
|
49
|
+
// Unlike a normal turn, a slash command's text is NOT persisted to history here.
|
|
50
|
+
// The command handler injects it via thread.runAgent({ prompt }) — the engine's
|
|
51
|
+
// designated path for input that isn't in the adapter's replayed history (mirrors
|
|
52
|
+
// bot-slack, where slash args never appear in channel history). Persisting it here
|
|
53
|
+
// too would double it in the agent's context.
|
|
54
|
+
await args.sink.onCommand({
|
|
55
|
+
command,
|
|
56
|
+
text,
|
|
57
|
+
conversationKey,
|
|
58
|
+
replyTarget,
|
|
59
|
+
user,
|
|
60
|
+
platform: "whatsapp",
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
let userText = body;
|
|
65
|
+
// Quote-reply: WhatsApp sends only the quoted message's id (`context.id`),
|
|
66
|
+
// not its text. Resolve it from our own history so the agent sees what the
|
|
67
|
+
// user is replying to (e.g. "can you do this" → which message is "this").
|
|
68
|
+
const quotedId = msg.context?.id;
|
|
69
|
+
if (quotedId) {
|
|
70
|
+
const hist = await args.history.read(conversationKey);
|
|
71
|
+
const quoted = hist.find((m) => m.id === quotedId);
|
|
72
|
+
if (quoted) {
|
|
73
|
+
const quotedText = typeof quoted.content === "string"
|
|
74
|
+
? quoted.content
|
|
75
|
+
: "[an earlier attachment]";
|
|
76
|
+
userText = `[Replying to an earlier message: "${quotedText}"]\n\n${body}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await args.history.append(conversationKey, {
|
|
80
|
+
role: "user",
|
|
81
|
+
content: userText,
|
|
82
|
+
ts: msg.timestamp ?? msg.id,
|
|
83
|
+
id: msg.id,
|
|
84
|
+
});
|
|
85
|
+
await args.sink.onTurn({
|
|
86
|
+
conversationKey,
|
|
87
|
+
replyTarget,
|
|
88
|
+
userText,
|
|
89
|
+
user,
|
|
90
|
+
platform: "whatsapp",
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// 3. Media → turn with multimodal content stored in history.
|
|
95
|
+
if (MEDIA_TYPES.includes(msg.type)) {
|
|
96
|
+
const conversationKey = conversationKeyOf(msg.from);
|
|
97
|
+
const mediaObj = msg[msg.type];
|
|
98
|
+
const caption = mediaObj?.caption ?? "";
|
|
99
|
+
const { parts, notes } = await buildFileContentParts(mediaObj ? [mediaObj] : [], args.client, args.files);
|
|
100
|
+
const content = [];
|
|
101
|
+
if (caption)
|
|
102
|
+
content.push({ type: "text", text: caption });
|
|
103
|
+
content.push(...parts);
|
|
104
|
+
if (notes.length > 0)
|
|
105
|
+
content.push({
|
|
106
|
+
type: "text",
|
|
107
|
+
text: `[attachment notes: ${notes.join("; ")}]`,
|
|
108
|
+
});
|
|
109
|
+
if (content.length === 0)
|
|
110
|
+
continue;
|
|
111
|
+
await args.history.append(conversationKey, {
|
|
112
|
+
role: "user",
|
|
113
|
+
content,
|
|
114
|
+
ts: msg.timestamp ?? msg.id,
|
|
115
|
+
id: msg.id,
|
|
116
|
+
});
|
|
117
|
+
await args.sink.onTurn({
|
|
118
|
+
conversationKey,
|
|
119
|
+
replyTarget,
|
|
120
|
+
userText: caption,
|
|
121
|
+
user,
|
|
122
|
+
platform: "whatsapp",
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// Unknown type → ignore.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-listener.test.d.ts","sourceRoot":"","sources":["../src/webhook-listener.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { handleWebhookValue } from "./webhook-listener.js";
|
|
3
|
+
import { InMemoryHistoryStore } from "./history-store.js";
|
|
4
|
+
function makeSink() {
|
|
5
|
+
return {
|
|
6
|
+
onTurn: vi.fn(),
|
|
7
|
+
onInteraction: vi.fn(),
|
|
8
|
+
onCommand: vi.fn(),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const baseArgs = (sink, history = new InMemoryHistoryStore()) => ({
|
|
12
|
+
sink,
|
|
13
|
+
history,
|
|
14
|
+
phoneNumberId: "PNID",
|
|
15
|
+
commandPrefix: "/",
|
|
16
|
+
client: {
|
|
17
|
+
downloadMedia: async () => ({ bytes: new Uint8Array(), mimeType: "x" }),
|
|
18
|
+
sendReadReceipt: vi.fn(async () => { }),
|
|
19
|
+
},
|
|
20
|
+
files: {},
|
|
21
|
+
});
|
|
22
|
+
describe("handleWebhookValue", () => {
|
|
23
|
+
it("emits a turn for an inbound text message and stores it", async () => {
|
|
24
|
+
const sink = makeSink();
|
|
25
|
+
const history = new InMemoryHistoryStore();
|
|
26
|
+
const value = {
|
|
27
|
+
contacts: [{ wa_id: "111", profile: { name: "Ada" } }],
|
|
28
|
+
messages: [
|
|
29
|
+
{ from: "111", id: "wamid.1", type: "text", text: { body: "hello" } },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
await handleWebhookValue(value, baseArgs(sink, history));
|
|
33
|
+
expect(sink.onTurn).toHaveBeenCalledWith(expect.objectContaining({
|
|
34
|
+
conversationKey: "whatsapp:111",
|
|
35
|
+
userText: "hello",
|
|
36
|
+
platform: "whatsapp",
|
|
37
|
+
user: { id: "111", name: "Ada" },
|
|
38
|
+
replyTarget: { to: "111", phoneNumberId: "PNID" },
|
|
39
|
+
}));
|
|
40
|
+
expect(await history.read("whatsapp:111")).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
it("emits a command for a text starting with the prefix", async () => {
|
|
43
|
+
const sink = makeSink();
|
|
44
|
+
const history = new InMemoryHistoryStore();
|
|
45
|
+
const value = {
|
|
46
|
+
messages: [
|
|
47
|
+
{
|
|
48
|
+
from: "111",
|
|
49
|
+
id: "wamid.2",
|
|
50
|
+
type: "text",
|
|
51
|
+
text: { body: "/triage urgent bug" },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
await handleWebhookValue(value, baseArgs(sink, history));
|
|
56
|
+
expect(sink.onCommand).toHaveBeenCalledWith(expect.objectContaining({
|
|
57
|
+
command: "triage",
|
|
58
|
+
text: "urgent bug",
|
|
59
|
+
conversationKey: "whatsapp:111",
|
|
60
|
+
}));
|
|
61
|
+
expect(sink.onTurn).not.toHaveBeenCalled();
|
|
62
|
+
expect(await history.read("whatsapp:111")).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
it("emits an interaction for a button_reply", async () => {
|
|
65
|
+
const sink = makeSink();
|
|
66
|
+
const value = {
|
|
67
|
+
messages: [
|
|
68
|
+
{
|
|
69
|
+
from: "111",
|
|
70
|
+
id: "wamid.3",
|
|
71
|
+
type: "interactive",
|
|
72
|
+
interactive: {
|
|
73
|
+
type: "button_reply",
|
|
74
|
+
button_reply: { id: "ck:1", title: "Yes" },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
await handleWebhookValue(value, baseArgs(sink));
|
|
80
|
+
expect(sink.onInteraction).toHaveBeenCalledWith(expect.objectContaining({ id: "ck:1" }));
|
|
81
|
+
});
|
|
82
|
+
it("ignores status receipts", async () => {
|
|
83
|
+
const sink = makeSink();
|
|
84
|
+
await handleWebhookValue({ statuses: [{}] }, baseArgs(sink));
|
|
85
|
+
expect(sink.onTurn).not.toHaveBeenCalled();
|
|
86
|
+
expect(sink.onInteraction).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
it("resolves a quote-reply by injecting the quoted message's text", async () => {
|
|
89
|
+
const sink = makeSink();
|
|
90
|
+
const history = new InMemoryHistoryStore();
|
|
91
|
+
await history.append("whatsapp:111", {
|
|
92
|
+
role: "user",
|
|
93
|
+
content: "pie chart of issues by state",
|
|
94
|
+
ts: "1",
|
|
95
|
+
id: "wamid.orig",
|
|
96
|
+
});
|
|
97
|
+
const value = {
|
|
98
|
+
messages: [
|
|
99
|
+
{
|
|
100
|
+
from: "111",
|
|
101
|
+
id: "wamid.new",
|
|
102
|
+
type: "text",
|
|
103
|
+
text: { body: "can you do this" },
|
|
104
|
+
context: { id: "wamid.orig" },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
await handleWebhookValue(value, baseArgs(sink, history));
|
|
109
|
+
expect(sink.onTurn).toHaveBeenCalledWith(expect.objectContaining({
|
|
110
|
+
userText: expect.stringContaining("pie chart of issues by state"),
|
|
111
|
+
}));
|
|
112
|
+
expect(sink.onTurn).toHaveBeenCalledWith(expect.objectContaining({
|
|
113
|
+
userText: expect.stringContaining("can you do this"),
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
it("leaves text unchanged when the quoted message is unknown", async () => {
|
|
117
|
+
const sink = makeSink();
|
|
118
|
+
const value = {
|
|
119
|
+
messages: [
|
|
120
|
+
{
|
|
121
|
+
from: "111",
|
|
122
|
+
id: "wamid.new",
|
|
123
|
+
type: "text",
|
|
124
|
+
text: { body: "hello" },
|
|
125
|
+
context: { id: "wamid.gone" },
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
await handleWebhookValue(value, baseArgs(sink));
|
|
130
|
+
expect(sink.onTurn).toHaveBeenCalledWith(expect.objectContaining({ userText: "hello" }));
|
|
131
|
+
});
|
|
132
|
+
it("fires a read-receipt + typing indicator for an inbound message", async () => {
|
|
133
|
+
const sink = makeSink();
|
|
134
|
+
const args = baseArgs(sink);
|
|
135
|
+
const value = {
|
|
136
|
+
messages: [
|
|
137
|
+
{ from: "111", id: "wamid.42", type: "text", text: { body: "hi" } },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
await handleWebhookValue(value, args);
|
|
141
|
+
expect(args.client.sendReadReceipt).toHaveBeenCalledWith("wamid.42", {
|
|
142
|
+
typing: true,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
it("emits a turn for inbound media and stores multimodal content", async () => {
|
|
146
|
+
const sink = makeSink();
|
|
147
|
+
const history = new InMemoryHistoryStore();
|
|
148
|
+
const client = {
|
|
149
|
+
downloadMedia: async () => ({
|
|
150
|
+
bytes: new Uint8Array([1, 2, 3]),
|
|
151
|
+
mimeType: "image/png",
|
|
152
|
+
}),
|
|
153
|
+
sendReadReceipt: vi.fn(async () => { }),
|
|
154
|
+
};
|
|
155
|
+
const value = {
|
|
156
|
+
messages: [
|
|
157
|
+
{
|
|
158
|
+
from: "111",
|
|
159
|
+
id: "wamid.m",
|
|
160
|
+
type: "image",
|
|
161
|
+
image: { id: "MID", mime_type: "image/png", caption: "look" },
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
await handleWebhookValue(value, { ...baseArgs(sink, history), client });
|
|
166
|
+
expect(sink.onTurn).toHaveBeenCalledWith(expect.objectContaining({
|
|
167
|
+
conversationKey: "whatsapp:111",
|
|
168
|
+
userText: "look",
|
|
169
|
+
platform: "whatsapp",
|
|
170
|
+
}));
|
|
171
|
+
const stored = await history.read("whatsapp:111");
|
|
172
|
+
expect(stored).toHaveLength(1);
|
|
173
|
+
expect(Array.isArray(stored[0].content)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
it("notes a media download failure instead of throwing", async () => {
|
|
176
|
+
const sink = makeSink();
|
|
177
|
+
const history = new InMemoryHistoryStore();
|
|
178
|
+
const client = {
|
|
179
|
+
downloadMedia: async () => {
|
|
180
|
+
throw new Error("boom");
|
|
181
|
+
},
|
|
182
|
+
sendReadReceipt: vi.fn(async () => { }),
|
|
183
|
+
};
|
|
184
|
+
const value = {
|
|
185
|
+
messages: [
|
|
186
|
+
{
|
|
187
|
+
from: "111",
|
|
188
|
+
id: "wamid.m2",
|
|
189
|
+
type: "image",
|
|
190
|
+
image: { id: "MID", mime_type: "image/png" },
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
await handleWebhookValue(value, { ...baseArgs(sink, history), client });
|
|
195
|
+
const stored = await history.read("whatsapp:111");
|
|
196
|
+
// download failed → a note is appended to content as a text part ("failed to download …")
|
|
197
|
+
// so the turn IS stored and emitted (the note is the only content item)
|
|
198
|
+
expect(stored).toHaveLength(1);
|
|
199
|
+
expect(Array.isArray(stored[0].content)).toBe(true);
|
|
200
|
+
const content = stored[0].content;
|
|
201
|
+
expect(content.some((p) => p.type === "text" && p.text?.includes("failed to download"))).toBe(true);
|
|
202
|
+
expect(sink.onTurn).toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
});
|