@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 +44 -2
- package/dist/index.js +179 -8
- package/package.json +2 -2
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
|
|
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:
|
|
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:
|
|
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": "
|
|
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": "^
|
|
39
|
+
"@spectrum-ts/core": "^12.0.0",
|
|
40
40
|
"typescript": "^5 || ^6.0.0"
|
|
41
41
|
},
|
|
42
42
|
"license": "MIT"
|