@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/dist/adapter.d.ts +57 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +242 -0
  6. package/dist/adapter.test.d.ts +2 -0
  7. package/dist/adapter.test.d.ts.map +1 -0
  8. package/dist/adapter.test.js +137 -0
  9. package/dist/built-in-context.d.ts +7 -0
  10. package/dist/built-in-context.d.ts.map +1 -0
  11. package/dist/built-in-context.js +19 -0
  12. package/dist/built-in-context.test.d.ts +2 -0
  13. package/dist/built-in-context.test.d.ts.map +1 -0
  14. package/dist/built-in-context.test.js +17 -0
  15. package/dist/built-in-tools.d.ts +8 -0
  16. package/dist/built-in-tools.d.ts.map +1 -0
  17. package/dist/built-in-tools.js +6 -0
  18. package/dist/client.d.ts +40 -0
  19. package/dist/client.d.ts.map +1 -0
  20. package/dist/client.js +108 -0
  21. package/dist/client.test.d.ts +2 -0
  22. package/dist/client.test.d.ts.map +1 -0
  23. package/dist/client.test.js +108 -0
  24. package/dist/conversation-store.d.ts +23 -0
  25. package/dist/conversation-store.d.ts.map +1 -0
  26. package/dist/conversation-store.js +41 -0
  27. package/dist/conversation-store.test.d.ts +2 -0
  28. package/dist/conversation-store.test.d.ts.map +1 -0
  29. package/dist/conversation-store.test.js +40 -0
  30. package/dist/download-files.d.ts +44 -0
  31. package/dist/download-files.d.ts.map +1 -0
  32. package/dist/download-files.js +66 -0
  33. package/dist/download-files.test.d.ts +2 -0
  34. package/dist/download-files.test.d.ts.map +1 -0
  35. package/dist/download-files.test.js +34 -0
  36. package/dist/event-renderer.d.ts +18 -0
  37. package/dist/event-renderer.d.ts.map +1 -0
  38. package/dist/event-renderer.js +110 -0
  39. package/dist/event-renderer.test.d.ts +2 -0
  40. package/dist/event-renderer.test.d.ts.map +1 -0
  41. package/dist/event-renderer.test.js +68 -0
  42. package/dist/history-store.d.ts +36 -0
  43. package/dist/history-store.d.ts.map +1 -0
  44. package/dist/history-store.js +17 -0
  45. package/dist/history-store.test.d.ts +2 -0
  46. package/dist/history-store.test.d.ts.map +1 -0
  47. package/dist/history-store.test.js +24 -0
  48. package/dist/index.d.ts +18 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +12 -0
  51. package/dist/interaction.d.ts +19 -0
  52. package/dist/interaction.d.ts.map +1 -0
  53. package/dist/interaction.js +56 -0
  54. package/dist/interaction.test.d.ts +2 -0
  55. package/dist/interaction.test.d.ts.map +1 -0
  56. package/dist/interaction.test.js +67 -0
  57. package/dist/markdown-to-wa.d.ts +15 -0
  58. package/dist/markdown-to-wa.d.ts.map +1 -0
  59. package/dist/markdown-to-wa.js +56 -0
  60. package/dist/markdown-to-wa.test.d.ts +2 -0
  61. package/dist/markdown-to-wa.test.d.ts.map +1 -0
  62. package/dist/markdown-to-wa.test.js +29 -0
  63. package/dist/render/budget.d.ts +34 -0
  64. package/dist/render/budget.d.ts.map +1 -0
  65. package/dist/render/budget.js +40 -0
  66. package/dist/render/budget.test.d.ts +2 -0
  67. package/dist/render/budget.test.d.ts.map +1 -0
  68. package/dist/render/budget.test.js +24 -0
  69. package/dist/render/message.d.ts +74 -0
  70. package/dist/render/message.d.ts.map +1 -0
  71. package/dist/render/message.js +247 -0
  72. package/dist/render/message.test.d.ts +2 -0
  73. package/dist/render/message.test.d.ts.map +1 -0
  74. package/dist/render/message.test.js +184 -0
  75. package/dist/types.d.ts +107 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +1 -0
  78. package/dist/webhook-listener.d.ts +16 -0
  79. package/dist/webhook-listener.d.ts.map +1 -0
  80. package/dist/webhook-listener.js +128 -0
  81. package/dist/webhook-listener.test.d.ts +2 -0
  82. package/dist/webhook-listener.test.d.ts.map +1 -0
  83. package/dist/webhook-listener.test.js +204 -0
  84. package/dist/webhook-server.d.ts +23 -0
  85. package/dist/webhook-server.d.ts.map +1 -0
  86. package/dist/webhook-server.js +99 -0
  87. package/dist/webhook-server.test.d.ts +2 -0
  88. package/dist/webhook-server.test.d.ts.map +1 -0
  89. package/dist/webhook-server.test.js +150 -0
  90. package/package.json +55 -0
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { whatsapp, splitForWhatsApp } from "./adapter.js";
3
+ import { InMemoryHistoryStore } from "./history-store.js";
4
+ describe("whatsapp() adapter", () => {
5
+ const opts = {
6
+ accessToken: "TOK",
7
+ phoneNumberId: "PNID",
8
+ appSecret: "SECRET",
9
+ verifyToken: "VTOK",
10
+ };
11
+ it("declares the platform and capabilities (no streaming)", () => {
12
+ const a = whatsapp(opts);
13
+ expect(a.platform).toBe("whatsapp");
14
+ expect(a.capabilities.supportsStreaming).toBe(false);
15
+ expect(a.capabilities.supportsModals).toBe(false);
16
+ expect(a.conversationStore).toBeDefined();
17
+ });
18
+ it("render() lowers IR to Cloud API payloads", () => {
19
+ const a = whatsapp(opts);
20
+ const payloads = a.render([
21
+ { type: "section", props: { children: "hi" } },
22
+ ]);
23
+ expect(payloads[0]).toMatchObject({ type: "text" });
24
+ });
25
+ it("post() sends each rendered payload via the client and returns a ref", async () => {
26
+ const a = whatsapp(opts);
27
+ const sent = [];
28
+ a.client = {
29
+ sendMessage: vi.fn(async (to, p) => {
30
+ sent.push({ to, p });
31
+ return { id: "wamid.X", to, phoneNumberId: "PNID" };
32
+ }),
33
+ };
34
+ const ref = await a.post({ to: "111", phoneNumberId: "PNID" }, [
35
+ { type: "section", props: { children: "hi" } },
36
+ ]);
37
+ expect(sent[0].to).toBe("111");
38
+ expect(ref).toMatchObject({ id: "wamid.X" });
39
+ });
40
+ it("records outbound messages in history keyed by wamid (quote-reply resolution)", async () => {
41
+ const history = new InMemoryHistoryStore();
42
+ const a = whatsapp({ ...opts, historyStore: history });
43
+ a.client = {
44
+ sendMessage: vi.fn(async (to) => ({
45
+ id: "wamid.OUT1",
46
+ to,
47
+ phoneNumberId: "PNID",
48
+ })),
49
+ };
50
+ await a.post({ to: "111", phoneNumberId: "PNID" }, [
51
+ { type: "section", props: { children: "Open CPK issues" } },
52
+ ]);
53
+ const hist = await history.read("whatsapp:111");
54
+ expect(hist).toHaveLength(1);
55
+ expect(hist[0]).toMatchObject({ role: "assistant", id: "wamid.OUT1" });
56
+ expect(hist[0].content).toContain("Open CPK issues");
57
+ });
58
+ it("decodeInteraction delegates to the interaction decoder", () => {
59
+ const a = whatsapp(opts);
60
+ const evt = a.decodeInteraction({
61
+ message: {
62
+ from: "111",
63
+ id: "wamid.1",
64
+ type: "interactive",
65
+ interactive: {
66
+ type: "button_reply",
67
+ button_reply: { id: "ck:9", title: "Y" },
68
+ },
69
+ },
70
+ replyTarget: { to: "111", phoneNumberId: "PNID" },
71
+ });
72
+ expect(evt?.id).toBe("ck:9");
73
+ });
74
+ it("lookupUser returns undefined (no directory)", async () => {
75
+ const a = whatsapp(opts);
76
+ expect(await a.lookupUser({ query: "anyone" })).toBeUndefined();
77
+ });
78
+ it("postFile uploads media and sends an image payload by id for image mimes", async () => {
79
+ const a = whatsapp(opts);
80
+ const calls = [];
81
+ a.client = {
82
+ uploadMedia: vi.fn(async () => "MEDIA1"),
83
+ sendMessage: vi.fn(async (to, p) => {
84
+ calls.push(p);
85
+ return { id: "x", to, phoneNumberId: "PNID" };
86
+ }),
87
+ };
88
+ const res = await a.postFile({ to: "111", phoneNumberId: "PNID" }, { bytes: new Uint8Array([1]), filename: "pic.png", altText: "a pic" });
89
+ expect(res).toEqual({ ok: true, fileId: "MEDIA1" });
90
+ expect(calls[0]).toEqual({
91
+ type: "image",
92
+ image: { id: "MEDIA1", caption: "a pic" },
93
+ });
94
+ });
95
+ it("postFile sends a document payload by id for non-image mimes", async () => {
96
+ const a = whatsapp(opts);
97
+ const calls = [];
98
+ a.client = {
99
+ uploadMedia: vi.fn(async () => "M2"),
100
+ sendMessage: vi.fn(async (to, p) => {
101
+ calls.push(p);
102
+ return { id: "x", to, phoneNumberId: "PNID" };
103
+ }),
104
+ };
105
+ await a.postFile({ to: "111", phoneNumberId: "PNID" }, { bytes: new Uint8Array([1]), filename: "report.pdf", title: "Report" });
106
+ expect(calls[0]).toEqual({
107
+ type: "document",
108
+ document: { id: "M2", filename: "report.pdf", caption: "Report" },
109
+ });
110
+ });
111
+ it("buffered run renderer send converts markdown before sending", async () => {
112
+ const a = whatsapp(opts);
113
+ const sent = [];
114
+ a.client = {
115
+ sendMessage: vi.fn(async (to, p) => {
116
+ sent.push(p.text.body);
117
+ return { id: "x", to, phoneNumberId: "PNID" };
118
+ }),
119
+ };
120
+ const r = a.createRunRenderer({ to: "111", phoneNumberId: "PNID" });
121
+ const s = r.subscriber;
122
+ s.onTextMessageStartEvent({ event: { messageId: "m" } });
123
+ s.onTextMessageContentEvent({
124
+ event: { messageId: "m", delta: "**hi** world" },
125
+ });
126
+ await s.onTextMessageEndEvent({ event: { messageId: "m" } });
127
+ expect(sent).toEqual(["*hi* world"]);
128
+ });
129
+ });
130
+ describe("splitForWhatsApp", () => {
131
+ it("returns one chunk when under the limit", () => {
132
+ expect(splitForWhatsApp("abc", 10)).toEqual(["abc"]);
133
+ });
134
+ it("splits text over the limit into max-sized chunks", () => {
135
+ expect(splitForWhatsApp("abcdef", 3)).toEqual(["abc", "def"]);
136
+ });
137
+ });
@@ -0,0 +1,7 @@
1
+ import type { ContextEntry } from "@copilotkit/channels";
2
+ /** Tell the model it's on WhatsApp and which formatting actually renders. */
3
+ export declare const whatsAppFormattingContext: ContextEntry;
4
+ /** WhatsApp messages cannot be edited — there is no token-by-token streaming. */
5
+ export declare const whatsAppDeliveryContext: ContextEntry;
6
+ export declare const defaultWhatsAppContext: ContextEntry[];
7
+ //# sourceMappingURL=built-in-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-context.d.ts","sourceRoot":"","sources":["../src/built-in-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,6EAA6E;AAC7E,eAAO,MAAM,yBAAyB,EAAE,YAQvC,CAAC;AAEF,iFAAiF;AACjF,eAAO,MAAM,uBAAuB,EAAE,YAKrC,CAAC;AAEF,eAAO,MAAM,sBAAsB,EAAE,YAAY,EAGhD,CAAC"}
@@ -0,0 +1,19 @@
1
+ /** Tell the model it's on WhatsApp and which formatting actually renders. */
2
+ export const whatsAppFormattingContext = {
3
+ description: "WhatsApp message formatting rules",
4
+ value: "You are replying inside WhatsApp. Use Markdown normally; it is converted " +
5
+ "to WhatsApp formatting: **bold**, *italic*/_italic_, ~~strikethrough~~, and " +
6
+ "`code`/```code blocks```. WhatsApp has no headings, tables, or clickable " +
7
+ "Markdown links — links render as plain text, so write 'label (https://url)'. " +
8
+ "Keep replies concise; long messages are split.",
9
+ };
10
+ /** WhatsApp messages cannot be edited — there is no token-by-token streaming. */
11
+ export const whatsAppDeliveryContext = {
12
+ description: "WhatsApp message delivery constraints",
13
+ value: "Replies are delivered as a single finished message (no live streaming/edits). " +
14
+ "Do not promise to 'update this message'; post a new message instead.",
15
+ };
16
+ export const defaultWhatsAppContext = [
17
+ whatsAppFormattingContext,
18
+ whatsAppDeliveryContext,
19
+ ];
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=built-in-context.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-context.test.d.ts","sourceRoot":"","sources":["../src/built-in-context.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { defaultWhatsAppTools } from "./built-in-tools.js";
3
+ import { defaultWhatsAppContext } from "./built-in-context.js";
4
+ describe("defaults", () => {
5
+ it("ships no built-in tools in v1 (WhatsApp has no user directory)", () => {
6
+ expect(defaultWhatsAppTools).toEqual([]);
7
+ });
8
+ it("ships formatting + no-streaming guidance context", () => {
9
+ expect(defaultWhatsAppContext.length).toBeGreaterThan(0);
10
+ const joined = defaultWhatsAppContext
11
+ .map((c) => c.value)
12
+ .join("\n")
13
+ .toLowerCase();
14
+ expect(joined).toContain("whatsapp");
15
+ expect(joined).toMatch(/bold|formatting/);
16
+ });
17
+ });
@@ -0,0 +1,8 @@
1
+ import type { BotTool } from "@copilotkit/channels";
2
+ /**
3
+ * WhatsApp ships no built-in tools in v1. Unlike Slack (which provides
4
+ * `lookup_slack_user` for @-mentions), WhatsApp exposes no user directory, so
5
+ * there is nothing platform-generic to register. Apps add their own tools.
6
+ */
7
+ export declare const defaultWhatsAppTools: BotTool[];
8
+ //# sourceMappingURL=built-in-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"built-in-tools.d.ts","sourceRoot":"","sources":["../src/built-in-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,EAAE,OAAO,EAAO,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * WhatsApp ships no built-in tools in v1. Unlike Slack (which provides
3
+ * `lookup_slack_user` for @-mentions), WhatsApp exposes no user directory, so
4
+ * there is nothing platform-generic to register. Apps add their own tools.
5
+ */
6
+ export const defaultWhatsAppTools = [];
@@ -0,0 +1,40 @@
1
+ import type { WhatsAppMessageRef } from "./types.js";
2
+ import type { WhatsAppOutbound } from "./render/message.js";
3
+ export interface WhatsAppClientOptions {
4
+ accessToken: string;
5
+ phoneNumberId: string;
6
+ apiVersion?: string;
7
+ graphBaseUrl?: string;
8
+ /** Injectable for tests. Defaults to global fetch. */
9
+ fetchImpl?: typeof fetch;
10
+ }
11
+ export interface DownloadedMedia {
12
+ bytes: Uint8Array;
13
+ mimeType: string;
14
+ }
15
+ /** Thin Cloud API client over fetch — send messages, upload/download media. */
16
+ export declare class WhatsAppClient {
17
+ private readonly accessToken;
18
+ private readonly phoneNumberId;
19
+ private readonly apiVersion;
20
+ private readonly base;
21
+ private readonly fetchImpl;
22
+ constructor(opts: WhatsAppClientOptions);
23
+ private get authHeader();
24
+ /** POST a message; returns the outbound message ref. */
25
+ sendMessage(to: string, payload: WhatsAppOutbound): Promise<WhatsAppMessageRef>;
26
+ /**
27
+ * Mark an inbound message as read and optionally show a typing indicator.
28
+ * The indicator displays for up to ~25s or until the next message is sent —
29
+ * it's the only "pending" affordance WhatsApp offers (no streaming/edits).
30
+ * Throws on a non-2xx so callers can decide; ingress fires it best-effort.
31
+ */
32
+ sendReadReceipt(messageId: string, opts?: {
33
+ typing?: boolean;
34
+ }): Promise<void>;
35
+ /** Upload media (multipart) and return its media id. */
36
+ uploadMedia(bytes: Uint8Array, mimeType: string, filename?: string): Promise<string>;
37
+ /** Resolve a media id to a download URL, then fetch the bytes. */
38
+ downloadMedia(mediaId: string): Promise<DownloadedMedia>;
39
+ }
40
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,+EAA+E;AAC/E,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,IAAI,EAAE,qBAAqB;IAQvC,OAAO,KAAK,UAAU,GAErB;IAED,wDAAwD;IAClD,WAAW,CACf,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,kBAAkB,CAAC;IAsB9B;;;;;OAKG;IACG,eAAe,CACnB,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAC9B,OAAO,CAAC,IAAI,CAAC;IAoBhB,wDAAwD;IAClD,WAAW,CACf,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,MAAM,EAChB,QAAQ,SAAe,GACtB,OAAO,CAAC,MAAM,CAAC;IAgClB,kEAAkE;IAC5D,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;CAe/D"}
package/dist/client.js ADDED
@@ -0,0 +1,108 @@
1
+ /** Thin Cloud API client over fetch — send messages, upload/download media. */
2
+ export class WhatsAppClient {
3
+ accessToken;
4
+ phoneNumberId;
5
+ apiVersion;
6
+ base;
7
+ fetchImpl;
8
+ constructor(opts) {
9
+ this.accessToken = opts.accessToken;
10
+ this.phoneNumberId = opts.phoneNumberId;
11
+ this.apiVersion = opts.apiVersion ?? "v21.0";
12
+ this.base = opts.graphBaseUrl ?? "https://graph.facebook.com";
13
+ this.fetchImpl = opts.fetchImpl ?? fetch;
14
+ }
15
+ get authHeader() {
16
+ return { Authorization: `Bearer ${this.accessToken}` };
17
+ }
18
+ /** POST a message; returns the outbound message ref. */
19
+ async sendMessage(to, payload) {
20
+ const url = `${this.base}/${this.apiVersion}/${this.phoneNumberId}/messages`;
21
+ const res = await this.fetchImpl(url, {
22
+ method: "POST",
23
+ headers: { ...this.authHeader, "Content-Type": "application/json" },
24
+ body: JSON.stringify({
25
+ messaging_product: "whatsapp",
26
+ recipient_type: "individual",
27
+ to,
28
+ ...payload,
29
+ }),
30
+ });
31
+ if (!res.ok) {
32
+ throw new Error(`WhatsApp sendMessage failed: ${res.status} ${await safeText(res)}`);
33
+ }
34
+ const json = (await res.json());
35
+ const id = json.messages?.[0]?.id ?? "";
36
+ return { id, to, phoneNumberId: this.phoneNumberId };
37
+ }
38
+ /**
39
+ * Mark an inbound message as read and optionally show a typing indicator.
40
+ * The indicator displays for up to ~25s or until the next message is sent —
41
+ * it's the only "pending" affordance WhatsApp offers (no streaming/edits).
42
+ * Throws on a non-2xx so callers can decide; ingress fires it best-effort.
43
+ */
44
+ async sendReadReceipt(messageId, opts = {}) {
45
+ const url = `${this.base}/${this.apiVersion}/${this.phoneNumberId}/messages`;
46
+ const body = {
47
+ messaging_product: "whatsapp",
48
+ status: "read",
49
+ message_id: messageId,
50
+ };
51
+ if (opts.typing)
52
+ body.typing_indicator = { type: "text" };
53
+ const res = await this.fetchImpl(url, {
54
+ method: "POST",
55
+ headers: { ...this.authHeader, "Content-Type": "application/json" },
56
+ body: JSON.stringify(body),
57
+ });
58
+ if (!res.ok) {
59
+ throw new Error(`WhatsApp read/typing failed: ${res.status} ${await safeText(res)}`);
60
+ }
61
+ }
62
+ /** Upload media (multipart) and return its media id. */
63
+ async uploadMedia(bytes, mimeType, filename = "upload.bin") {
64
+ const url = `${this.base}/${this.apiVersion}/${this.phoneNumberId}/media`;
65
+ const form = new FormData();
66
+ form.append("messaging_product", "whatsapp");
67
+ form.append("type", mimeType);
68
+ form.append("file", new Blob([
69
+ bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
70
+ ], { type: mimeType }), filename);
71
+ const res = await this.fetchImpl(url, {
72
+ method: "POST",
73
+ headers: this.authHeader,
74
+ body: form,
75
+ });
76
+ if (!res.ok)
77
+ throw new Error(`WhatsApp uploadMedia failed: ${res.status} ${await safeText(res)}`);
78
+ const json = (await res.json());
79
+ if (!json.id)
80
+ throw new Error("WhatsApp uploadMedia returned no id");
81
+ return json.id;
82
+ }
83
+ /** Resolve a media id to a download URL, then fetch the bytes. */
84
+ async downloadMedia(mediaId) {
85
+ const metaUrl = `${this.base}/${this.apiVersion}/${mediaId}`;
86
+ const metaRes = await this.fetchImpl(metaUrl, { headers: this.authHeader });
87
+ if (!metaRes.ok)
88
+ throw new Error(`WhatsApp media meta failed: ${metaRes.status}`);
89
+ const meta = (await metaRes.json());
90
+ if (!meta.url)
91
+ throw new Error("WhatsApp media meta returned no url");
92
+ const blobRes = await this.fetchImpl(meta.url, {
93
+ headers: this.authHeader,
94
+ });
95
+ if (!blobRes.ok)
96
+ throw new Error(`WhatsApp media download failed: ${blobRes.status}`);
97
+ const bytes = new Uint8Array(await blobRes.arrayBuffer());
98
+ return { bytes, mimeType: meta.mime_type ?? "application/octet-stream" };
99
+ }
100
+ }
101
+ async function safeText(res) {
102
+ try {
103
+ return await res.text();
104
+ }
105
+ catch {
106
+ return "";
107
+ }
108
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../src/client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { WhatsAppClient } from "./client.js";
3
+ function fakeFetch(impl) {
4
+ return vi.fn(impl);
5
+ }
6
+ describe("WhatsAppClient", () => {
7
+ it("POSTs a message to the messages endpoint with auth + messaging_product", async () => {
8
+ const calls = [];
9
+ const client = new WhatsAppClient({
10
+ accessToken: "TOK",
11
+ phoneNumberId: "PNID",
12
+ apiVersion: "v21.0",
13
+ graphBaseUrl: "https://graph.test",
14
+ fetchImpl: fakeFetch(async (url, init) => {
15
+ calls.push({
16
+ url: url,
17
+ body: JSON.parse(init?.body ?? "{}"),
18
+ auth: init?.headers?.Authorization,
19
+ });
20
+ return new Response(JSON.stringify({ messages: [{ id: "wamid.OUT" }] }), { status: 200 });
21
+ }),
22
+ });
23
+ const ref = await client.sendMessage("15551234567", {
24
+ type: "text",
25
+ text: { body: "hi", preview_url: false },
26
+ });
27
+ expect(calls[0].url).toBe("https://graph.test/v21.0/PNID/messages");
28
+ expect(calls[0].auth).toBe("Bearer TOK");
29
+ expect(calls[0].body).toMatchObject({
30
+ messaging_product: "whatsapp",
31
+ to: "15551234567",
32
+ type: "text",
33
+ });
34
+ expect(ref).toEqual({
35
+ id: "wamid.OUT",
36
+ to: "15551234567",
37
+ phoneNumberId: "PNID",
38
+ });
39
+ });
40
+ it("throws on a non-2xx response", async () => {
41
+ const client = new WhatsAppClient({
42
+ accessToken: "TOK",
43
+ phoneNumberId: "PNID",
44
+ fetchImpl: fakeFetch(async () => new Response("bad", { status: 400 })),
45
+ });
46
+ await expect(client.sendMessage("x", {
47
+ type: "text",
48
+ text: { body: "y", preview_url: false },
49
+ })).rejects.toThrow(/400/);
50
+ });
51
+ it("downloads media in two hops (metadata then bytes)", async () => {
52
+ const client = new WhatsAppClient({
53
+ accessToken: "TOK",
54
+ phoneNumberId: "PNID",
55
+ apiVersion: "v21.0",
56
+ graphBaseUrl: "https://graph.test",
57
+ fetchImpl: fakeFetch(async (url) => {
58
+ if (url.endsWith("/MEDIA_ID")) {
59
+ return new Response(JSON.stringify({
60
+ url: "https://cdn.test/blob",
61
+ mime_type: "image/png",
62
+ }), {
63
+ status: 200,
64
+ });
65
+ }
66
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
67
+ }),
68
+ });
69
+ const res = await client.downloadMedia("MEDIA_ID");
70
+ expect(res.mimeType).toBe("image/png");
71
+ expect(Array.from(res.bytes)).toEqual([1, 2, 3]);
72
+ });
73
+ it("sends a read receipt with a typing indicator", async () => {
74
+ let sent;
75
+ const client = new WhatsAppClient({
76
+ accessToken: "TOK",
77
+ phoneNumberId: "PNID",
78
+ apiVersion: "v21.0",
79
+ graphBaseUrl: "https://graph.test",
80
+ fetchImpl: fakeFetch(async (url, init) => {
81
+ sent = { url, body: JSON.parse(init?.body ?? "{}") };
82
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
83
+ }),
84
+ });
85
+ await client.sendReadReceipt("wamid.IN", { typing: true });
86
+ expect(sent.url).toBe("https://graph.test/v21.0/PNID/messages");
87
+ expect(sent.body).toEqual({
88
+ messaging_product: "whatsapp",
89
+ status: "read",
90
+ message_id: "wamid.IN",
91
+ typing_indicator: { type: "text" },
92
+ });
93
+ });
94
+ it("sends a plain read receipt without typing when not requested", async () => {
95
+ let body;
96
+ const client = new WhatsAppClient({
97
+ accessToken: "TOK",
98
+ phoneNumberId: "PNID",
99
+ fetchImpl: fakeFetch(async (_url, init) => {
100
+ body = JSON.parse(init?.body ?? "{}");
101
+ return new Response("{}", { status: 200 });
102
+ }),
103
+ });
104
+ await client.sendReadReceipt("wamid.IN");
105
+ expect(body.typing_indicator).toBeUndefined();
106
+ expect(body.status).toBe("read");
107
+ });
108
+ });
@@ -0,0 +1,23 @@
1
+ import type { AbstractAgent } from "@ag-ui/client";
2
+ import type { AgentSession, ConversationStore } from "@copilotkit/channels";
3
+ import type { ThreadMessage } from "@copilotkit/channels-ui";
4
+ import type { HistoryStore } from "./history-store.js";
5
+ import type { ReplyTarget } from "./types.js";
6
+ /**
7
+ * Adapter-owned conversation store. WhatsApp has no readable history, so we
8
+ * rebuild each turn from a pluggable `HistoryStore` and set the agent's
9
+ * `messages` from it. A fresh threadId per turn keeps stateless/agent-backend
10
+ * runs from desyncing on accumulated internal (tool) messages — same approach
11
+ * as `bot-slack`.
12
+ */
13
+ export declare class WhatsAppConversationStore implements ConversationStore {
14
+ private readonly historyStore;
15
+ constructor(args: {
16
+ historyStore: HistoryStore;
17
+ });
18
+ private newThreadId;
19
+ getOrCreate(conversationKey: string, _replyTarget: ReplyTarget, makeAgent: (threadId: string) => AbstractAgent): Promise<AgentSession>;
20
+ /** Back `Thread.getMessages()` from stored history. */
21
+ getMessages(replyTarget: ReplyTarget): Promise<ThreadMessage[]>;
22
+ }
23
+ //# sourceMappingURL=conversation-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation-store.d.ts","sourceRoot":"","sources":["../src/conversation-store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAiB,MAAM,oBAAoB,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAS9C;;;;;;GAMG;AACH,qBAAa,yBAA0B,YAAW,iBAAiB;IACjE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAe;gBAEhC,IAAI,EAAE;QAAE,YAAY,EAAE,YAAY,CAAA;KAAE;IAIhD,OAAO,CAAC,WAAW;IAKb,WAAW,CACf,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,WAAW,EACzB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,aAAa,GAC7C,OAAO,CAAC,YAAY,CAAC;IAaxB,uDAAuD;IACjD,WAAW,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;CAStE"}
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { conversationKeyOf, waIdFromKey } from "./interaction.js";
3
+ /**
4
+ * Adapter-owned conversation store. WhatsApp has no readable history, so we
5
+ * rebuild each turn from a pluggable `HistoryStore` and set the agent's
6
+ * `messages` from it. A fresh threadId per turn keeps stateless/agent-backend
7
+ * runs from desyncing on accumulated internal (tool) messages — same approach
8
+ * as `bot-slack`.
9
+ */
10
+ export class WhatsAppConversationStore {
11
+ historyStore;
12
+ constructor(args) {
13
+ this.historyStore = args.historyStore;
14
+ }
15
+ newThreadId(conversationKey) {
16
+ const waId = waIdFromKey(conversationKey);
17
+ return `whatsapp-${waId}-${randomUUID()}`;
18
+ }
19
+ async getOrCreate(conversationKey, _replyTarget, makeAgent) {
20
+ const threadId = this.newThreadId(conversationKey);
21
+ const agent = makeAgent(threadId);
22
+ const history = await this.historyStore.read(conversationKey);
23
+ const messages = history.map((m, i) => ({
24
+ id: `${m.ts}-${i}`,
25
+ role: m.role,
26
+ content: m.content,
27
+ }));
28
+ agent.messages = messages;
29
+ return { agent, threadId };
30
+ }
31
+ /** Back `Thread.getMessages()` from stored history. */
32
+ async getMessages(replyTarget) {
33
+ const key = conversationKeyOf(replyTarget.to);
34
+ const history = await this.historyStore.read(key);
35
+ return history.map((m) => ({
36
+ text: typeof m.content === "string" ? m.content : "[media]",
37
+ isBot: m.role === "assistant",
38
+ ts: m.ts,
39
+ }));
40
+ }
41
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=conversation-store.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation-store.test.d.ts","sourceRoot":"","sources":["../src/conversation-store.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { WhatsAppConversationStore } from "./conversation-store.js";
3
+ import { InMemoryHistoryStore } from "./history-store.js";
4
+ function fakeAgent(threadId) {
5
+ return { threadId, messages: [], addMessage() { } };
6
+ }
7
+ describe("WhatsAppConversationStore", () => {
8
+ it("replays stored history into agent.messages with a fresh threadId", async () => {
9
+ const history = new InMemoryHistoryStore();
10
+ await history.append("whatsapp:111", {
11
+ role: "user",
12
+ content: "hi",
13
+ ts: "1",
14
+ });
15
+ await history.append("whatsapp:111", {
16
+ role: "assistant",
17
+ content: "hello",
18
+ ts: "2",
19
+ });
20
+ const store = new WhatsAppConversationStore({ historyStore: history });
21
+ const target = { to: "111", phoneNumberId: "P" };
22
+ const s1 = await store.getOrCreate("whatsapp:111", target, fakeAgent);
23
+ const s2 = await store.getOrCreate("whatsapp:111", target, fakeAgent);
24
+ expect(s1.agent.messages).toHaveLength(2);
25
+ expect(s1.agent.messages[0]).toMatchObject({ role: "user", content: "hi" });
26
+ expect(s1.threadId).not.toBe(s2.threadId); // fresh per turn
27
+ expect(s1.threadId).toMatch(/^whatsapp-111-/);
28
+ });
29
+ it("getMessages maps stored history into ThreadMessage[]", async () => {
30
+ const history = new InMemoryHistoryStore();
31
+ await history.append("whatsapp:111", {
32
+ role: "assistant",
33
+ content: "yo",
34
+ ts: "9",
35
+ });
36
+ const store = new WhatsAppConversationStore({ historyStore: history });
37
+ const out = await store.getMessages({ to: "111", phoneNumberId: "P" });
38
+ expect(out).toEqual([{ text: "yo", isBot: true, ts: "9" }]);
39
+ });
40
+ });
@@ -0,0 +1,44 @@
1
+ import type { WhatsAppClient } from "./client.js";
2
+ type MediaDataSource = {
3
+ type: "data";
4
+ value: string;
5
+ mimeType: string;
6
+ };
7
+ /** AG-UI multimodal content parts (shape the runtime's converter expects). */
8
+ export type AgentContentPart = {
9
+ type: "text";
10
+ text: string;
11
+ } | {
12
+ type: "image";
13
+ source: MediaDataSource;
14
+ } | {
15
+ type: "audio";
16
+ source: MediaDataSource;
17
+ } | {
18
+ type: "video";
19
+ source: MediaDataSource;
20
+ } | {
21
+ type: "document";
22
+ source: MediaDataSource;
23
+ };
24
+ /** Subset of an inbound media object we use. */
25
+ export interface WhatsAppMediaRef {
26
+ id: string;
27
+ mime_type?: string;
28
+ filename?: string;
29
+ }
30
+ export interface FileDeliveryConfig {
31
+ /** Skip a single file larger than this many bytes. Default 8 MiB. */
32
+ maxBytesPerFile?: number;
33
+ /** Process at most this many media per message. Default 5. */
34
+ maxFiles?: number;
35
+ /** Truncate decoded text files to this many bytes. Default 200 KiB. */
36
+ maxTextBytes?: number;
37
+ }
38
+ /** Download inbound media via the client and build AG-UI content parts. */
39
+ export declare function buildFileContentParts(media: WhatsAppMediaRef[], client: Pick<WhatsAppClient, "downloadMedia">, config: FileDeliveryConfig): Promise<{
40
+ parts: AgentContentPart[];
41
+ notes: string[];
42
+ }>;
43
+ export {};
44
+ //# sourceMappingURL=download-files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-files.d.ts","sourceRoot":"","sources":["../src/download-files.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,KAAK,eAAe,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzE,8EAA8E;AAC9E,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC;AAElD,gDAAgD;AAChD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA+BD,2EAA2E;AAC3E,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,gBAAgB,EAAE,EACzB,MAAM,EAAE,IAAI,CAAC,cAAc,EAAE,eAAe,CAAC,EAC7C,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC;IAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAsCzD"}