@spectrum-ts/whatsapp-business 11.2.0 → 12.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/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,6 +11,47 @@ 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
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;
@@ -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,6 +1,6 @@
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, 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
@@ -251,6 +251,51 @@ const resubscribableStream = (state, options) => {
251
251
  });
252
252
  };
253
253
  //#endregion
254
+ //#region src/content/template.ts
255
+ /**
256
+ * WhatsApp-only template message content. Lives entirely under the WhatsApp
257
+ * Business provider — never enters the universal `Content` discriminated
258
+ * union. The framework recognizes it via the generic content-level platform
259
+ * contract: `__platform: "WhatsApp Business"` lets
260
+ * `findUnsupportedPlatformContent` warn-and-skip when a different platform
261
+ * receives it.
262
+ *
263
+ * Templates are the only message type Meta accepts outside the 24-hour
264
+ * customer-service window (free-form sends fail with error 131047), so this
265
+ * is the escape hatch for re-engaging stale conversations. v1 covers the
266
+ * common case: a pre-approved template addressed by name + language, with
267
+ * positional `{{1}}..{{n}}` body text parameters. Header/button components
268
+ * can be added later without breaking this shape.
269
+ */
270
+ const whatsappTemplateSchema = z.object({
271
+ type: z.literal("whatsapp-template"),
272
+ __platform: z.literal("WhatsApp Business"),
273
+ name: z.string().min(1),
274
+ languageCode: z.string().min(1),
275
+ bodyParams: z.array(z.string()).optional()
276
+ });
277
+ const isWhatsAppTemplate = (value) => whatsappTemplateSchema.safeParse(value).success;
278
+ const asWhatsAppTemplate = (input) => whatsappTemplateSchema.parse({
279
+ type: "whatsapp-template",
280
+ __platform: "WhatsApp Business",
281
+ ...input
282
+ });
283
+ function whatsappTemplate(input) {
284
+ return { build: async () => asWhatsAppTemplate(input) };
285
+ }
286
+ //#endregion
287
+ //#region src/errors/partial-send.ts
288
+ var WhatsAppPartialSendError = class extends Error {
289
+ sent;
290
+ failedIndex;
291
+ constructor(input) {
292
+ 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 });
293
+ this.name = "WhatsAppPartialSendError";
294
+ this.sent = input.sent;
295
+ this.failedIndex = input.failedIndex;
296
+ }
297
+ };
298
+ //#endregion
254
299
  //#region src/poll.ts
255
300
  const MAX_BUTTON_OPTIONS = 3;
256
301
  const LIST_BUTTON_TEXT = "View options";
@@ -321,6 +366,26 @@ const reactionTargetStub = (reactedId) => ({
321
366
  stub: true
322
367
  })
323
368
  });
369
+ const replyTargetStub = (quotedId) => ({
370
+ id: quotedId,
371
+ content: asCustom({
372
+ whatsapp_type: "reply-target",
373
+ stub: true
374
+ })
375
+ });
376
+ const REPLY_EXEMPT_TYPES = new Set([
377
+ "interactive",
378
+ "button",
379
+ "reaction"
380
+ ]);
381
+ const withReplyContext = (msg, content) => {
382
+ const quotedId = msg.context?.id;
383
+ if (quotedId === void 0 || REPLY_EXEMPT_TYPES.has(msg.content.type)) return content;
384
+ return asReply({
385
+ content,
386
+ target: replyTargetStub(quotedId)
387
+ });
388
+ };
324
389
  const optionIndexFromId = (id) => {
325
390
  if (!id.startsWith(OPTION_ID_PREFIX)) return;
326
391
  const index = Number(id.slice(4));
@@ -411,13 +476,13 @@ const toMessages = (client, msg) => {
411
476
  return msg.content.contacts.map((card, index) => ({
412
477
  ...base,
413
478
  id: multi ? `${msg.id}:${index}` : msg.id,
414
- content: waContactToSpectrum(card)
479
+ content: withReplyContext(msg, waContactToSpectrum(card))
415
480
  }));
416
481
  }
417
482
  return [{
418
483
  ...base,
419
484
  id: msg.id,
420
- content: mapContent(client, msg)
485
+ content: withReplyContext(msg, mapContent(client, msg))
421
486
  }];
422
487
  };
423
488
  const mapReactionContent = (client, msg, reaction) => {
@@ -456,7 +521,7 @@ const mapContent = (client, msg) => {
456
521
  case "video":
457
522
  case "audio":
458
523
  case "document": {
459
- const media = lazyMedia(client, content.media);
524
+ const media = content.type === "audio" && content.media.voice ? lazyVoice(client, content.media) : lazyMedia(client, content.media);
460
525
  const caption = content.media.caption?.trim();
461
526
  if (!caption) return media;
462
527
  return asGroup({ items: [groupItem(msg, 0, media), groupItem(msg, 1, asText(caption))] });
@@ -518,7 +583,7 @@ const fetchMedia = async (client, mediaId) => {
518
583
  if (!response.ok) throw new Error(`Media download failed: ${response.status}`);
519
584
  return response;
520
585
  };
521
- const lazyMedia = (client, media) => asAttachment({
586
+ const lazyMediaSource = (client, media) => ({
522
587
  id: media.id,
523
588
  name: media.filename ?? `media-${media.id}`,
524
589
  mimeType: media.mimeType,
@@ -529,6 +594,8 @@ const lazyMedia = (client, media) => asAttachment({
529
594
  return response.body;
530
595
  }
531
596
  });
597
+ const lazyMedia = (client, media) => asAttachment(lazyMediaSource(client, media));
598
+ const lazyVoice = (client, media) => asVoice(lazyMediaSource(client, media));
532
599
  const mimeToMediaType = (mimeType) => {
533
600
  if (mimeType.startsWith("image/")) return "image";
534
601
  if (mimeType.startsWith("video/")) return "video";
@@ -626,7 +693,83 @@ const clientStream = (client) => {
626
693
  });
627
694
  };
628
695
  const messages = (clients) => mergeStreams(clients.map(clientStream));
696
+ const MAX_CAPTION_LENGTH = 1024;
697
+ const captionablePair = (group) => {
698
+ if (group.items.length !== 2) return;
699
+ const contents = group.items.map((item) => item.content);
700
+ const attachment = contents.find((c) => c.type === "attachment");
701
+ const text = contents.find((c) => c.type === "text");
702
+ return attachment && text && mimeToMediaType(attachment.mimeType) !== "audio" && text.text.length <= MAX_CAPTION_LENGTH ? {
703
+ attachment,
704
+ caption: text.text
705
+ } : void 0;
706
+ };
707
+ const sendGroupParts = async (clients, spaceId, group, replyTo) => {
708
+ const pair = captionablePair(group);
709
+ if (pair) {
710
+ const client = primary(clients);
711
+ const { attachment, caption } = pair;
712
+ const { mediaId } = await client.media.upload({
713
+ file: await attachment.read(),
714
+ mimeType: attachment.mimeType,
715
+ filename: attachment.name
716
+ });
717
+ const mediaType = mimeToMediaType(attachment.mimeType);
718
+ const mediaPayload = mediaType === "document" ? {
719
+ id: mediaId,
720
+ filename: attachment.name,
721
+ caption
722
+ } : {
723
+ id: mediaId,
724
+ caption
725
+ };
726
+ return toRecord(await client.messages.send({
727
+ to: spaceId,
728
+ ...replyTo === void 0 ? {} : { replyTo },
729
+ [mediaType]: mediaPayload
730
+ }), spaceId, group);
731
+ }
732
+ const sent = [];
733
+ let first;
734
+ for (const [index, item] of group.items.entries()) {
735
+ const itemContent = item.content;
736
+ let record;
737
+ try {
738
+ record = index === 0 && replyTo !== void 0 ? await replyToMessage(clients, spaceId, replyTo, itemContent) : await send(clients, spaceId, itemContent);
739
+ } catch (error) {
740
+ if (sent.length === 0) throw error;
741
+ throw new WhatsAppPartialSendError({
742
+ sent,
743
+ failedIndex: index,
744
+ total: group.items.length,
745
+ cause: error
746
+ });
747
+ }
748
+ if (record) sent.push(record);
749
+ first ??= record;
750
+ }
751
+ if (!first) throw UnsupportedError.content(group.type);
752
+ return {
753
+ ...first,
754
+ content: group
755
+ };
756
+ };
757
+ const toTemplateInput = (content) => ({
758
+ name: content.name,
759
+ languageCode: content.languageCode,
760
+ components: content.bodyParams?.length ? [{
761
+ type: "body",
762
+ parameters: content.bodyParams.map((text) => ({
763
+ type: "text",
764
+ text
765
+ }))
766
+ }] : []
767
+ });
629
768
  const send = async (clients, spaceId, content) => {
769
+ if (isWhatsAppTemplate(content)) return toRecord(await primary(clients).messages.send({
770
+ to: spaceId,
771
+ template: toTemplateInput(content)
772
+ }), spaceId, content);
630
773
  if (content.type === "reply") return await replyToMessage(clients, spaceId, parentWamid(content.target.id), content.content);
631
774
  if (content.type === "reaction") return await reactToMessage(clients, spaceId, content);
632
775
  if (content.type === "typing") return;
@@ -691,10 +834,21 @@ const send = async (clients, spaceId, content) => {
691
834
  cachePoll(client, result.messageId, content);
692
835
  return toRecord(result, spaceId, content);
693
836
  }
837
+ case "richlink": return toRecord(await client.messages.send({
838
+ to: spaceId,
839
+ text: {
840
+ body: content.url,
841
+ previewUrl: true
842
+ }
843
+ }), spaceId, content);
694
844
  case "app": return toRecord(await client.messages.send({
695
845
  to: spaceId,
696
- text: await content.url()
846
+ text: {
847
+ body: await content.url(),
848
+ previewUrl: true
849
+ }
697
850
  }), spaceId, content);
851
+ case "group": return await sendGroupParts(clients, spaceId, content);
698
852
  default: throw UnsupportedError.content(content.type);
699
853
  }
700
854
  };
@@ -709,6 +863,11 @@ const reactToMessage = async (clients, spaceId, content) => {
709
863
  };
710
864
  const replyToMessage = async (clients, spaceId, messageId, content) => {
711
865
  const client = primary(clients);
866
+ if (isWhatsAppTemplate(content)) return toRecord(await client.messages.send({
867
+ to: spaceId,
868
+ replyTo: messageId,
869
+ template: toTemplateInput(content)
870
+ }), spaceId, content);
712
871
  switch (content.type) {
713
872
  case "text": return toRecord(await client.messages.send({
714
873
  to: spaceId,
@@ -758,11 +917,23 @@ const replyToMessage = async (clients, spaceId, messageId, content) => {
758
917
  cachePoll(client, result.messageId, content);
759
918
  return toRecord(result, spaceId, content);
760
919
  }
920
+ case "richlink": return toRecord(await client.messages.send({
921
+ to: spaceId,
922
+ replyTo: messageId,
923
+ text: {
924
+ body: content.url,
925
+ previewUrl: true
926
+ }
927
+ }), spaceId, content);
761
928
  case "app": return toRecord(await client.messages.send({
762
929
  to: spaceId,
763
930
  replyTo: messageId,
764
- text: await content.url()
931
+ text: {
932
+ body: await content.url(),
933
+ previewUrl: true
934
+ }
765
935
  }), spaceId, content);
936
+ case "group": return await sendGroupParts(clients, spaceId, content, messageId);
766
937
  default: throw UnsupportedError.content(content.type);
767
938
  }
768
939
  };
@@ -811,4 +982,4 @@ const whatsappBusiness = definePlatform("WhatsApp Business", {
811
982
  send: async ({ space, content, client }) => await send(client, space.id, content)
812
983
  });
813
984
  //#endregion
814
- export { whatsappBusiness };
985
+ 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.0.0",
4
4
  "description": "WhatsApp Business provider for spectrum-ts.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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"