spectrum-ts 0.6.1 → 0.8.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.
@@ -2,20 +2,243 @@ import {
2
2
  asAttachment,
3
3
  asContact,
4
4
  asCustom,
5
+ cloud,
6
+ mergeStreams,
5
7
  stream
6
- } from "../../chunk-UZWRB3FZ.js";
8
+ } from "../../chunk-ZNUORCLB.js";
7
9
  import {
10
+ UnsupportedError,
8
11
  asText,
9
12
  definePlatform
10
- } from "../../chunk-XZTTLPHE.js";
13
+ } from "../../chunk-4O6MQC5Z.js";
11
14
 
12
15
  // src/providers/whatsapp-business/index.ts
16
+ import { createClient as createClient2 } from "@photon-ai/whatsapp-business";
17
+
18
+ // src/providers/whatsapp-business/auth.ts
13
19
  import {
14
- createClient
20
+ createClient,
21
+ TypedEventStream
15
22
  } from "@photon-ai/whatsapp-business";
23
+ var RENEWAL_RATIO = 0.8;
24
+ var EXPIRY_BUFFER_MS = 3e4;
25
+ var RETRY_DELAY_MS = 3e4;
26
+ var RESUBSCRIBE_BACKOFF_MS = 500;
27
+ var cloudAuthState = /* @__PURE__ */ new WeakMap();
28
+ async function createCloudClients(projectId, projectSecret) {
29
+ let tokenData = await cloud.issueWhatsappBusinessTokens(
30
+ projectId,
31
+ projectSecret
32
+ );
33
+ let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
34
+ let disposed = false;
35
+ let renewalTimer;
36
+ const lines = /* @__PURE__ */ new Map();
37
+ const buildRawClient = (phoneNumberId) => {
38
+ const accessToken = tokenData.auth[phoneNumberId];
39
+ if (!accessToken) {
40
+ throw new Error(
41
+ `WhatsApp Business line ${phoneNumberId} missing from token response`
42
+ );
43
+ }
44
+ return createClient({ accessToken, appSecret: "", phoneNumberId });
45
+ };
46
+ const refreshTokens = async () => {
47
+ tokenData = await cloud.issueWhatsappBusinessTokens(
48
+ projectId,
49
+ projectSecret
50
+ );
51
+ tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
52
+ for (const [phoneNumberId, state] of lines) {
53
+ if (!tokenData.auth[phoneNumberId]) {
54
+ continue;
55
+ }
56
+ const old = state.current;
57
+ state.current = buildRawClient(phoneNumberId);
58
+ for (const sub of state.subscriptions) {
59
+ sub.swap();
60
+ }
61
+ await old.close().catch(() => void 0);
62
+ }
63
+ };
64
+ const clearRenewalTimer = () => {
65
+ if (renewalTimer !== void 0) {
66
+ clearTimeout(renewalTimer);
67
+ renewalTimer = void 0;
68
+ }
69
+ };
70
+ const scheduleRenewal = () => {
71
+ if (disposed) {
72
+ return;
73
+ }
74
+ clearRenewalTimer();
75
+ const ttlMs = tokenData.expiresIn * 1e3;
76
+ const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
77
+ renewalTimer = setTimeout(async () => {
78
+ try {
79
+ await refreshTokens();
80
+ scheduleRenewal();
81
+ } catch (err) {
82
+ console.warn(
83
+ `[spectrum-ts] WhatsApp Business token refresh failed; retrying in ${RETRY_DELAY_MS}ms.`,
84
+ err
85
+ );
86
+ clearRenewalTimer();
87
+ renewalTimer = setTimeout(() => scheduleRenewal(), RETRY_DELAY_MS);
88
+ renewalTimer?.unref?.();
89
+ }
90
+ }, renewInMs);
91
+ renewalTimer?.unref?.();
92
+ };
93
+ const refreshIfNeeded = async () => {
94
+ if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) {
95
+ return;
96
+ }
97
+ await refreshTokens();
98
+ scheduleRenewal();
99
+ };
100
+ scheduleRenewal();
101
+ const clients = Object.keys(tokenData.auth).map(
102
+ (phoneNumberId) => {
103
+ const state = {
104
+ current: buildRawClient(phoneNumberId),
105
+ subscriptions: /* @__PURE__ */ new Set()
106
+ };
107
+ lines.set(phoneNumberId, state);
108
+ return buildClientProxy(state, refreshIfNeeded);
109
+ }
110
+ );
111
+ cloudAuthState.set(clients, {
112
+ dispose: async () => {
113
+ disposed = true;
114
+ clearRenewalTimer();
115
+ for (const state of lines.values()) {
116
+ for (const sub of state.subscriptions) {
117
+ sub.close();
118
+ }
119
+ }
120
+ await Promise.allSettled(
121
+ Array.from(lines.values()).map((s) => s.current.close())
122
+ );
123
+ lines.clear();
124
+ }
125
+ });
126
+ return clients;
127
+ }
128
+ async function disposeCloudAuth(clients) {
129
+ const auth = cloudAuthState.get(clients);
130
+ if (!auth) {
131
+ return;
132
+ }
133
+ await auth.dispose();
134
+ cloudAuthState.delete(clients);
135
+ }
136
+ var buildClientProxy = (state, refresh) => {
137
+ const forwarder = (pick) => new Proxy({}, {
138
+ get: (_, prop) => {
139
+ return async (...args) => {
140
+ await refresh();
141
+ const target = pick(state.current);
142
+ const fn = target[prop];
143
+ return Reflect.apply(fn, pick(state.current), args);
144
+ };
145
+ }
146
+ });
147
+ const events = {
148
+ fetchMissed: async (opts) => {
149
+ await refresh();
150
+ return state.current.events.fetchMissed(opts);
151
+ },
152
+ subscribe: (options) => resubscribableStream(state, options)
153
+ };
154
+ return {
155
+ events,
156
+ media: forwarder((c) => c.media),
157
+ messages: forwarder((c) => c.messages),
158
+ close: async () => {
159
+ for (const sub of state.subscriptions) {
160
+ sub.close();
161
+ }
162
+ await state.current.close();
163
+ },
164
+ [Symbol.asyncDispose]: async () => {
165
+ for (const sub of state.subscriptions) {
166
+ sub.close();
167
+ }
168
+ await state.current.close();
169
+ }
170
+ };
171
+ };
172
+ var pumpOnce = async (ctx) => {
173
+ const sub = ctx.getCurrent().events.subscribe(ctx.options);
174
+ ctx.setActive(sub);
175
+ try {
176
+ for await (const event of sub) {
177
+ await ctx.emit(event);
178
+ }
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ } finally {
183
+ ctx.setActive(void 0);
184
+ }
185
+ };
186
+ var resubscribableStream = (state, options) => {
187
+ let closed = false;
188
+ let active;
189
+ const source = stream((emit, end) => {
190
+ const ctx = {
191
+ emit,
192
+ getCurrent: () => state.current,
193
+ options,
194
+ setActive: (s) => {
195
+ active = s;
196
+ }
197
+ };
198
+ const pump = (async () => {
199
+ while (!closed) {
200
+ await pumpOnce(ctx);
201
+ if (!closed) {
202
+ await new Promise((r) => setTimeout(r, RESUBSCRIBE_BACKOFF_MS));
203
+ }
204
+ }
205
+ end();
206
+ })();
207
+ return async () => {
208
+ closed = true;
209
+ active?.close().catch(() => void 0);
210
+ active = void 0;
211
+ state.subscriptions.delete(subscription);
212
+ await pump;
213
+ };
214
+ });
215
+ const subscription = {
216
+ close: () => {
217
+ closed = true;
218
+ active?.close().catch(() => void 0);
219
+ },
220
+ swap: () => {
221
+ active?.close().catch(() => void 0);
222
+ }
223
+ };
224
+ state.subscriptions.add(subscription);
225
+ return new TypedEventStream(source, async () => {
226
+ closed = true;
227
+ active?.close().catch(() => void 0);
228
+ state.subscriptions.delete(subscription);
229
+ await source.close();
230
+ });
231
+ };
16
232
 
17
233
  // src/providers/whatsapp-business/messages.ts
18
234
  import { extension as mimeExtension } from "mime-types";
235
+ var primary = (clients) => {
236
+ const client = clients[0];
237
+ if (!client) {
238
+ throw new Error("No WhatsApp Business client available");
239
+ }
240
+ return client;
241
+ };
19
242
  var toSendResult = (result) => ({
20
243
  id: result.messageId
21
244
  });
@@ -291,16 +514,16 @@ var contactToWa = (contact) => {
291
514
  };
292
515
  return card;
293
516
  };
294
- var messages = (client) => {
517
+ var clientStream = (client) => {
295
518
  const eventStream = client.events.subscribe().filter(
296
519
  (e) => e.type === "message"
297
520
  );
298
521
  return stream((emit, end) => {
299
- (async () => {
522
+ const pump = (async () => {
300
523
  try {
301
524
  for await (const event of eventStream) {
302
525
  for (const m of toMessages(client, event.message)) {
303
- emit(m);
526
+ await emit(m);
304
527
  }
305
528
  }
306
529
  end();
@@ -308,10 +531,15 @@ var messages = (client) => {
308
531
  end(e);
309
532
  }
310
533
  })();
311
- return () => eventStream.close();
534
+ return async () => {
535
+ await eventStream.close();
536
+ await pump;
537
+ };
312
538
  });
313
539
  };
314
- var send = async (client, spaceId, content) => {
540
+ var messages = (clients) => mergeStreams(clients.map(clientStream));
541
+ var send = async (clients, spaceId, content) => {
542
+ const client = primary(clients);
315
543
  switch (content.type) {
316
544
  case "text":
317
545
  return toSendResult(
@@ -353,16 +581,17 @@ var send = async (client, spaceId, content) => {
353
581
  );
354
582
  }
355
583
  default:
356
- throw new Error(`Unsupported WhatsApp content type: ${content.type}`);
584
+ throw UnsupportedError.content(content.type);
357
585
  }
358
586
  };
359
- var reactToMessage = async (client, spaceId, messageId, reaction) => {
360
- await client.messages.send({
587
+ var reactToMessage = async (clients, spaceId, messageId, reaction) => {
588
+ await primary(clients).messages.send({
361
589
  to: spaceId,
362
590
  reaction: { messageId, emoji: reaction }
363
591
  });
364
592
  };
365
- var replyToMessage = async (client, spaceId, messageId, content) => {
593
+ var replyToMessage = async (clients, spaceId, messageId, content) => {
594
+ const client = primary(clients);
366
595
  switch (content.type) {
367
596
  case "text":
368
597
  return toSendResult(
@@ -411,17 +640,20 @@ var replyToMessage = async (client, spaceId, messageId, content) => {
411
640
  );
412
641
  }
413
642
  default:
414
- throw new Error(`Unsupported WhatsApp content type: ${content.type}`);
643
+ throw UnsupportedError.content(content.type);
415
644
  }
416
645
  };
417
646
 
418
647
  // src/providers/whatsapp-business/types.ts
419
648
  import z from "zod";
420
- var configSchema = z.object({
649
+ var directConfig = z.object({
421
650
  accessToken: z.string().min(1),
422
- phoneNumberId: z.string().min(1),
423
- appSecret: z.string().optional()
651
+ appSecret: z.string().optional(),
652
+ phoneNumberId: z.string().min(1)
424
653
  });
654
+ var cloudConfig = z.object({}).strict();
655
+ var configSchema = z.union([directConfig, cloudConfig]);
656
+ var isCloudConfig = (config) => !("accessToken" in config);
425
657
  var userSchema = z.object({});
426
658
  var spaceSchema = z.object({
427
659
  id: z.string()
@@ -440,8 +672,10 @@ var whatsappBusiness = definePlatform("WhatsApp Business", {
440
672
  throw new Error("WhatsApp space creation requires at least one user");
441
673
  }
442
674
  if (input.users.length > 1) {
443
- throw new Error(
444
- "WhatsApp Business API only supports 1:1 conversations"
675
+ throw UnsupportedError.action(
676
+ "createSpace",
677
+ "WhatsApp Business",
678
+ "only 1:1 conversations are supported"
445
679
  );
446
680
  }
447
681
  const user = input.users[0];
@@ -452,15 +686,30 @@ var whatsappBusiness = definePlatform("WhatsApp Business", {
452
686
  }
453
687
  },
454
688
  lifecycle: {
455
- createClient: async ({ config }) => {
456
- return createClient({
457
- accessToken: config.accessToken,
458
- phoneNumberId: config.phoneNumberId,
459
- appSecret: config.appSecret ?? ""
460
- });
689
+ createClient: async ({
690
+ config,
691
+ projectId,
692
+ projectSecret
693
+ }) => {
694
+ if (!isCloudConfig(config)) {
695
+ return [
696
+ createClient2({
697
+ accessToken: config.accessToken,
698
+ appSecret: config.appSecret ?? "",
699
+ phoneNumberId: config.phoneNumberId
700
+ })
701
+ ];
702
+ }
703
+ if (!(projectId && projectSecret)) {
704
+ throw new Error(
705
+ "WhatsApp Business cloud mode requires projectId and projectSecret. Either pass credentials to Spectrum(), or provide direct credentials: whatsappBusiness.config({ accessToken, phoneNumberId })"
706
+ );
707
+ }
708
+ return await createCloudClients(projectId, projectSecret);
461
709
  },
462
710
  destroyClient: async ({ client }) => {
463
- await client.close();
711
+ await disposeCloudAuth(client);
712
+ await Promise.all(client.map((c) => c.close()));
464
713
  }
465
714
  },
466
715
  events: {
@@ -2,7 +2,7 @@ interface ManagedStream<T> extends AsyncIterable<T> {
2
2
  close(): Promise<void>;
3
3
  }
4
4
  type StreamCleanup = void | (() => void | Promise<void>);
5
- declare function stream<T>(setup: (emit: (value: T) => void, end: (error?: unknown) => void) => StreamCleanup | Promise<StreamCleanup>): ManagedStream<T>;
5
+ declare function stream<T>(setup: (emit: (value: T) => Promise<void>, end: (error?: unknown) => void) => StreamCleanup | Promise<StreamCleanup>): ManagedStream<T>;
6
6
  declare function mergeStreams<T>(streams: readonly ManagedStream<T>[]): ManagedStream<T>;
7
7
 
8
8
  export { type ManagedStream as M, mergeStreams as m, stream as s };
@@ -78,6 +78,16 @@ declare const contentSchema: z__default.ZodDiscriminatedUnion<[z__default.ZodObj
78
78
  size: z__default.ZodOptional<z__default.ZodNumber>;
79
79
  read: z__default.ZodFunction<z__default.ZodTuple<readonly [], null>, z__default.ZodPromise<z__default.ZodCustom<Buffer<ArrayBufferLike>, Buffer<ArrayBufferLike>>>>;
80
80
  stream: z__default.ZodFunction<z__default.ZodTuple<readonly [], null>, z__default.ZodPromise<z__default.ZodCustom<ReadableStream<unknown>, ReadableStream<unknown>>>>;
81
+ }, z__default.core.$strip>, z__default.ZodObject<{
82
+ type: z__default.ZodLiteral<"richlink">;
83
+ url: z__default.ZodURL;
84
+ title: z__default.ZodFunction<z__default.ZodTuple<readonly [], null>, z__default.ZodPromise<z__default.ZodOptional<z__default.ZodString>>>;
85
+ summary: z__default.ZodFunction<z__default.ZodTuple<readonly [], null>, z__default.ZodPromise<z__default.ZodOptional<z__default.ZodString>>>;
86
+ cover: z__default.ZodFunction<z__default.ZodTuple<readonly [], null>, z__default.ZodPromise<z__default.ZodOptional<z__default.ZodObject<{
87
+ mimeType: z__default.ZodOptional<z__default.ZodString>;
88
+ read: z__default.ZodFunction<z__default.ZodTuple<readonly [], null>, z__default.ZodPromise<z__default.ZodCustom<Buffer<ArrayBufferLike>, Buffer<ArrayBufferLike>>>>;
89
+ stream: z__default.ZodFunction<z__default.ZodTuple<readonly [], null>, z__default.ZodPromise<z__default.ZodCustom<ReadableStream<unknown>, ReadableStream<unknown>>>>;
90
+ }, z__default.core.$strip>>>>;
81
91
  }, z__default.core.$strip>], "type">;
82
92
  type Content = z__default.infer<typeof contentSchema>;
83
93
  interface ContentBuilder {
@@ -95,7 +105,7 @@ interface Space<_Def = unknown> {
95
105
  edit(message: OutboundMessage, newContent: ContentInput): Promise<void>;
96
106
  readonly id: string;
97
107
  responding<T>(fn: () => T | Promise<T>): Promise<T>;
98
- send(content: ContentInput): Promise<OutboundMessage>;
108
+ send(content: ContentInput): Promise<OutboundMessage | undefined>;
99
109
  send(...content: [ContentInput, ContentInput, ...ContentInput[]]): Promise<OutboundMessage[]>;
100
110
  startTyping(): Promise<void>;
101
111
  stopTyping(): Promise<void>;
@@ -106,7 +116,7 @@ interface BaseMessage<TPlatform extends string = string, TSender extends User =
106
116
  readonly id: string;
107
117
  platform: TPlatform;
108
118
  react(reaction: string): Promise<void>;
109
- reply(content: ContentInput): Promise<OutboundMessage<TPlatform, TSender, TSpace>>;
119
+ reply(content: ContentInput): Promise<OutboundMessage<TPlatform, TSender, TSpace> | undefined>;
110
120
  reply(...content: [ContentInput, ContentInput, ...ContentInput[]]): Promise<OutboundMessage<TPlatform, TSender, TSpace>[]>;
111
121
  space: TSpace;
112
122
  timestamp: Date;
@@ -310,12 +320,13 @@ type SpaceShapeOf<Def extends AnyPlatformDef> = [SchemaSpaceOf<Def>] extends [
310
320
  never
311
321
  ] ? ResolvedSpaceOf<Def> : SchemaSpaceOf<Def>;
312
322
  type SpaceParamsInputOf<Def extends AnyPlatformDef> = InputSchema<Def["space"]["params"]>;
323
+ type SpaceUserLike<Def extends AnyPlatformDef> = PlatformUser<Def> | string;
313
324
  type SpaceArrayArgs<Def extends AnyPlatformDef> = [
314
325
  SpaceParamsInputOf<Def>
315
- ] extends [never] ? [users: PlatformUser<Def>[]] : [users: PlatformUser<Def>[]] | [users: PlatformUser<Def>[], params: SpaceParamsInputOf<Def>] | [params: SpaceParamsInputOf<Def>];
326
+ ] extends [never] ? [users: SpaceUserLike<Def>[]] : [users: SpaceUserLike<Def>[]] | [users: SpaceUserLike<Def>[], params: SpaceParamsInputOf<Def>] | [params: SpaceParamsInputOf<Def>];
316
327
  type SpaceVarargArgs<Def extends AnyPlatformDef> = [
317
328
  SpaceParamsInputOf<Def>
318
- ] extends [never] ? PlatformUser<Def>[] : PlatformUser<Def>[] | [...PlatformUser<Def>[], SpaceParamsInputOf<Def>];
329
+ ] extends [never] ? SpaceUserLike<Def>[] : SpaceUserLike<Def>[] | [...SpaceUserLike<Def>[], SpaceParamsInputOf<Def>];
319
330
  type SpaceArgs<Def extends AnyPlatformDef> = SpaceArrayArgs<Def> | SpaceVarargArgs<Def>;
320
331
  type PlatformSpace<Def extends AnyPlatformDef> = Omit<SpaceShapeOf<Def>, keyof Space> & Space;
321
332
  type PlatformMessage<Def extends AnyPlatformDef> = Omit<SchemaInfer<Def["message"]>, keyof Message> & Message<Def["name"], PlatformUser<Def>, PlatformSpace<Def>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-ts",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -25,6 +25,7 @@
25
25
  "@repeaterjs/repeater": "^3.0.6",
26
26
  "better-grpc": "^0.3.2",
27
27
  "mime-types": "^3.0.1",
28
+ "open-graph-scraper": "^6.11.0",
28
29
  "type-fest": "^5.4.1",
29
30
  "vcf": "^2.1.2",
30
31
  "zod": "^4.2.1"
@@ -1,67 +0,0 @@
1
- // src/utils/cloud.ts
2
- var SPECTRUM_CLOUD_URL = `https://${process.env.SPECTRUM_CLOUD_URL ?? "spectrum.photon.codes"}`;
3
- var SpectrumCloudError = class extends Error {
4
- status;
5
- code;
6
- constructor(status, code, message) {
7
- super(message);
8
- this.name = "SpectrumCloudError";
9
- this.status = status;
10
- this.code = code;
11
- }
12
- };
13
- var request = async (path, init) => {
14
- const response = await fetch(`${SPECTRUM_CLOUD_URL}${path}`, init);
15
- if (!response.ok) {
16
- const body = await response.text().catch(() => "");
17
- try {
18
- const parsed = JSON.parse(body);
19
- throw new SpectrumCloudError(
20
- response.status,
21
- parsed.code,
22
- parsed.message
23
- );
24
- } catch (error) {
25
- if (error instanceof SpectrumCloudError) {
26
- throw error;
27
- }
28
- throw new SpectrumCloudError(
29
- response.status,
30
- "UNKNOWN",
31
- body || response.statusText
32
- );
33
- }
34
- }
35
- const json = await response.json();
36
- if (!json.succeed) {
37
- throw new SpectrumCloudError(
38
- response.status,
39
- "UNKNOWN",
40
- "Server returned succeed=false"
41
- );
42
- }
43
- return json.data;
44
- };
45
- var basicAuth = (projectId, projectSecret) => `Basic ${btoa(`${projectId}:${projectSecret}`)}`;
46
- var cloud = {
47
- getSubscription: (projectId) => request(`/projects/${projectId}/billing/subscription`),
48
- issueImessageTokens: (projectId, projectSecret) => request(`/projects/${projectId}/imessage/tokens`, {
49
- method: "POST",
50
- headers: { Authorization: basicAuth(projectId, projectSecret) }
51
- }),
52
- getImessageInfo: (projectId) => request(`/projects/${projectId}/imessage/`),
53
- getPlatforms: (projectId) => request(`/projects/${projectId}/platforms/`),
54
- togglePlatform: (projectId, projectSecret, platform, enabled) => request(`/projects/${projectId}/platforms/`, {
55
- method: "PATCH",
56
- headers: {
57
- Authorization: basicAuth(projectId, projectSecret),
58
- "Content-Type": "application/json"
59
- },
60
- body: JSON.stringify({ platform, enabled })
61
- })
62
- };
63
-
64
- export {
65
- SpectrumCloudError,
66
- cloud
67
- };