@spectrum-ts/whatsapp-business 5.0.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License Copyright (c) 2025 Photon AI
2
+
3
+ Permission is hereby granted,
4
+ free of charge, to any person obtaining a copy of this software and associated
5
+ documentation files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use, copy, modify, merge,
7
+ publish, distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice
12
+ (including the next paragraph) shall be included in all copies or substantial
13
+ portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @spectrum-ts/whatsapp-business
2
+
3
+ WhatsApp Business provider for [spectrum-ts](https://github.com/photon-hq/spectrum-ts).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add spectrum-ts @spectrum-ts/whatsapp-business
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```ts
14
+ import { Spectrum } from "spectrum-ts";
15
+ import { whatsappBusiness } from "@spectrum-ts/whatsapp-business";
16
+
17
+ const spectrum = Spectrum({
18
+ providers: [whatsappBusiness.config({ /* ... */ })],
19
+ });
20
+ ```
21
+
22
+ See the [spectrum-ts documentation](https://photon.codes/spectrum) for the full guide.
@@ -0,0 +1,26 @@
1
+ import { WhatsAppClient } from "@photon-ai/whatsapp-business";
2
+ import { SchemaMessage } from "@spectrum-ts/core";
3
+ import z from "zod";
4
+
5
+ //#region src/types.d.ts
6
+ type WhatsAppClients = WhatsAppClient[];
7
+ declare const userSchema: z.ZodObject<{}, z.core.$strip>;
8
+ declare const spaceSchema: z.ZodObject<{
9
+ id: z.ZodString;
10
+ }, z.core.$strip>;
11
+ type WhatsAppMessage = SchemaMessage<typeof userSchema, typeof spaceSchema>;
12
+ //#endregion
13
+ //#region src/index.d.ts
14
+ declare const whatsappBusiness: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"WhatsApp Business", import("zod").ZodUnion<readonly [import("zod").ZodObject<{
15
+ accessToken: import("zod").ZodString;
16
+ appSecret: import("zod").ZodOptional<import("zod").ZodString>;
17
+ phoneNumberId: import("zod").ZodString;
18
+ }, import("zod/v4/core").$strip>, import("zod").ZodObject<{}, import("zod/v4/core").$strict>]>, import("zod").ZodType<object, unknown, import("zod/v4/core").$ZodTypeInternals<object, unknown>> | undefined, import("zod").ZodObject<{
19
+ id: import("zod").ZodString;
20
+ }, import("zod/v4/core").$strip>, import("zod").ZodType<object, unknown, import("zod/v4/core").$ZodTypeInternals<object, unknown>> | undefined, WhatsAppClients, {
21
+ id: string;
22
+ }, {
23
+ id: string;
24
+ }, undefined, WhatsAppMessage, undefined, Record<never, never>, Record<never, never>, Record<never, never>>> & Readonly<Record<never, never>>;
25
+ //#endregion
26
+ export { whatsappBusiness };
package/dist/index.js ADDED
@@ -0,0 +1,666 @@
1
+ import { TypedEventStream, button, buttons, createClient, list } from "@photon-ai/whatsapp-business";
2
+ import { UnsupportedError, cloud, definePlatform, mergeStreams, stream } from "@spectrum-ts/core";
3
+ import { asAttachment, asContact, asCustom, asPollOption, asReaction, asText, createLogger, errorAttrs } from "@spectrum-ts/core/authoring";
4
+ import { extension } from "mime-types";
5
+ import z from "zod";
6
+ //#region src/auth.ts
7
+ const log = createLogger("spectrum.whatsapp.auth");
8
+ const streamLog = createLogger("spectrum.whatsapp.stream");
9
+ const RENEWAL_RATIO = .8;
10
+ const EXPIRY_BUFFER_MS = 3e4;
11
+ const RETRY_DELAY_MS = 3e4;
12
+ const RESUBSCRIBE_BACKOFF_MS = 500;
13
+ const cloudAuthState = /* @__PURE__ */ new WeakMap();
14
+ async function createCloudClients(projectId, projectSecret) {
15
+ let tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
16
+ let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
17
+ let disposed = false;
18
+ let renewalTimer;
19
+ let refreshFailures = 0;
20
+ const lines = /* @__PURE__ */ new Map();
21
+ const buildRawClient = (phoneNumberId) => {
22
+ const accessToken = tokenData.auth[phoneNumberId];
23
+ if (!accessToken) throw new Error(`WhatsApp Business line ${phoneNumberId} missing from token response`);
24
+ return createClient({
25
+ accessToken,
26
+ appSecret: "",
27
+ phoneNumberId
28
+ });
29
+ };
30
+ const refreshTokens = async () => {
31
+ tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
32
+ tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
33
+ for (const [phoneNumberId, state] of lines) {
34
+ if (!tokenData.auth[phoneNumberId]) continue;
35
+ const old = state.current;
36
+ state.current = buildRawClient(phoneNumberId);
37
+ for (const sub of state.subscriptions) sub.swap();
38
+ await old.close().catch(() => void 0);
39
+ }
40
+ };
41
+ const onRefreshSuccess = () => {
42
+ if (refreshFailures > 0) {
43
+ log.info("whatsapp token refresh recovered", { "spectrum.whatsapp.auth.attempt": refreshFailures });
44
+ refreshFailures = 0;
45
+ }
46
+ };
47
+ const onRefreshFailure = (error) => {
48
+ refreshFailures += 1;
49
+ log.warn("whatsapp token refresh failed; retrying", {
50
+ "spectrum.whatsapp.auth.attempt": refreshFailures,
51
+ "spectrum.whatsapp.auth.retry_in_ms": RETRY_DELAY_MS,
52
+ ...errorAttrs(error)
53
+ }, error);
54
+ };
55
+ const clearRenewalTimer = () => {
56
+ if (renewalTimer !== void 0) {
57
+ clearTimeout(renewalTimer);
58
+ renewalTimer = void 0;
59
+ }
60
+ };
61
+ const scheduleRenewal = () => {
62
+ if (disposed) return;
63
+ clearRenewalTimer();
64
+ const ttlMs = tokenData.expiresIn * 1e3;
65
+ const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
66
+ renewalTimer = setTimeout(async () => {
67
+ try {
68
+ await refreshTokens();
69
+ onRefreshSuccess();
70
+ scheduleRenewal();
71
+ } catch (err) {
72
+ onRefreshFailure(err);
73
+ clearRenewalTimer();
74
+ renewalTimer = setTimeout(() => scheduleRenewal(), RETRY_DELAY_MS);
75
+ renewalTimer?.unref?.();
76
+ }
77
+ }, renewInMs);
78
+ renewalTimer?.unref?.();
79
+ };
80
+ const refreshIfNeeded = async () => {
81
+ if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
82
+ await refreshTokens();
83
+ onRefreshSuccess();
84
+ scheduleRenewal();
85
+ };
86
+ scheduleRenewal();
87
+ const clients = Object.keys(tokenData.auth).map((phoneNumberId) => {
88
+ const state = {
89
+ current: buildRawClient(phoneNumberId),
90
+ subscriptions: /* @__PURE__ */ new Set()
91
+ };
92
+ lines.set(phoneNumberId, state);
93
+ return buildClientProxy(state, refreshIfNeeded);
94
+ });
95
+ cloudAuthState.set(clients, { dispose: async () => {
96
+ disposed = true;
97
+ clearRenewalTimer();
98
+ for (const state of lines.values()) for (const sub of state.subscriptions) sub.close();
99
+ await Promise.allSettled(Array.from(lines.values()).map((s) => s.current.close()));
100
+ lines.clear();
101
+ } });
102
+ return clients;
103
+ }
104
+ async function disposeCloudAuth(clients) {
105
+ const auth = cloudAuthState.get(clients);
106
+ if (!auth) return;
107
+ await auth.dispose();
108
+ cloudAuthState.delete(clients);
109
+ }
110
+ const buildClientProxy = (state, refresh) => {
111
+ const forwarder = (pick) => new Proxy({}, { get: (_, prop) => async (...args) => {
112
+ await refresh();
113
+ const fn = pick(state.current)[prop];
114
+ return Reflect.apply(fn, pick(state.current), args);
115
+ } });
116
+ return {
117
+ events: {
118
+ fetchMissed: async (opts) => {
119
+ await refresh();
120
+ return state.current.events.fetchMissed(opts);
121
+ },
122
+ subscribe: (options) => resubscribableStream(state, options)
123
+ },
124
+ media: forwarder((c) => c.media),
125
+ messages: forwarder((c) => c.messages),
126
+ close: async () => {
127
+ for (const sub of state.subscriptions) sub.close();
128
+ await state.current.close();
129
+ },
130
+ [Symbol.asyncDispose]: async () => {
131
+ for (const sub of state.subscriptions) sub.close();
132
+ await state.current.close();
133
+ }
134
+ };
135
+ };
136
+ const pumpOnce = async (ctx) => {
137
+ const sub = ctx.getCurrent().events.subscribe(ctx.options);
138
+ ctx.setActive(sub);
139
+ try {
140
+ for await (const event of sub) await ctx.emit(event);
141
+ return true;
142
+ } catch (error) {
143
+ streamLog.warn("whatsapp event stream interrupted; resubscribing", {
144
+ "spectrum.whatsapp.resubscribe_in_ms": RESUBSCRIBE_BACKOFF_MS,
145
+ ...errorAttrs(error)
146
+ }, error);
147
+ return false;
148
+ } finally {
149
+ ctx.setActive(void 0);
150
+ }
151
+ };
152
+ const resubscribableStream = (state, options) => {
153
+ let closed = false;
154
+ let active;
155
+ const source = stream((emit, end) => {
156
+ const ctx = {
157
+ emit,
158
+ getCurrent: () => state.current,
159
+ options,
160
+ setActive: (s) => {
161
+ active = s;
162
+ }
163
+ };
164
+ const pump = (async () => {
165
+ while (!closed) {
166
+ await pumpOnce(ctx);
167
+ if (!closed) await new Promise((r) => setTimeout(r, RESUBSCRIBE_BACKOFF_MS));
168
+ }
169
+ end();
170
+ })();
171
+ return async () => {
172
+ closed = true;
173
+ active?.close().catch(() => void 0);
174
+ active = void 0;
175
+ state.subscriptions.delete(subscription);
176
+ await pump;
177
+ };
178
+ });
179
+ const subscription = {
180
+ close: () => {
181
+ closed = true;
182
+ active?.close().catch(() => void 0);
183
+ },
184
+ swap: () => {
185
+ active?.close().catch(() => void 0);
186
+ }
187
+ };
188
+ state.subscriptions.add(subscription);
189
+ return new TypedEventStream(source, async () => {
190
+ closed = true;
191
+ active?.close().catch(() => void 0);
192
+ state.subscriptions.delete(subscription);
193
+ await source.close();
194
+ });
195
+ };
196
+ //#endregion
197
+ //#region src/poll.ts
198
+ const MAX_BUTTON_OPTIONS = 3;
199
+ const LIST_BUTTON_TEXT = "View options";
200
+ const LIST_SECTION_TITLE = "Options";
201
+ const pollOptionId = (index) => `opt_${index}`;
202
+ const pollToInteractive = (content) => {
203
+ if (content.options.length <= MAX_BUTTON_OPTIONS) return buttons(content.title, ...content.options.map((o, i) => button(pollOptionId(i), o.title)));
204
+ return list(content.title, LIST_BUTTON_TEXT).section(LIST_SECTION_TITLE, content.options.map((o, i) => ({
205
+ id: pollOptionId(i),
206
+ title: o.title
207
+ })));
208
+ };
209
+ //#endregion
210
+ //#region src/messages.ts
211
+ const primary = (clients) => {
212
+ const client = clients[0];
213
+ if (!client) throw new Error("No WhatsApp Business client available");
214
+ return client;
215
+ };
216
+ const toRecord = (result, spaceId, content) => ({
217
+ id: result.messageId,
218
+ content,
219
+ space: { id: spaceId },
220
+ timestamp: /* @__PURE__ */ new Date()
221
+ });
222
+ const MAX_POLL_CACHE_SIZE = 1e3;
223
+ const OPTION_ID_PREFIX = "opt_";
224
+ const pollCaches = /* @__PURE__ */ new WeakMap();
225
+ const getPollCache = (client) => {
226
+ let cache = pollCaches.get(client);
227
+ if (!cache) {
228
+ cache = /* @__PURE__ */ new Map();
229
+ pollCaches.set(client, cache);
230
+ }
231
+ return cache;
232
+ };
233
+ const cachePoll = (client, messageId, poll) => {
234
+ const cache = getPollCache(client);
235
+ if (cache.has(messageId)) cache.delete(messageId);
236
+ cache.set(messageId, poll);
237
+ if (cache.size > MAX_POLL_CACHE_SIZE) {
238
+ const first = cache.keys().next().value;
239
+ if (first !== void 0) cache.delete(first);
240
+ }
241
+ };
242
+ const optionIndexFromId = (id) => {
243
+ if (!id.startsWith(OPTION_ID_PREFIX)) return;
244
+ const index = Number(id.slice(4));
245
+ if (!Number.isInteger(index) || index < 0 || pollOptionId(index) !== id) return;
246
+ return index;
247
+ };
248
+ const mapWaPhoneType = (type) => {
249
+ if (!type) return;
250
+ const upper = type.toUpperCase();
251
+ if (upper === "CELL" || upper === "MOBILE" || upper === "IPHONE") return "mobile";
252
+ if (upper === "HOME") return "home";
253
+ if (upper === "WORK" || upper === "BUSINESS") return "work";
254
+ return "other";
255
+ };
256
+ const mapWaSimpleType = (type) => {
257
+ if (!type) return;
258
+ const upper = type.toUpperCase();
259
+ if (upper === "HOME") return "home";
260
+ if (upper === "WORK" || upper === "BUSINESS") return "work";
261
+ return "other";
262
+ };
263
+ const waNameToSpectrum = (name) => {
264
+ const result = { formatted: name.formattedName };
265
+ if (name.firstName) result.first = name.firstName;
266
+ if (name.lastName) result.last = name.lastName;
267
+ if (name.middleName) result.middle = name.middleName;
268
+ if (name.prefix) result.prefix = name.prefix;
269
+ if (name.suffix) result.suffix = name.suffix;
270
+ return result;
271
+ };
272
+ const waPhoneToSpectrum = (phone) => {
273
+ const entry = { value: phone.phone };
274
+ const type = mapWaPhoneType(phone.type);
275
+ if (type) entry.type = type;
276
+ return entry;
277
+ };
278
+ const waEmailToSpectrum = (email) => {
279
+ const entry = { value: email.email };
280
+ const type = mapWaSimpleType(email.type);
281
+ if (type) entry.type = type;
282
+ return entry;
283
+ };
284
+ const waAddressToSpectrum = (address) => {
285
+ const entry = {};
286
+ if (address.street) entry.street = address.street;
287
+ if (address.city) entry.city = address.city;
288
+ if (address.state) entry.region = address.state;
289
+ if (address.zip) entry.postalCode = address.zip;
290
+ if (address.country) entry.country = address.country;
291
+ const type = mapWaSimpleType(address.type);
292
+ if (type) entry.type = type;
293
+ return entry;
294
+ };
295
+ const waOrgToSpectrum = (org) => {
296
+ const entry = {};
297
+ if (org.company) entry.name = org.company;
298
+ if (org.title) entry.title = org.title;
299
+ if (org.department) entry.department = org.department;
300
+ return entry;
301
+ };
302
+ const waContactToSpectrum = (card) => {
303
+ const input = { raw: card };
304
+ input.name = waNameToSpectrum(card.name);
305
+ if (card.phones.length > 0) input.phones = card.phones.map(waPhoneToSpectrum);
306
+ if (card.emails.length > 0) input.emails = card.emails.map(waEmailToSpectrum);
307
+ if (card.addresses.length > 0) input.addresses = card.addresses.map(waAddressToSpectrum);
308
+ if (card.org) input.org = waOrgToSpectrum(card.org);
309
+ if (card.urls.length > 0) input.urls = card.urls.map((u) => u.url);
310
+ if (card.birthday) input.birthday = card.birthday;
311
+ return asContact(input);
312
+ };
313
+ const toMessages = (client, msg) => {
314
+ const base = {
315
+ sender: { id: msg.from },
316
+ space: { id: msg.from },
317
+ timestamp: msg.timestamp
318
+ };
319
+ if (msg.content.type === "contacts") {
320
+ const multi = msg.content.contacts.length > 1;
321
+ return msg.content.contacts.map((card, index) => ({
322
+ ...base,
323
+ id: multi ? `${msg.id}:${index}` : msg.id,
324
+ content: waContactToSpectrum(card)
325
+ }));
326
+ }
327
+ return [{
328
+ ...base,
329
+ id: msg.id,
330
+ content: mapContent(client, msg)
331
+ }];
332
+ };
333
+ const mapContent = (client, msg) => {
334
+ const { content } = msg;
335
+ switch (content.type) {
336
+ case "text": return asText(content.body);
337
+ case "image":
338
+ case "video":
339
+ case "audio":
340
+ case "document": return lazyMedia(client, content.media);
341
+ case "sticker": return asCustom({
342
+ whatsapp_type: "sticker",
343
+ ...content.sticker
344
+ });
345
+ case "location": return asCustom({
346
+ whatsapp_type: "location",
347
+ ...content.location
348
+ });
349
+ case "reaction": {
350
+ const stubTarget = {
351
+ id: content.reaction.messageId,
352
+ content: asCustom({
353
+ whatsapp_type: "reaction-target",
354
+ stub: true
355
+ })
356
+ };
357
+ return asReaction({
358
+ emoji: content.reaction.emoji,
359
+ target: stubTarget
360
+ });
361
+ }
362
+ case "interactive": {
363
+ const inter = content.interactive;
364
+ if (inter.type === "button_reply" || inter.type === "list_reply") {
365
+ const poll = msg.context?.id === void 0 ? void 0 : getPollCache(client).get(msg.context.id);
366
+ const optionIndex = optionIndexFromId(inter.reply.id);
367
+ const option = optionIndex === void 0 ? void 0 : poll?.options[optionIndex];
368
+ if (poll && option) return asPollOption({
369
+ poll,
370
+ option,
371
+ selected: true
372
+ });
373
+ }
374
+ return asCustom({
375
+ whatsapp_type: "interactive",
376
+ ...inter
377
+ });
378
+ }
379
+ case "button": return asCustom({
380
+ whatsapp_type: "button",
381
+ ...content.button
382
+ });
383
+ case "order": return asCustom({
384
+ whatsapp_type: "order",
385
+ ...content.order
386
+ });
387
+ case "system": return asCustom({
388
+ whatsapp_type: "system",
389
+ ...content.system
390
+ });
391
+ default: return asCustom({ whatsapp_type: "unknown" });
392
+ }
393
+ };
394
+ const fetchMedia = async (client, mediaId) => {
395
+ const { url } = await client.media.getUrl(mediaId);
396
+ const response = await fetch(url);
397
+ if (!response.ok) throw new Error(`Media download failed: ${response.status}`);
398
+ return response;
399
+ };
400
+ const lazyMedia = (client, media) => asAttachment({
401
+ id: media.id,
402
+ name: media.filename ?? `media-${media.id}`,
403
+ mimeType: media.mimeType,
404
+ read: async () => Buffer.from(await (await fetchMedia(client, media.id)).arrayBuffer()),
405
+ stream: async () => {
406
+ const response = await fetchMedia(client, media.id);
407
+ if (!response.body) throw new Error("Media response missing body");
408
+ return response.body;
409
+ }
410
+ });
411
+ const mimeToMediaType = (mimeType) => {
412
+ if (mimeType.startsWith("image/")) return "image";
413
+ if (mimeType.startsWith("video/")) return "video";
414
+ if (mimeType.startsWith("audio/")) return "audio";
415
+ return "document";
416
+ };
417
+ const voiceFilename = (content) => {
418
+ if (content.name) return content.name;
419
+ const ext = extension(content.mimeType);
420
+ return ext ? `voice.${ext}` : "voice";
421
+ };
422
+ const spectrumPhoneTypeToWa = (type) => {
423
+ if (type === "mobile") return "CELL";
424
+ if (type === "home" || type === "work" || type === "other") return type.toUpperCase();
425
+ };
426
+ const spectrumSimpleTypeToWa = (type) => type ? type.toUpperCase() : void 0;
427
+ const spectrumNameToWa = (name) => ({
428
+ formattedName: name?.formatted ?? ([
429
+ name?.first,
430
+ name?.middle,
431
+ name?.last
432
+ ].filter((p) => Boolean(p)).join(" ") || "Unknown"),
433
+ firstName: name?.first,
434
+ lastName: name?.last,
435
+ middleName: name?.middle,
436
+ prefix: name?.prefix,
437
+ suffix: name?.suffix
438
+ });
439
+ const isWhatsAppContactCard = (value) => {
440
+ if (!value || typeof value !== "object") return false;
441
+ const raw = value;
442
+ const name = raw.name;
443
+ if (!name || typeof name !== "object" || typeof name.formattedName !== "string") return false;
444
+ return Array.isArray(raw.phones) && Array.isArray(raw.emails) && Array.isArray(raw.addresses) && Array.isArray(raw.urls);
445
+ };
446
+ const contactToWa = (contact) => {
447
+ if (isWhatsAppContactCard(contact.raw)) return contact.raw;
448
+ return {
449
+ name: spectrumNameToWa(contact.name),
450
+ phones: (contact.phones ?? []).map((p) => ({
451
+ phone: p.value,
452
+ type: spectrumPhoneTypeToWa(p.type)
453
+ })),
454
+ emails: (contact.emails ?? []).map((e) => ({
455
+ email: e.value,
456
+ type: spectrumSimpleTypeToWa(e.type)
457
+ })),
458
+ addresses: (contact.addresses ?? []).map((a) => ({
459
+ street: a.street,
460
+ city: a.city,
461
+ state: a.region,
462
+ zip: a.postalCode,
463
+ country: a.country,
464
+ type: spectrumSimpleTypeToWa(a.type)
465
+ })),
466
+ urls: (contact.urls ?? []).map((url) => ({ url })),
467
+ org: contact.org?.name || contact.org?.department || contact.org?.title ? {
468
+ company: contact.org.name,
469
+ department: contact.org.department,
470
+ title: contact.org.title
471
+ } : void 0,
472
+ birthday: contact.birthday
473
+ };
474
+ };
475
+ const clientStream = (client) => {
476
+ const eventStream = client.events.subscribe().filter((e) => e.type === "message");
477
+ return stream((emit, end) => {
478
+ const pump = (async () => {
479
+ try {
480
+ for await (const event of eventStream) for (const m of toMessages(client, event.message)) await emit(m);
481
+ end();
482
+ } catch (e) {
483
+ end(e);
484
+ }
485
+ })();
486
+ return async () => {
487
+ await eventStream.close();
488
+ await pump;
489
+ };
490
+ });
491
+ };
492
+ const messages = (clients) => mergeStreams(clients.map(clientStream));
493
+ const send = async (clients, spaceId, content) => {
494
+ if (content.type === "reply") return await replyToMessage(clients, spaceId, content.target.id, content.content);
495
+ if (content.type === "reaction") return await reactToMessage(clients, spaceId, content);
496
+ if (content.type === "typing") return;
497
+ if (content.type === "read") {
498
+ await primary(clients).messages.markRead(content.target.id);
499
+ return;
500
+ }
501
+ const client = primary(clients);
502
+ switch (content.type) {
503
+ case "text": return toRecord(await client.messages.send({
504
+ to: spaceId,
505
+ text: content.text
506
+ }), spaceId, content);
507
+ case "attachment": {
508
+ const { mediaId } = await client.media.upload({
509
+ file: await content.read(),
510
+ mimeType: content.mimeType,
511
+ filename: content.name
512
+ });
513
+ const mediaType = mimeToMediaType(content.mimeType);
514
+ const mediaPayload = mediaType === "document" ? {
515
+ id: mediaId,
516
+ filename: content.name
517
+ } : { id: mediaId };
518
+ return toRecord(await client.messages.send({
519
+ to: spaceId,
520
+ [mediaType]: mediaPayload
521
+ }), spaceId, content);
522
+ }
523
+ case "contact": return toRecord(await client.messages.send({
524
+ to: spaceId,
525
+ contacts: [contactToWa(content)]
526
+ }), spaceId, content);
527
+ case "voice": {
528
+ const { mediaId } = await client.media.upload({
529
+ file: await content.read(),
530
+ mimeType: content.mimeType,
531
+ filename: voiceFilename(content)
532
+ });
533
+ return toRecord(await client.messages.send({
534
+ to: spaceId,
535
+ audio: { id: mediaId }
536
+ }), spaceId, content);
537
+ }
538
+ case "poll": {
539
+ const result = await client.messages.send({
540
+ to: spaceId,
541
+ interactive: pollToInteractive(content)
542
+ });
543
+ cachePoll(client, result.messageId, content);
544
+ return toRecord(result, spaceId, content);
545
+ }
546
+ case "app": return toRecord(await client.messages.send({
547
+ to: spaceId,
548
+ text: await content.url()
549
+ }), spaceId, content);
550
+ default: throw UnsupportedError.content(content.type);
551
+ }
552
+ };
553
+ const reactToMessage = async (clients, spaceId, content) => {
554
+ return toRecord(await primary(clients).messages.send({
555
+ to: spaceId,
556
+ reaction: {
557
+ messageId: content.target.id,
558
+ emoji: content.emoji
559
+ }
560
+ }), spaceId, content);
561
+ };
562
+ const replyToMessage = async (clients, spaceId, messageId, content) => {
563
+ const client = primary(clients);
564
+ switch (content.type) {
565
+ case "text": return toRecord(await client.messages.send({
566
+ to: spaceId,
567
+ replyTo: messageId,
568
+ text: content.text
569
+ }), spaceId, content);
570
+ case "attachment": {
571
+ const { mediaId } = await client.media.upload({
572
+ file: await content.read(),
573
+ mimeType: content.mimeType,
574
+ filename: content.name
575
+ });
576
+ const mediaType = mimeToMediaType(content.mimeType);
577
+ const mediaPayload = mediaType === "document" ? {
578
+ id: mediaId,
579
+ filename: content.name
580
+ } : { id: mediaId };
581
+ return toRecord(await client.messages.send({
582
+ to: spaceId,
583
+ replyTo: messageId,
584
+ [mediaType]: mediaPayload
585
+ }), spaceId, content);
586
+ }
587
+ case "contact": return toRecord(await client.messages.send({
588
+ to: spaceId,
589
+ replyTo: messageId,
590
+ contacts: [contactToWa(content)]
591
+ }), spaceId, content);
592
+ case "voice": {
593
+ const { mediaId } = await client.media.upload({
594
+ file: await content.read(),
595
+ mimeType: content.mimeType,
596
+ filename: voiceFilename(content)
597
+ });
598
+ return toRecord(await client.messages.send({
599
+ to: spaceId,
600
+ replyTo: messageId,
601
+ audio: { id: mediaId }
602
+ }), spaceId, content);
603
+ }
604
+ case "poll": {
605
+ const result = await client.messages.send({
606
+ to: spaceId,
607
+ replyTo: messageId,
608
+ interactive: pollToInteractive(content)
609
+ });
610
+ cachePoll(client, result.messageId, content);
611
+ return toRecord(result, spaceId, content);
612
+ }
613
+ case "app": return toRecord(await client.messages.send({
614
+ to: spaceId,
615
+ replyTo: messageId,
616
+ text: await content.url()
617
+ }), spaceId, content);
618
+ default: throw UnsupportedError.content(content.type);
619
+ }
620
+ };
621
+ //#endregion
622
+ //#region src/types.ts
623
+ const directConfig = z.object({
624
+ accessToken: z.string().min(1),
625
+ appSecret: z.string().optional(),
626
+ phoneNumberId: z.string().min(1)
627
+ });
628
+ const cloudConfig = z.object({}).strict();
629
+ const configSchema = z.union([directConfig, cloudConfig]);
630
+ const isCloudConfig = (config) => !("accessToken" in config);
631
+ z.object({});
632
+ //#endregion
633
+ //#region src/index.ts
634
+ const whatsappBusiness = definePlatform("WhatsApp Business", {
635
+ config: configSchema,
636
+ lifecycle: {
637
+ createClient: async ({ config, projectId, projectSecret }) => {
638
+ if (!isCloudConfig(config)) return [createClient({
639
+ accessToken: config.accessToken,
640
+ appSecret: config.appSecret ?? "",
641
+ phoneNumberId: config.phoneNumberId
642
+ })];
643
+ if (!(projectId && projectSecret)) throw new Error("WhatsApp Business cloud mode requires projectId and projectSecret. Either pass credentials to Spectrum(), or provide direct credentials: whatsappBusiness.config({ accessToken, phoneNumberId })");
644
+ return await createCloudClients(projectId, projectSecret);
645
+ },
646
+ destroyClient: async ({ client }) => {
647
+ await disposeCloudAuth(client);
648
+ await Promise.allSettled(client.map((c) => c.close()));
649
+ }
650
+ },
651
+ user: { resolve: async ({ input }) => ({ id: input.userID }) },
652
+ space: {
653
+ schema: z.object({ id: z.string() }),
654
+ create: async ({ input }) => {
655
+ if (input.users.length === 0) throw new Error("WhatsApp space creation requires at least one user");
656
+ if (input.users.length > 1) throw UnsupportedError.action("space.create", "WhatsApp Business", "only 1:1 conversations are supported");
657
+ const user = input.users[0];
658
+ if (!user) throw new Error("WhatsApp space creation requires a user");
659
+ return { id: user.id };
660
+ }
661
+ },
662
+ messages: ({ client }) => messages(client),
663
+ send: async ({ space, content, client }) => await send(client, space.id, content)
664
+ });
665
+ //#endregion
666
+ export { whatsappBusiness };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@spectrum-ts/whatsapp-business",
3
+ "version": "5.0.0",
4
+ "description": "WhatsApp Business provider for spectrum-ts.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/photon-hq/spectrum-ts.git",
8
+ "directory": "packages/whatsapp-business"
9
+ },
10
+ "homepage": "https://photon.codes/spectrum",
11
+ "bugs": {
12
+ "url": "https://github.com/photon-hq/spectrum-ts/issues"
13
+ },
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "spectrum": {
29
+ "key": "whatsapp-business",
30
+ "import": "whatsappBusiness",
31
+ "label": "WhatsApp Business"
32
+ },
33
+ "dependencies": {
34
+ "@photon-ai/whatsapp-business": "^0.1.1",
35
+ "mime-types": "^3.0.1",
36
+ "zod": "^4.2.1"
37
+ },
38
+ "peerDependencies": {
39
+ "@spectrum-ts/core": "^5.0.0",
40
+ "typescript": "^5 || ^6.0.0"
41
+ },
42
+ "license": "MIT"
43
+ }