@spectrum-ts/whatsapp-business 11.2.0 → 12.1.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/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { WhatsAppClient } from "@photon-ai/whatsapp-business";
2
- import { SchemaMessage } from "@spectrum-ts/core";
2
+ import { ContentBuilder, SchemaMessage } from "@spectrum-ts/core";
3
+ import { ProviderMessageRecord } from "@spectrum-ts/core/authoring";
3
4
  import z from "zod";
4
5
 
5
6
  //#region src/types.d.ts
@@ -10,8 +11,49 @@ declare const spaceSchema: z.ZodObject<{
10
11
  }, z.core.$strip>;
11
12
  type WhatsAppMessage = SchemaMessage<typeof userSchema, typeof spaceSchema>;
12
13
  //#endregion
14
+ //#region src/content/template.d.ts
15
+ /**
16
+ * WhatsApp-only template message content. Lives entirely under the WhatsApp
17
+ * Business provider — never enters the universal `Content` discriminated
18
+ * union. The framework recognizes it via the generic content-level platform
19
+ * contract: `__platform: "whatsapp_business"` lets
20
+ * `findUnsupportedPlatformContent` warn-and-skip when a different platform
21
+ * receives it.
22
+ *
23
+ * Templates are the only message type Meta accepts outside the 24-hour
24
+ * customer-service window (free-form sends fail with error 131047), so this
25
+ * is the escape hatch for re-engaging stale conversations. v1 covers the
26
+ * common case: a pre-approved template addressed by name + language, with
27
+ * positional `{{1}}..{{n}}` body text parameters. Header/button components
28
+ * can be added later without breaking this shape.
29
+ */
30
+ declare const whatsappTemplateSchema: z.ZodObject<{
31
+ type: z.ZodLiteral<"whatsapp-template">;
32
+ __platform: z.ZodLiteral<"whatsapp_business">;
33
+ name: z.ZodString;
34
+ languageCode: z.ZodString;
35
+ bodyParams: z.ZodOptional<z.ZodArray<z.ZodString>>;
36
+ }, z.core.$strip>;
37
+ type WhatsAppTemplate = z.infer<typeof whatsappTemplateSchema>;
38
+ type WhatsAppTemplateInput = Omit<WhatsAppTemplate, "type" | "__platform">;
39
+ declare const isWhatsAppTemplate: (value: unknown) => value is WhatsAppTemplate;
40
+ declare const asWhatsAppTemplate: (input: WhatsAppTemplateInput) => WhatsAppTemplate;
41
+ declare function whatsappTemplate(input: WhatsAppTemplateInput): ContentBuilder;
42
+ //#endregion
43
+ //#region src/errors/partial-send.d.ts
44
+ declare class WhatsAppPartialSendError extends Error {
45
+ readonly sent: ProviderMessageRecord[];
46
+ readonly failedIndex: number;
47
+ constructor(input: {
48
+ sent: ProviderMessageRecord[];
49
+ failedIndex: number;
50
+ total: number;
51
+ cause: unknown;
52
+ });
53
+ }
54
+ //#endregion
13
55
  //#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<{
56
+ declare const whatsappBusiness: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"whatsapp_business", import("zod").ZodUnion<readonly [import("zod").ZodObject<{
15
57
  accessToken: import("zod").ZodString;
16
58
  appSecret: import("zod").ZodOptional<import("zod").ZodString>;
17
59
  phoneNumberId: import("zod").ZodString;
@@ -23,4 +65,4 @@ declare const whatsappBusiness: import("@spectrum-ts/core").Platform<import("@sp
23
65
  id: string;
24
66
  }, undefined, WhatsAppMessage, undefined, Record<never, never>, Record<never, never>, Record<never, never>>> & Readonly<Record<never, never>>;
25
67
  //#endregion
26
- export { whatsappBusiness };
68
+ export { WhatsAppPartialSendError, type WhatsAppTemplate, type WhatsAppTemplateInput, asWhatsAppTemplate, isWhatsAppTemplate, whatsappBusiness, whatsappTemplate, whatsappTemplateSchema };
package/dist/index.js CHANGED
@@ -1,24 +1,15 @@
1
1
  import { TypedEventStream, button, buttons, createClient, list } from "@photon-ai/whatsapp-business";
2
2
  import { UnsupportedError, cloud, definePlatform, mergeStreams, stream } from "@spectrum-ts/core";
3
- import { asAttachment, asContact, asCustom, asGroup, asPollOption, asReaction, asText, asUnsend, createLogger, errorAttrs, tracedFetch } from "@spectrum-ts/core/authoring";
3
+ import { asAttachment, asContact, asCustom, asGroup, asPollOption, asReaction, asReply, asText, asUnsend, asVoice, createLogger, createTokenRenewal, errorAttrs, tracedFetch } from "@spectrum-ts/core/authoring";
4
4
  import { extension } from "mime-types";
5
5
  import z from "zod";
6
6
  //#region src/auth.ts
7
- const log = createLogger("spectrum.whatsapp.auth");
8
7
  const streamLog$1 = createLogger("spectrum.whatsapp.stream");
9
- const RENEWAL_RATIO = .8;
10
- const EXPIRY_BUFFER_MS = 3e4;
11
- const RETRY_DELAY_MS = 3e4;
12
8
  const RESUBSCRIBE_BACKOFF_MS = 500;
13
9
  const ignoreCleanupError = () => void 0;
14
10
  const cloudAuthState = /* @__PURE__ */ new WeakMap();
15
11
  async function createCloudClients(projectId, projectSecret) {
16
12
  let tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
17
- let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
18
- let disposed = false;
19
- let renewalTimer;
20
- let refreshFailures = 0;
21
- let refreshInFlight;
22
13
  const lines = /* @__PURE__ */ new Map();
23
14
  const buildRawClient = (phoneNumberId) => {
24
15
  const accessToken = tokenData.auth[phoneNumberId];
@@ -31,7 +22,6 @@ async function createCloudClients(projectId, projectSecret) {
31
22
  };
32
23
  const refreshTokens = async () => {
33
24
  tokenData = await cloud.issueWhatsappBusinessTokens(projectId, projectSecret);
34
- tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
35
25
  for (const [phoneNumberId, state] of lines) {
36
26
  if (!tokenData.auth[phoneNumberId]) continue;
37
27
  const old = state.current;
@@ -40,69 +30,21 @@ async function createCloudClients(projectId, projectSecret) {
40
30
  await old.close().catch(() => void 0);
41
31
  }
42
32
  };
43
- const onRefreshSuccess = () => {
44
- if (refreshFailures > 0) {
45
- log.info("whatsapp token refresh recovered", { "spectrum.whatsapp.auth.attempt": refreshFailures });
46
- refreshFailures = 0;
47
- }
48
- };
49
- const onRefreshFailure = (error) => {
50
- refreshFailures += 1;
51
- log.warn("whatsapp token refresh failed; retrying", {
52
- "spectrum.whatsapp.auth.attempt": refreshFailures,
53
- "spectrum.whatsapp.auth.retry_in_ms": RETRY_DELAY_MS,
54
- ...errorAttrs(error)
55
- }, error);
56
- };
57
- const clearRenewalTimer = () => {
58
- if (renewalTimer !== void 0) {
59
- clearTimeout(renewalTimer);
60
- renewalTimer = void 0;
61
- }
62
- };
63
- const refreshNow = async () => {
64
- await refreshTokens();
65
- onRefreshSuccess();
66
- scheduleRenewal();
67
- };
68
- const coalescedRefresh = () => {
69
- if (!refreshInFlight) refreshInFlight = refreshNow().finally(() => {
70
- refreshInFlight = void 0;
71
- });
72
- return refreshInFlight;
73
- };
74
- const scheduleRenewal = () => {
75
- if (disposed) return;
76
- clearRenewalTimer();
77
- const ttlMs = tokenData.expiresIn * 1e3;
78
- const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
79
- const runScheduledRefresh = () => {
80
- coalescedRefresh().catch((err) => {
81
- onRefreshFailure(err);
82
- if (disposed) return;
83
- renewalTimer = setTimeout(runScheduledRefresh, RETRY_DELAY_MS);
84
- renewalTimer?.unref?.();
85
- });
86
- };
87
- renewalTimer = setTimeout(runScheduledRefresh, renewInMs);
88
- renewalTimer?.unref?.();
89
- };
90
- const refreshIfNeeded = async () => {
91
- if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) return;
92
- await coalescedRefresh();
93
- };
94
- scheduleRenewal();
33
+ const renewal = createTokenRenewal({
34
+ expiresInSeconds: () => tokenData.expiresIn,
35
+ name: "whatsapp",
36
+ refresh: refreshTokens
37
+ });
95
38
  const clients = Object.keys(tokenData.auth).map((phoneNumberId) => {
96
39
  const state = {
97
40
  current: buildRawClient(phoneNumberId),
98
41
  subscriptions: /* @__PURE__ */ new Set()
99
42
  };
100
43
  lines.set(phoneNumberId, state);
101
- return buildClientProxy(state, refreshIfNeeded);
44
+ return buildClientProxy(state, renewal.refreshIfNeeded);
102
45
  });
103
46
  cloudAuthState.set(clients, { dispose: async () => {
104
- disposed = true;
105
- clearRenewalTimer();
47
+ renewal.dispose();
106
48
  for (const state of lines.values()) for (const sub of state.subscriptions) sub.close();
107
49
  await Promise.allSettled(Array.from(lines.values()).map((s) => s.current.close()));
108
50
  lines.clear();
@@ -251,6 +193,51 @@ const resubscribableStream = (state, options) => {
251
193
  });
252
194
  };
253
195
  //#endregion
196
+ //#region src/content/template.ts
197
+ /**
198
+ * WhatsApp-only template message content. Lives entirely under the WhatsApp
199
+ * Business provider — never enters the universal `Content` discriminated
200
+ * union. The framework recognizes it via the generic content-level platform
201
+ * contract: `__platform: "whatsapp_business"` lets
202
+ * `findUnsupportedPlatformContent` warn-and-skip when a different platform
203
+ * receives it.
204
+ *
205
+ * Templates are the only message type Meta accepts outside the 24-hour
206
+ * customer-service window (free-form sends fail with error 131047), so this
207
+ * is the escape hatch for re-engaging stale conversations. v1 covers the
208
+ * common case: a pre-approved template addressed by name + language, with
209
+ * positional `{{1}}..{{n}}` body text parameters. Header/button components
210
+ * can be added later without breaking this shape.
211
+ */
212
+ const whatsappTemplateSchema = z.object({
213
+ type: z.literal("whatsapp-template"),
214
+ __platform: z.literal("whatsapp_business"),
215
+ name: z.string().min(1),
216
+ languageCode: z.string().min(1),
217
+ bodyParams: z.array(z.string()).optional()
218
+ });
219
+ const isWhatsAppTemplate = (value) => whatsappTemplateSchema.safeParse(value).success;
220
+ const asWhatsAppTemplate = (input) => whatsappTemplateSchema.parse({
221
+ type: "whatsapp-template",
222
+ __platform: "whatsapp_business",
223
+ ...input
224
+ });
225
+ function whatsappTemplate(input) {
226
+ return { build: async () => asWhatsAppTemplate(input) };
227
+ }
228
+ //#endregion
229
+ //#region src/errors/partial-send.ts
230
+ var WhatsAppPartialSendError = class extends Error {
231
+ sent;
232
+ failedIndex;
233
+ constructor(input) {
234
+ super(`WhatsApp group send failed at part ${input.failedIndex + 1}/${input.total}; ${input.sent.length} earlier part(s) were already delivered and cannot be retracted`, { cause: input.cause });
235
+ this.name = "WhatsAppPartialSendError";
236
+ this.sent = input.sent;
237
+ this.failedIndex = input.failedIndex;
238
+ }
239
+ };
240
+ //#endregion
254
241
  //#region src/poll.ts
255
242
  const MAX_BUTTON_OPTIONS = 3;
256
243
  const LIST_BUTTON_TEXT = "View options";
@@ -321,6 +308,26 @@ const reactionTargetStub = (reactedId) => ({
321
308
  stub: true
322
309
  })
323
310
  });
311
+ const replyTargetStub = (quotedId) => ({
312
+ id: quotedId,
313
+ content: asCustom({
314
+ whatsapp_type: "reply-target",
315
+ stub: true
316
+ })
317
+ });
318
+ const REPLY_EXEMPT_TYPES = new Set([
319
+ "interactive",
320
+ "button",
321
+ "reaction"
322
+ ]);
323
+ const withReplyContext = (msg, content) => {
324
+ const quotedId = msg.context?.id;
325
+ if (quotedId === void 0 || REPLY_EXEMPT_TYPES.has(msg.content.type)) return content;
326
+ return asReply({
327
+ content,
328
+ target: replyTargetStub(quotedId)
329
+ });
330
+ };
324
331
  const optionIndexFromId = (id) => {
325
332
  if (!id.startsWith(OPTION_ID_PREFIX)) return;
326
333
  const index = Number(id.slice(4));
@@ -411,13 +418,13 @@ const toMessages = (client, msg) => {
411
418
  return msg.content.contacts.map((card, index) => ({
412
419
  ...base,
413
420
  id: multi ? `${msg.id}:${index}` : msg.id,
414
- content: waContactToSpectrum(card)
421
+ content: withReplyContext(msg, waContactToSpectrum(card))
415
422
  }));
416
423
  }
417
424
  return [{
418
425
  ...base,
419
426
  id: msg.id,
420
- content: mapContent(client, msg)
427
+ content: withReplyContext(msg, mapContent(client, msg))
421
428
  }];
422
429
  };
423
430
  const mapReactionContent = (client, msg, reaction) => {
@@ -456,7 +463,7 @@ const mapContent = (client, msg) => {
456
463
  case "video":
457
464
  case "audio":
458
465
  case "document": {
459
- const media = lazyMedia(client, content.media);
466
+ const media = content.type === "audio" && content.media.voice ? lazyVoice(client, content.media) : lazyMedia(client, content.media);
460
467
  const caption = content.media.caption?.trim();
461
468
  if (!caption) return media;
462
469
  return asGroup({ items: [groupItem(msg, 0, media), groupItem(msg, 1, asText(caption))] });
@@ -518,7 +525,7 @@ const fetchMedia = async (client, mediaId) => {
518
525
  if (!response.ok) throw new Error(`Media download failed: ${response.status}`);
519
526
  return response;
520
527
  };
521
- const lazyMedia = (client, media) => asAttachment({
528
+ const lazyMediaSource = (client, media) => ({
522
529
  id: media.id,
523
530
  name: media.filename ?? `media-${media.id}`,
524
531
  mimeType: media.mimeType,
@@ -529,6 +536,8 @@ const lazyMedia = (client, media) => asAttachment({
529
536
  return response.body;
530
537
  }
531
538
  });
539
+ const lazyMedia = (client, media) => asAttachment(lazyMediaSource(client, media));
540
+ const lazyVoice = (client, media) => asVoice(lazyMediaSource(client, media));
532
541
  const mimeToMediaType = (mimeType) => {
533
542
  if (mimeType.startsWith("image/")) return "image";
534
543
  if (mimeType.startsWith("video/")) return "video";
@@ -626,7 +635,83 @@ const clientStream = (client) => {
626
635
  });
627
636
  };
628
637
  const messages = (clients) => mergeStreams(clients.map(clientStream));
638
+ const MAX_CAPTION_LENGTH = 1024;
639
+ const captionablePair = (group) => {
640
+ if (group.items.length !== 2) return;
641
+ const contents = group.items.map((item) => item.content);
642
+ const attachment = contents.find((c) => c.type === "attachment");
643
+ const text = contents.find((c) => c.type === "text");
644
+ return attachment && text && mimeToMediaType(attachment.mimeType) !== "audio" && text.text.length <= MAX_CAPTION_LENGTH ? {
645
+ attachment,
646
+ caption: text.text
647
+ } : void 0;
648
+ };
649
+ const sendGroupParts = async (clients, spaceId, group, replyTo) => {
650
+ const pair = captionablePair(group);
651
+ if (pair) {
652
+ const client = primary(clients);
653
+ const { attachment, caption } = pair;
654
+ const { mediaId } = await client.media.upload({
655
+ file: await attachment.read(),
656
+ mimeType: attachment.mimeType,
657
+ filename: attachment.name
658
+ });
659
+ const mediaType = mimeToMediaType(attachment.mimeType);
660
+ const mediaPayload = mediaType === "document" ? {
661
+ id: mediaId,
662
+ filename: attachment.name,
663
+ caption
664
+ } : {
665
+ id: mediaId,
666
+ caption
667
+ };
668
+ return toRecord(await client.messages.send({
669
+ to: spaceId,
670
+ ...replyTo === void 0 ? {} : { replyTo },
671
+ [mediaType]: mediaPayload
672
+ }), spaceId, group);
673
+ }
674
+ const sent = [];
675
+ let first;
676
+ for (const [index, item] of group.items.entries()) {
677
+ const itemContent = item.content;
678
+ let record;
679
+ try {
680
+ record = index === 0 && replyTo !== void 0 ? await replyToMessage(clients, spaceId, replyTo, itemContent) : await send(clients, spaceId, itemContent);
681
+ } catch (error) {
682
+ if (sent.length === 0) throw error;
683
+ throw new WhatsAppPartialSendError({
684
+ sent,
685
+ failedIndex: index,
686
+ total: group.items.length,
687
+ cause: error
688
+ });
689
+ }
690
+ if (record) sent.push(record);
691
+ first ??= record;
692
+ }
693
+ if (!first) throw UnsupportedError.content(group.type);
694
+ return {
695
+ ...first,
696
+ content: group
697
+ };
698
+ };
699
+ const toTemplateInput = (content) => ({
700
+ name: content.name,
701
+ languageCode: content.languageCode,
702
+ components: content.bodyParams?.length ? [{
703
+ type: "body",
704
+ parameters: content.bodyParams.map((text) => ({
705
+ type: "text",
706
+ text
707
+ }))
708
+ }] : []
709
+ });
629
710
  const send = async (clients, spaceId, content) => {
711
+ if (isWhatsAppTemplate(content)) return toRecord(await primary(clients).messages.send({
712
+ to: spaceId,
713
+ template: toTemplateInput(content)
714
+ }), spaceId, content);
630
715
  if (content.type === "reply") return await replyToMessage(clients, spaceId, parentWamid(content.target.id), content.content);
631
716
  if (content.type === "reaction") return await reactToMessage(clients, spaceId, content);
632
717
  if (content.type === "typing") return;
@@ -691,10 +776,21 @@ const send = async (clients, spaceId, content) => {
691
776
  cachePoll(client, result.messageId, content);
692
777
  return toRecord(result, spaceId, content);
693
778
  }
779
+ case "richlink": return toRecord(await client.messages.send({
780
+ to: spaceId,
781
+ text: {
782
+ body: content.url,
783
+ previewUrl: true
784
+ }
785
+ }), spaceId, content);
694
786
  case "app": return toRecord(await client.messages.send({
695
787
  to: spaceId,
696
- text: await content.url()
788
+ text: {
789
+ body: await content.url(),
790
+ previewUrl: true
791
+ }
697
792
  }), spaceId, content);
793
+ case "group": return await sendGroupParts(clients, spaceId, content);
698
794
  default: throw UnsupportedError.content(content.type);
699
795
  }
700
796
  };
@@ -709,6 +805,11 @@ const reactToMessage = async (clients, spaceId, content) => {
709
805
  };
710
806
  const replyToMessage = async (clients, spaceId, messageId, content) => {
711
807
  const client = primary(clients);
808
+ if (isWhatsAppTemplate(content)) return toRecord(await client.messages.send({
809
+ to: spaceId,
810
+ replyTo: messageId,
811
+ template: toTemplateInput(content)
812
+ }), spaceId, content);
712
813
  switch (content.type) {
713
814
  case "text": return toRecord(await client.messages.send({
714
815
  to: spaceId,
@@ -758,11 +859,23 @@ const replyToMessage = async (clients, spaceId, messageId, content) => {
758
859
  cachePoll(client, result.messageId, content);
759
860
  return toRecord(result, spaceId, content);
760
861
  }
862
+ case "richlink": return toRecord(await client.messages.send({
863
+ to: spaceId,
864
+ replyTo: messageId,
865
+ text: {
866
+ body: content.url,
867
+ previewUrl: true
868
+ }
869
+ }), spaceId, content);
761
870
  case "app": return toRecord(await client.messages.send({
762
871
  to: spaceId,
763
872
  replyTo: messageId,
764
- text: await content.url()
873
+ text: {
874
+ body: await content.url(),
875
+ previewUrl: true
876
+ }
765
877
  }), spaceId, content);
878
+ case "group": return await sendGroupParts(clients, spaceId, content, messageId);
766
879
  default: throw UnsupportedError.content(content.type);
767
880
  }
768
881
  };
@@ -777,9 +890,11 @@ const cloudConfig = z.object({}).strict();
777
890
  const configSchema = z.union([directConfig, cloudConfig]);
778
891
  const isCloudConfig = (config) => !("accessToken" in config);
779
892
  z.object({});
893
+ const spaceSchema = z.object({ id: z.string() });
780
894
  //#endregion
781
895
  //#region src/index.ts
782
- const whatsappBusiness = definePlatform("WhatsApp Business", {
896
+ const PLATFORM_ID = "whatsapp_business";
897
+ const whatsappBusiness = definePlatform(PLATFORM_ID, {
783
898
  config: configSchema,
784
899
  lifecycle: {
785
900
  createClient: async ({ config, projectId, projectSecret }) => {
@@ -798,10 +913,10 @@ const whatsappBusiness = definePlatform("WhatsApp Business", {
798
913
  },
799
914
  user: { resolve: async ({ input }) => ({ id: input.userID }) },
800
915
  space: {
801
- schema: z.object({ id: z.string() }),
916
+ schema: spaceSchema,
802
917
  create: async ({ input }) => {
803
918
  if (input.users.length === 0) throw new Error("WhatsApp space creation requires at least one user");
804
- if (input.users.length > 1) throw UnsupportedError.action("space.create", "WhatsApp Business", "only 1:1 conversations are supported");
919
+ if (input.users.length > 1) throw UnsupportedError.action("space.create", PLATFORM_ID, "only 1:1 conversations are supported");
805
920
  const user = input.users[0];
806
921
  if (!user) throw new Error("WhatsApp space creation requires a user");
807
922
  return { id: user.id };
@@ -811,4 +926,4 @@ const whatsappBusiness = definePlatform("WhatsApp Business", {
811
926
  send: async ({ space, content, client }) => await send(client, space.id, content)
812
927
  });
813
928
  //#endregion
814
- export { whatsappBusiness };
929
+ export { WhatsAppPartialSendError, asWhatsAppTemplate, isWhatsAppTemplate, whatsappBusiness, whatsappTemplate, whatsappTemplateSchema };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectrum-ts/whatsapp-business",
3
- "version": "11.2.0",
3
+ "version": "12.1.0",
4
4
  "description": "WhatsApp Business provider for spectrum-ts.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,9 +26,9 @@
26
26
  "access": "public"
27
27
  },
28
28
  "spectrum": {
29
- "key": "whatsapp-business",
29
+ "key": "whatsapp_business",
30
30
  "import": "whatsappBusiness",
31
- "label": "WhatsApp Business"
31
+ "label": "whatsapp_business"
32
32
  },
33
33
  "dependencies": {
34
34
  "@photon-ai/whatsapp-business": "^0.2.0",
@@ -36,7 +36,7 @@
36
36
  "zod": "^4.2.1"
37
37
  },
38
38
  "peerDependencies": {
39
- "@spectrum-ts/core": "^11.0.0",
39
+ "@spectrum-ts/core": "^12.0.0",
40
40
  "typescript": "^5 || ^6.0.0"
41
41
  },
42
42
  "license": "MIT"