spectrum-ts 0.1.2 → 0.2.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.
@@ -155,12 +155,13 @@ function definePlatform(name, def) {
155
155
  }
156
156
  throw new Error("Invalid input to platform narrowing function");
157
157
  });
158
- narrower.config = (config = {}) => {
158
+ narrower.config = (config) => {
159
+ const resolvedConfig = config ?? {};
159
160
  return {
160
161
  __tag: "PlatformProviderConfig",
161
162
  __def: void 0,
162
163
  __name: name,
163
- config,
164
+ config: resolvedConfig,
164
165
  __definition: fullDef
165
166
  };
166
167
  };
@@ -0,0 +1,67 @@
1
+ // src/utils/cloud.ts
2
+ var SPECTRUM_CLOUD_URL = `https://${process.env.SPECTRUM_CLOUD_URL ?? "spectrum-cloud.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
+ };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import z__default from 'zod';
2
- import { P as ProviderMessage, a as PlatformDef, b as Platform, c as PlatformProviderConfig, S as SpectrumLike, C as CustomEventStreams, d as Space, M as Message, e as ContentBuilder } from './types-eXHZpal1.js';
3
- export { A as AnyPlatformDef, f as Content, E as EventProducer, g as PlatformInstance, h as PlatformMessage, i as PlatformSpace, j as PlatformUser, k as SchemaMessage, U as User, l as attachment, m as custom, t as text } from './types-eXHZpal1.js';
2
+ import { P as ProviderMessage, a as PlatformDef, b as Platform, c as PlatformProviderConfig, S as SpectrumLike, C as CustomEventStreams, d as Space, M as Message, e as ContentBuilder } from './types-DQE0dQT4.js';
3
+ export { A as AnyPlatformDef, f as Content, E as EventProducer, g as PlatformInstance, h as PlatformMessage, i as PlatformSpace, j as PlatformUser, k as SchemaMessage, U as User, l as attachment, m as custom, t as text } from './types-DQE0dQT4.js';
4
4
  export { M as ManagedStream, m as mergeStreams, s as stream } from './stream-DGy4geUK.js';
5
5
  import 'hotscript';
6
6
  import 'type-fest';
@@ -39,4 +39,41 @@ declare function Spectrum<const Providers extends PlatformProviderConfig[]>(opti
39
39
  providers: [...Providers];
40
40
  }): Promise<SpectrumInstance<Providers>>;
41
41
 
42
- export { ContentBuilder, Message, Platform, PlatformDef, PlatformProviderConfig, Space, Spectrum, type SpectrumInstance, definePlatform };
42
+ type SubscriptionStatus = "active" | "canceled" | "past_due";
43
+ interface SubscriptionData {
44
+ status: SubscriptionStatus | null;
45
+ tier: string;
46
+ }
47
+ interface SharedTokenData {
48
+ expiresIn: number;
49
+ token: string;
50
+ type: "shared";
51
+ }
52
+ interface DedicatedTokenData {
53
+ auth: Record<string, string>;
54
+ expiresIn: number;
55
+ type: "dedicated";
56
+ }
57
+ type TokenData = SharedTokenData | DedicatedTokenData;
58
+ type CloudPlatform = "imessage" | "whatsapp_business";
59
+ interface PlatformStatus {
60
+ enabled: boolean;
61
+ }
62
+ type PlatformsData = Record<CloudPlatform, PlatformStatus>;
63
+ interface ImessageInfoData {
64
+ type: "shared" | "dedicated";
65
+ }
66
+ declare class SpectrumCloudError extends Error {
67
+ readonly status: number;
68
+ readonly code: string;
69
+ constructor(status: number, code: string, message: string);
70
+ }
71
+ declare const cloud: {
72
+ getSubscription: (projectId: string) => Promise<SubscriptionData>;
73
+ issueImessageTokens: (projectId: string, projectSecret: string) => Promise<TokenData>;
74
+ getImessageInfo: (projectId: string) => Promise<ImessageInfoData>;
75
+ getPlatforms: (projectId: string) => Promise<PlatformsData>;
76
+ togglePlatform: (projectId: string, projectSecret: string, platform: CloudPlatform, enabled: boolean) => Promise<PlatformsData>;
77
+ };
78
+
79
+ export { type CloudPlatform, ContentBuilder, type DedicatedTokenData, type ImessageInfoData, Message, Platform, PlatformDef, PlatformProviderConfig, type PlatformStatus, type PlatformsData, type SharedTokenData, Space, Spectrum, SpectrumCloudError, type SpectrumInstance, type SubscriptionData, type SubscriptionStatus, type TokenData, cloud, definePlatform };
package/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
+ import {
2
+ SpectrumCloudError,
3
+ cloud
4
+ } from "./chunk-XOBTWTFC.js";
1
5
  import {
2
6
  mergeStreams,
3
7
  stream
4
8
  } from "./chunk-3TBRO2J7.js";
5
9
  import {
6
10
  definePlatform
7
- } from "./chunk-UZ2CXPOD.js";
11
+ } from "./chunk-LIRM7SBA.js";
8
12
 
9
13
  // src/spectrum.ts
10
14
  import z from "zod";
@@ -339,7 +343,9 @@ function attachment(input, options) {
339
343
  }
340
344
  export {
341
345
  Spectrum,
346
+ SpectrumCloudError,
342
347
  attachment,
348
+ cloud,
343
349
  custom,
344
350
  definePlatform,
345
351
  mergeStreams,
@@ -3,7 +3,7 @@ import { AdvancedIMessage } from '@photon-ai/advanced-imessage';
3
3
  import { IMessageSDK } from '@photon-ai/imessage-kit';
4
4
  import * as z from 'zod';
5
5
  import z__default from 'zod';
6
- import { k as SchemaMessage, b as Platform, a as PlatformDef, P as ProviderMessage } from '../../types-eXHZpal1.js';
6
+ import { k as SchemaMessage, b as Platform, a as PlatformDef, P as ProviderMessage } from '../../types-DQE0dQT4.js';
7
7
  import * as zod_v4_core from 'zod/v4/core';
8
8
  import 'hotscript';
9
9
  import 'type-fest';
@@ -1,10 +1,13 @@
1
+ import {
2
+ cloud
3
+ } from "../../chunk-XOBTWTFC.js";
1
4
  import {
2
5
  mergeStreams,
3
6
  stream
4
7
  } from "../../chunk-3TBRO2J7.js";
5
8
  import {
6
9
  definePlatform
7
- } from "../../chunk-UZ2CXPOD.js";
10
+ } from "../../chunk-LIRM7SBA.js";
8
11
 
9
12
  // src/providers/imessage/index.ts
10
13
  import { createClient as createClient2, directChat } from "@photon-ai/advanced-imessage";
@@ -14,40 +17,12 @@ import { IMessageSDK as IMessageSDK2 } from "@photon-ai/imessage-kit";
14
17
  import {
15
18
  createClient
16
19
  } from "@photon-ai/advanced-imessage";
17
-
18
- // src/utils/cloud.ts
19
- var SPECTRUM_CLOUD_URL = `https://${process.env.SPECTRUM_CLOUD_URL ?? "spectrum-cloud.photon.codes"}`;
20
-
21
- // src/providers/imessage/auth.ts
22
20
  var RENEWAL_RATIO = 0.8;
23
21
  var EXPIRY_BUFFER_MS = 3e4;
24
22
  var RETRY_DELAY_MS = 3e4;
25
23
  var cloudAuthState = /* @__PURE__ */ new WeakMap();
26
- async function fetchTokens(projectId, projectSecret) {
27
- const url = `${SPECTRUM_CLOUD_URL}/${projectId}/imessage/tokens`;
28
- const credentials = btoa(`${projectId}:${projectSecret}`);
29
- const response = await fetch(url, {
30
- method: "POST",
31
- headers: {
32
- Authorization: `Basic ${credentials}`
33
- }
34
- });
35
- if (!response.ok) {
36
- const body = await response.text().catch(() => "");
37
- throw new Error(
38
- `Spectrum Cloud authentication failed (${response.status}): ${body || response.statusText}`
39
- );
40
- }
41
- const json = await response.json();
42
- if (!json.succeed) {
43
- throw new Error(
44
- "Spectrum Cloud authentication failed: server returned succeed=false"
45
- );
46
- }
47
- return json.data;
48
- }
49
24
  async function createCloudClients(projectId, projectSecret) {
50
- let tokenData = await fetchTokens(projectId, projectSecret);
25
+ let tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
51
26
  let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
52
27
  let disposed = false;
53
28
  let renewalTimer;
@@ -59,7 +34,7 @@ async function createCloudClients(projectId, projectSecret) {
59
34
  const renewInMs = Math.max(ttlMs * RENEWAL_RATIO, 5e3);
60
35
  renewalTimer = setTimeout(async () => {
61
36
  try {
62
- tokenData = await fetchTokens(projectId, projectSecret);
37
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
63
38
  tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
64
39
  scheduleRenewal();
65
40
  } catch {
@@ -74,7 +49,7 @@ async function createCloudClients(projectId, projectSecret) {
74
49
  if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) {
75
50
  return;
76
51
  }
77
- tokenData = await fetchTokens(projectId, projectSecret);
52
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
78
53
  tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
79
54
  scheduleRenewal();
80
55
  };
@@ -1,4 +1,4 @@
1
- import { b as Platform, a as PlatformDef, P as ProviderMessage } from '../../types-eXHZpal1.js';
1
+ import { b as Platform, a as PlatformDef, P as ProviderMessage } from '../../types-DQE0dQT4.js';
2
2
  import * as node_readline from 'node:readline';
3
3
  import z__default from 'zod';
4
4
  import 'hotscript';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  definePlatform
3
- } from "../../chunk-UZ2CXPOD.js";
3
+ } from "../../chunk-LIRM7SBA.js";
4
4
 
5
5
  // src/providers/terminal/index.ts
6
6
  import { createInterface } from "readline";
@@ -0,0 +1,41 @@
1
+ import { M as ManagedStream } from '../../stream-DGy4geUK.js';
2
+ import * as z from 'zod';
3
+ import z__default from 'zod';
4
+ import { k as SchemaMessage, b as Platform, a as PlatformDef, P as ProviderMessage } from '../../types-DQE0dQT4.js';
5
+ import * as zod_v4_core from 'zod/v4/core';
6
+ import { WhatsAppClient } from '@photon-ai/whatsapp-business';
7
+ import 'hotscript';
8
+ import 'type-fest';
9
+
10
+ declare const userSchema: z__default.ZodObject<{}, z__default.core.$strip>;
11
+ declare const spaceSchema: z__default.ZodObject<{
12
+ id: z__default.ZodString;
13
+ }, z__default.core.$strip>;
14
+ type WhatsAppMessage = SchemaMessage<typeof userSchema, typeof spaceSchema>;
15
+
16
+ declare const whatsappBusiness: Platform<PlatformDef<"WhatsApp Business", z.ZodObject<{
17
+ accessToken: z.ZodString;
18
+ phoneNumberId: z.ZodString;
19
+ appSecret: z.ZodOptional<z.ZodString>;
20
+ }, zod_v4_core.$strip>, z.ZodType<object, unknown, zod_v4_core.$ZodTypeInternals<object, unknown>> | undefined, z.ZodObject<{
21
+ id: z.ZodString;
22
+ }, zod_v4_core.$strip>, z.ZodType<object, unknown, zod_v4_core.$ZodTypeInternals<object, unknown>> | undefined, WhatsAppClient, {
23
+ id: string;
24
+ }, {
25
+ id: string;
26
+ }, undefined, ProviderMessage<{
27
+ id: string;
28
+ }, {
29
+ id: string;
30
+ }, Record<never, never>>, {
31
+ messages: ({ client }: {
32
+ client: WhatsAppClient;
33
+ config: {
34
+ accessToken: string;
35
+ phoneNumberId: string;
36
+ appSecret?: string | undefined;
37
+ };
38
+ }) => ManagedStream<WhatsAppMessage>;
39
+ }>> & Readonly<Record<never, never>>;
40
+
41
+ export { whatsappBusiness };
@@ -0,0 +1,280 @@
1
+ import {
2
+ stream
3
+ } from "../../chunk-3TBRO2J7.js";
4
+ import {
5
+ definePlatform
6
+ } from "../../chunk-LIRM7SBA.js";
7
+
8
+ // src/providers/whatsapp-business/index.ts
9
+ import {
10
+ createClient
11
+ } from "@photon-ai/whatsapp-business";
12
+
13
+ // src/providers/whatsapp-business/messages.ts
14
+ var toMessage = async (client, msg) => {
15
+ const content = await mapContent(client, msg.content);
16
+ return {
17
+ id: msg.id,
18
+ content,
19
+ sender: { id: msg.from },
20
+ space: { id: msg.from },
21
+ timestamp: msg.timestamp
22
+ };
23
+ };
24
+ var mapContent = async (client, content) => {
25
+ switch (content.type) {
26
+ case "text":
27
+ return [{ type: "plain_text", text: content.body }];
28
+ case "image":
29
+ case "video":
30
+ case "audio":
31
+ case "document":
32
+ return [await downloadMedia(client, content.media)];
33
+ case "sticker":
34
+ return [
35
+ {
36
+ type: "custom",
37
+ raw: { whatsapp_type: "sticker", ...content.sticker }
38
+ }
39
+ ];
40
+ case "location":
41
+ return [
42
+ {
43
+ type: "custom",
44
+ raw: { whatsapp_type: "location", ...content.location }
45
+ }
46
+ ];
47
+ case "contacts":
48
+ return [
49
+ {
50
+ type: "custom",
51
+ raw: { whatsapp_type: "contacts", contacts: content.contacts }
52
+ }
53
+ ];
54
+ case "reaction":
55
+ return [
56
+ {
57
+ type: "custom",
58
+ raw: { whatsapp_type: "reaction", ...content.reaction }
59
+ }
60
+ ];
61
+ case "interactive":
62
+ return [
63
+ {
64
+ type: "custom",
65
+ raw: { whatsapp_type: "interactive", ...content.interactive }
66
+ }
67
+ ];
68
+ case "button":
69
+ return [
70
+ {
71
+ type: "custom",
72
+ raw: { whatsapp_type: "button", ...content.button }
73
+ }
74
+ ];
75
+ case "order":
76
+ return [
77
+ { type: "custom", raw: { whatsapp_type: "order", ...content.order } }
78
+ ];
79
+ case "system":
80
+ return [
81
+ {
82
+ type: "custom",
83
+ raw: { whatsapp_type: "system", ...content.system }
84
+ }
85
+ ];
86
+ default:
87
+ return [{ type: "custom", raw: { whatsapp_type: "unknown" } }];
88
+ }
89
+ };
90
+ var downloadMedia = async (client, media) => {
91
+ try {
92
+ const { url } = await client.media.getUrl(media.id);
93
+ const response = await fetch(url);
94
+ if (!response.ok) {
95
+ throw new Error(`Media download failed: ${response.status}`);
96
+ }
97
+ const data = Buffer.from(await response.arrayBuffer());
98
+ return {
99
+ type: "attachment",
100
+ data,
101
+ mimeType: media.mimeType,
102
+ name: media.filename ?? `media-${media.id}`
103
+ };
104
+ } catch {
105
+ return {
106
+ type: "custom",
107
+ raw: {
108
+ whatsapp_type: "media_error",
109
+ mediaId: media.id,
110
+ mimeType: media.mimeType
111
+ }
112
+ };
113
+ }
114
+ };
115
+ var mimeToMediaType = (mimeType) => {
116
+ if (mimeType.startsWith("image/")) {
117
+ return "image";
118
+ }
119
+ if (mimeType.startsWith("video/")) {
120
+ return "video";
121
+ }
122
+ if (mimeType.startsWith("audio/")) {
123
+ return "audio";
124
+ }
125
+ return "document";
126
+ };
127
+ var messages = (client) => {
128
+ const eventStream = client.events.subscribe().filter(
129
+ (e) => e.type === "message"
130
+ );
131
+ return stream((emit, end) => {
132
+ (async () => {
133
+ try {
134
+ for await (const event of eventStream) {
135
+ const msg = await toMessage(client, event.message);
136
+ emit(msg);
137
+ }
138
+ end();
139
+ } catch (e) {
140
+ end(e);
141
+ }
142
+ })();
143
+ return () => eventStream.close();
144
+ });
145
+ };
146
+ var send = async (client, spaceId, content) => {
147
+ switch (content.type) {
148
+ case "plain_text":
149
+ await client.messages.send({ to: spaceId, text: content.text });
150
+ break;
151
+ case "attachment": {
152
+ const { mediaId } = await client.media.upload({
153
+ file: content.data,
154
+ mimeType: content.mimeType,
155
+ filename: content.name
156
+ });
157
+ const mediaType = mimeToMediaType(content.mimeType);
158
+ const mediaPayload = mediaType === "document" ? { id: mediaId, filename: content.name } : { id: mediaId };
159
+ await client.messages.send({
160
+ to: spaceId,
161
+ [mediaType]: mediaPayload
162
+ });
163
+ break;
164
+ }
165
+ default:
166
+ break;
167
+ }
168
+ };
169
+ var reactToMessage = async (client, spaceId, messageId, reaction) => {
170
+ await client.messages.send({
171
+ to: spaceId,
172
+ reaction: { messageId, emoji: reaction }
173
+ });
174
+ };
175
+ var replyToMessage = async (client, spaceId, messageId, content) => {
176
+ switch (content.type) {
177
+ case "plain_text":
178
+ await client.messages.send({
179
+ to: spaceId,
180
+ replyTo: messageId,
181
+ text: content.text
182
+ });
183
+ break;
184
+ case "attachment": {
185
+ const { mediaId } = await client.media.upload({
186
+ file: content.data,
187
+ mimeType: content.mimeType,
188
+ filename: content.name
189
+ });
190
+ const mediaType = mimeToMediaType(content.mimeType);
191
+ const mediaPayload = mediaType === "document" ? { id: mediaId, filename: content.name } : { id: mediaId };
192
+ await client.messages.send({
193
+ to: spaceId,
194
+ replyTo: messageId,
195
+ [mediaType]: mediaPayload
196
+ });
197
+ break;
198
+ }
199
+ default:
200
+ break;
201
+ }
202
+ };
203
+
204
+ // src/providers/whatsapp-business/types.ts
205
+ import z from "zod";
206
+ var configSchema = z.object({
207
+ accessToken: z.string().min(1),
208
+ phoneNumberId: z.string().min(1),
209
+ appSecret: z.string().optional()
210
+ });
211
+ var userSchema = z.object({});
212
+ var spaceSchema = z.object({
213
+ id: z.string()
214
+ });
215
+
216
+ // src/providers/whatsapp-business/index.ts
217
+ var whatsappBusiness = definePlatform("WhatsApp Business", {
218
+ config: configSchema,
219
+ user: {
220
+ resolve: async ({ input }) => ({ id: input.userID })
221
+ },
222
+ space: {
223
+ schema: spaceSchema,
224
+ resolve: async ({ input }) => {
225
+ if (input.users.length === 0) {
226
+ throw new Error("WhatsApp space creation requires at least one user");
227
+ }
228
+ if (input.users.length > 1) {
229
+ throw new Error(
230
+ "WhatsApp Business API only supports 1:1 conversations"
231
+ );
232
+ }
233
+ const user = input.users[0];
234
+ if (!user) {
235
+ throw new Error("WhatsApp space creation requires a user");
236
+ }
237
+ return { id: user.id };
238
+ }
239
+ },
240
+ lifecycle: {
241
+ createClient: async ({ config }) => {
242
+ return createClient({
243
+ accessToken: config.accessToken,
244
+ phoneNumberId: config.phoneNumberId,
245
+ appSecret: config.appSecret ?? ""
246
+ });
247
+ },
248
+ destroyClient: async ({ client }) => {
249
+ await client.close();
250
+ }
251
+ },
252
+ events: {
253
+ messages: ({ client }) => messages(client)
254
+ },
255
+ actions: {
256
+ send: async ({ space, content, client }) => {
257
+ const wa = client;
258
+ for (const item of content) {
259
+ await send(wa, space.id, item);
260
+ }
261
+ },
262
+ reactToMessage: async ({ space, messageId, reaction, client }) => {
263
+ await reactToMessage(
264
+ client,
265
+ space.id,
266
+ messageId,
267
+ reaction
268
+ );
269
+ },
270
+ replyToMessage: async ({ space, messageId, content, client }) => {
271
+ const wa = client;
272
+ for (const item of content) {
273
+ await replyToMessage(wa, space.id, messageId, item);
274
+ }
275
+ }
276
+ }
277
+ });
278
+ export {
279
+ whatsappBusiness
280
+ };
@@ -252,7 +252,7 @@ interface SpectrumLike<Providers extends PlatformProviderConfig[] = PlatformProv
252
252
  readonly __providers: Providers;
253
253
  }
254
254
  interface Platform<Def extends AnyPlatformDef> {
255
- config(config?: z__default.input<Def["config"]>): PlatformProviderConfig<Def>;
255
+ config(...args: Record<string, never> extends z__default.input<Def["config"]> ? [config?: z__default.input<Def["config"]>] : [config: z__default.input<Def["config"]>]): PlatformProviderConfig<Def>;
256
256
  <Providers extends PlatformProviderConfig[]>(spectrum: SpectrumLike<Providers>): HasProvider<Providers, Def["name"]> extends true ? PlatformInstance<Def> : never;
257
257
  (space: Space): PlatformSpace<Def>;
258
258
  (message: Message): PlatformMessage<Def>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-ts",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -24,6 +24,11 @@
24
24
  "types": "./dist/providers/terminal/index.d.ts",
25
25
  "bun": "./src/providers/terminal/index.ts",
26
26
  "default": "./dist/providers/terminal/index.js"
27
+ },
28
+ "./providers/whatsapp-business": {
29
+ "types": "./dist/providers/whatsapp-business/index.d.ts",
30
+ "bun": "./src/providers/whatsapp-business/index.ts",
31
+ "default": "./dist/providers/whatsapp-business/index.js"
27
32
  }
28
33
  },
29
34
  "scripts": {
@@ -32,6 +37,7 @@
32
37
  },
33
38
  "dependencies": {
34
39
  "@photon-ai/advanced-imessage": "^0.1.0",
40
+ "@photon-ai/whatsapp-business": "^0.1.1",
35
41
  "@photon-ai/imessage-kit": "^2.1.2",
36
42
  "@repeaterjs/repeater": "^3.0.6",
37
43
  "better-grpc": "^0.3.2",
package/src/index.ts CHANGED
@@ -23,4 +23,16 @@ export {
23
23
  export type { Message } from "./types/message";
24
24
  export type { Space } from "./types/space";
25
25
  export type { User } from "./types/user";
26
+ export type {
27
+ CloudPlatform,
28
+ DedicatedTokenData,
29
+ ImessageInfoData,
30
+ PlatformStatus,
31
+ PlatformsData,
32
+ SharedTokenData,
33
+ SubscriptionData,
34
+ SubscriptionStatus,
35
+ TokenData,
36
+ } from "./utils/cloud";
37
+ export { cloud, SpectrumCloudError } from "./utils/cloud";
26
38
  export { type ManagedStream, mergeStreams, stream } from "./utils/stream";
@@ -289,14 +289,13 @@ export function definePlatform<
289
289
  throw new Error("Invalid input to platform narrowing function");
290
290
  }) as Platform<Def>;
291
291
 
292
- narrower.config = (
293
- config: z.input<_ConfigSchema> = {} as z.input<_ConfigSchema>
294
- ) => {
292
+ narrower.config = (config?: z.input<_ConfigSchema>) => {
293
+ const resolvedConfig = config ?? {};
295
294
  return {
296
295
  __tag: "PlatformProviderConfig" as const,
297
296
  __def: undefined as unknown as Def,
298
297
  __name: name,
299
- config,
298
+ config: resolvedConfig,
300
299
  __definition: fullDef as AnyPlatformDef,
301
300
  } satisfies PlatformProviderConfig<Def> as PlatformProviderConfig<Def>;
302
301
  };
@@ -423,7 +423,11 @@ export interface SpectrumLike<
423
423
  // ---------------------------------------------------------------------------
424
424
 
425
425
  export interface Platform<Def extends AnyPlatformDef> {
426
- config(config?: z.input<Def["config"]>): PlatformProviderConfig<Def>;
426
+ config(
427
+ ...args: Record<string, never> extends z.input<Def["config"]>
428
+ ? [config?: z.input<Def["config"]>]
429
+ : [config: z.input<Def["config"]>]
430
+ ): PlatformProviderConfig<Def>;
427
431
  <Providers extends PlatformProviderConfig[]>(
428
432
  spectrum: SpectrumLike<Providers>
429
433
  ): HasProvider<Providers, Def["name"]> extends true
@@ -2,73 +2,27 @@ import {
2
2
  type AdvancedIMessage,
3
3
  createClient,
4
4
  } from "@photon-ai/advanced-imessage";
5
- import { SPECTRUM_CLOUD_URL } from "../../utils/cloud";
5
+ import {
6
+ cloud,
7
+ type DedicatedTokenData,
8
+ type SharedTokenData,
9
+ } from "../../utils/cloud";
6
10
 
7
11
  const RENEWAL_RATIO = 0.8;
8
12
  const EXPIRY_BUFFER_MS = 30_000;
9
13
  const RETRY_DELAY_MS = 30_000;
10
14
 
11
- interface SharedTokenData {
12
- expiresIn: number;
13
- token: string;
14
- type: "shared";
15
- }
16
-
17
- interface DedicatedTokenData {
18
- auth: Record<string, string>;
19
- expiresIn: number;
20
- type: "dedicated";
21
- }
22
-
23
- type TokenData = SharedTokenData | DedicatedTokenData;
24
-
25
- interface TokenResponse {
26
- data: TokenData;
27
- succeed: boolean;
28
- }
29
-
30
15
  interface CloudAuth {
31
16
  dispose: () => void;
32
17
  }
33
18
 
34
19
  const cloudAuthState = new WeakMap<AdvancedIMessage[], CloudAuth>();
35
20
 
36
- async function fetchTokens(
37
- projectId: string,
38
- projectSecret: string
39
- ): Promise<TokenData> {
40
- const url = `${SPECTRUM_CLOUD_URL}/${projectId}/imessage/tokens`;
41
- const credentials = btoa(`${projectId}:${projectSecret}`);
42
-
43
- const response = await fetch(url, {
44
- method: "POST",
45
- headers: {
46
- Authorization: `Basic ${credentials}`,
47
- },
48
- });
49
-
50
- if (!response.ok) {
51
- const body = await response.text().catch(() => "");
52
- throw new Error(
53
- `Spectrum Cloud authentication failed (${response.status}): ${body || response.statusText}`
54
- );
55
- }
56
-
57
- const json = (await response.json()) as TokenResponse;
58
- if (!json.succeed) {
59
- throw new Error(
60
- "Spectrum Cloud authentication failed: server returned succeed=false"
61
- );
62
- }
63
-
64
- return json.data;
65
- }
66
-
67
21
  export async function createCloudClients(
68
22
  projectId: string,
69
23
  projectSecret: string
70
24
  ): Promise<AdvancedIMessage[]> {
71
- let tokenData = await fetchTokens(projectId, projectSecret);
25
+ let tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
72
26
  let tokenExpiresAt = Date.now() + tokenData.expiresIn * 1000;
73
27
  let disposed = false;
74
28
  let renewalTimer: ReturnType<typeof setTimeout> | undefined;
@@ -82,7 +36,7 @@ export async function createCloudClients(
82
36
 
83
37
  renewalTimer = setTimeout(async () => {
84
38
  try {
85
- tokenData = await fetchTokens(projectId, projectSecret);
39
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
86
40
  tokenExpiresAt = Date.now() + tokenData.expiresIn * 1000;
87
41
  scheduleRenewal();
88
42
  } catch {
@@ -99,7 +53,7 @@ export async function createCloudClients(
99
53
  if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) {
100
54
  return;
101
55
  }
102
- tokenData = await fetchTokens(projectId, projectSecret);
56
+ tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
103
57
  tokenExpiresAt = Date.now() + tokenData.expiresIn * 1000;
104
58
  scheduleRenewal();
105
59
  };
@@ -0,0 +1,77 @@
1
+ import {
2
+ createClient,
3
+ type WhatsAppClient,
4
+ } from "@photon-ai/whatsapp-business";
5
+ import { definePlatform } from "../../platform/define";
6
+ import { messages, reactToMessage, replyToMessage, send } from "./messages";
7
+ import { configSchema, spaceSchema } from "./types";
8
+
9
+ export const whatsappBusiness = definePlatform("WhatsApp Business", {
10
+ config: configSchema,
11
+
12
+ user: {
13
+ resolve: async ({ input }) => ({ id: input.userID }),
14
+ },
15
+
16
+ space: {
17
+ schema: spaceSchema,
18
+ resolve: async ({ input }) => {
19
+ if (input.users.length === 0) {
20
+ throw new Error("WhatsApp space creation requires at least one user");
21
+ }
22
+ if (input.users.length > 1) {
23
+ throw new Error(
24
+ "WhatsApp Business API only supports 1:1 conversations"
25
+ );
26
+ }
27
+ const user = input.users[0];
28
+ if (!user) {
29
+ throw new Error("WhatsApp space creation requires a user");
30
+ }
31
+ return { id: user.id };
32
+ },
33
+ },
34
+
35
+ lifecycle: {
36
+ createClient: async ({ config }): Promise<WhatsAppClient> => {
37
+ return createClient({
38
+ accessToken: config.accessToken,
39
+ phoneNumberId: config.phoneNumberId,
40
+ appSecret: config.appSecret ?? "",
41
+ });
42
+ },
43
+
44
+ destroyClient: async ({ client }: { client: WhatsAppClient }) => {
45
+ await client.close();
46
+ },
47
+ },
48
+
49
+ events: {
50
+ messages: ({ client }) => messages(client as WhatsAppClient),
51
+ },
52
+
53
+ actions: {
54
+ send: async ({ space, content, client }) => {
55
+ const wa = client as WhatsAppClient;
56
+ for (const item of content) {
57
+ await send(wa, space.id, item);
58
+ }
59
+ },
60
+
61
+ reactToMessage: async ({ space, messageId, reaction, client }) => {
62
+ await reactToMessage(
63
+ client as WhatsAppClient,
64
+ space.id,
65
+ messageId,
66
+ reaction
67
+ );
68
+ },
69
+
70
+ replyToMessage: async ({ space, messageId, content, client }) => {
71
+ const wa = client as WhatsAppClient;
72
+ for (const item of content) {
73
+ await replyToMessage(wa, space.id, messageId, item);
74
+ }
75
+ },
76
+ },
77
+ });
@@ -0,0 +1,240 @@
1
+ import type {
2
+ InboundMessage,
3
+ WhatsAppClient,
4
+ } from "@photon-ai/whatsapp-business";
5
+ import type { Content } from "../../types/content";
6
+ import { type ManagedStream, stream } from "../../utils/stream";
7
+ import type { WhatsAppMessage } from "./types";
8
+
9
+ const toMessage = async (
10
+ client: WhatsAppClient,
11
+ msg: InboundMessage
12
+ ): Promise<WhatsAppMessage> => {
13
+ const content = await mapContent(client, msg.content);
14
+ return {
15
+ id: msg.id,
16
+ content,
17
+ sender: { id: msg.from },
18
+ space: { id: msg.from },
19
+ timestamp: msg.timestamp,
20
+ };
21
+ };
22
+
23
+ const mapContent = async (
24
+ client: WhatsAppClient,
25
+ content: InboundMessage["content"]
26
+ ): Promise<Content[]> => {
27
+ switch (content.type) {
28
+ case "text":
29
+ return [{ type: "plain_text", text: content.body }];
30
+ case "image":
31
+ case "video":
32
+ case "audio":
33
+ case "document":
34
+ return [await downloadMedia(client, content.media)];
35
+ case "sticker":
36
+ return [
37
+ {
38
+ type: "custom",
39
+ raw: { whatsapp_type: "sticker", ...content.sticker },
40
+ },
41
+ ];
42
+ case "location":
43
+ return [
44
+ {
45
+ type: "custom",
46
+ raw: { whatsapp_type: "location", ...content.location },
47
+ },
48
+ ];
49
+ case "contacts":
50
+ return [
51
+ {
52
+ type: "custom",
53
+ raw: { whatsapp_type: "contacts", contacts: content.contacts },
54
+ },
55
+ ];
56
+ case "reaction":
57
+ return [
58
+ {
59
+ type: "custom",
60
+ raw: { whatsapp_type: "reaction", ...content.reaction },
61
+ },
62
+ ];
63
+ case "interactive":
64
+ return [
65
+ {
66
+ type: "custom",
67
+ raw: { whatsapp_type: "interactive", ...content.interactive },
68
+ },
69
+ ];
70
+ case "button":
71
+ return [
72
+ {
73
+ type: "custom",
74
+ raw: { whatsapp_type: "button", ...content.button },
75
+ },
76
+ ];
77
+ case "order":
78
+ return [
79
+ { type: "custom", raw: { whatsapp_type: "order", ...content.order } },
80
+ ];
81
+ case "system":
82
+ return [
83
+ {
84
+ type: "custom",
85
+ raw: { whatsapp_type: "system", ...content.system },
86
+ },
87
+ ];
88
+ default:
89
+ return [{ type: "custom", raw: { whatsapp_type: "unknown" } }];
90
+ }
91
+ };
92
+
93
+ const downloadMedia = async (
94
+ client: WhatsAppClient,
95
+ media: { id: string; mimeType: string; filename?: string }
96
+ ): Promise<Content> => {
97
+ try {
98
+ const { url } = await client.media.getUrl(media.id);
99
+ const response = await fetch(url);
100
+ if (!response.ok) {
101
+ throw new Error(`Media download failed: ${response.status}`);
102
+ }
103
+ const data = Buffer.from(await response.arrayBuffer());
104
+ return {
105
+ type: "attachment",
106
+ data,
107
+ mimeType: media.mimeType,
108
+ name: media.filename ?? `media-${media.id}`,
109
+ };
110
+ } catch {
111
+ return {
112
+ type: "custom",
113
+ raw: {
114
+ whatsapp_type: "media_error",
115
+ mediaId: media.id,
116
+ mimeType: media.mimeType,
117
+ },
118
+ };
119
+ }
120
+ };
121
+
122
+ const mimeToMediaType = (
123
+ mimeType: string
124
+ ): "image" | "video" | "audio" | "document" => {
125
+ if (mimeType.startsWith("image/")) {
126
+ return "image";
127
+ }
128
+ if (mimeType.startsWith("video/")) {
129
+ return "video";
130
+ }
131
+ if (mimeType.startsWith("audio/")) {
132
+ return "audio";
133
+ }
134
+ return "document";
135
+ };
136
+
137
+ export const messages = (
138
+ client: WhatsAppClient
139
+ ): ManagedStream<WhatsAppMessage> => {
140
+ const eventStream = client.events
141
+ .subscribe()
142
+ .filter(
143
+ (e): e is Extract<typeof e, { type: "message" }> => e.type === "message"
144
+ );
145
+
146
+ return stream<WhatsAppMessage>((emit, end) => {
147
+ (async () => {
148
+ try {
149
+ for await (const event of eventStream) {
150
+ const msg = await toMessage(client, event.message);
151
+ emit(msg);
152
+ }
153
+ end();
154
+ } catch (e) {
155
+ end(e);
156
+ }
157
+ })();
158
+ return () => eventStream.close();
159
+ });
160
+ };
161
+
162
+ export const send = async (
163
+ client: WhatsAppClient,
164
+ spaceId: string,
165
+ content: Content
166
+ ): Promise<void> => {
167
+ switch (content.type) {
168
+ case "plain_text":
169
+ await client.messages.send({ to: spaceId, text: content.text });
170
+ break;
171
+ case "attachment": {
172
+ const { mediaId } = await client.media.upload({
173
+ file: content.data,
174
+ mimeType: content.mimeType,
175
+ filename: content.name,
176
+ });
177
+ const mediaType = mimeToMediaType(content.mimeType);
178
+ const mediaPayload =
179
+ mediaType === "document"
180
+ ? { id: mediaId, filename: content.name }
181
+ : { id: mediaId };
182
+ await client.messages.send({
183
+ to: spaceId,
184
+ [mediaType]: mediaPayload,
185
+ } as Parameters<typeof client.messages.send>[0]);
186
+ break;
187
+ }
188
+ default:
189
+ break;
190
+ }
191
+ };
192
+
193
+ export const reactToMessage = async (
194
+ client: WhatsAppClient,
195
+ spaceId: string,
196
+ messageId: string,
197
+ reaction: string
198
+ ): Promise<void> => {
199
+ await client.messages.send({
200
+ to: spaceId,
201
+ reaction: { messageId, emoji: reaction },
202
+ });
203
+ };
204
+
205
+ export const replyToMessage = async (
206
+ client: WhatsAppClient,
207
+ spaceId: string,
208
+ messageId: string,
209
+ content: Content
210
+ ): Promise<void> => {
211
+ switch (content.type) {
212
+ case "plain_text":
213
+ await client.messages.send({
214
+ to: spaceId,
215
+ replyTo: messageId,
216
+ text: content.text,
217
+ });
218
+ break;
219
+ case "attachment": {
220
+ const { mediaId } = await client.media.upload({
221
+ file: content.data,
222
+ mimeType: content.mimeType,
223
+ filename: content.name,
224
+ });
225
+ const mediaType = mimeToMediaType(content.mimeType);
226
+ const mediaPayload =
227
+ mediaType === "document"
228
+ ? { id: mediaId, filename: content.name }
229
+ : { id: mediaId };
230
+ await client.messages.send({
231
+ to: spaceId,
232
+ replyTo: messageId,
233
+ [mediaType]: mediaPayload,
234
+ } as Parameters<typeof client.messages.send>[0]);
235
+ break;
236
+ }
237
+ default:
238
+ break;
239
+ }
240
+ };
@@ -0,0 +1,19 @@
1
+ import z from "zod";
2
+ import type { SchemaMessage } from "../../platform/types";
3
+
4
+ export const configSchema = z.object({
5
+ accessToken: z.string().min(1),
6
+ phoneNumberId: z.string().min(1),
7
+ appSecret: z.string().optional(),
8
+ });
9
+
10
+ export const userSchema = z.object({});
11
+
12
+ export const spaceSchema = z.object({
13
+ id: z.string(),
14
+ });
15
+
16
+ export type WhatsAppMessage = SchemaMessage<
17
+ typeof userSchema,
18
+ typeof spaceSchema
19
+ >;
@@ -1 +1,147 @@
1
1
  export const SPECTRUM_CLOUD_URL = `https://${process.env.SPECTRUM_CLOUD_URL ?? "spectrum-cloud.photon.codes"}`;
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // API response types (aligned with OpenAPI spec)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type SubscriptionStatus = "active" | "canceled" | "past_due";
8
+
9
+ export interface SubscriptionData {
10
+ status: SubscriptionStatus | null;
11
+ tier: string;
12
+ }
13
+
14
+ export interface SharedTokenData {
15
+ expiresIn: number;
16
+ token: string;
17
+ type: "shared";
18
+ }
19
+
20
+ export interface DedicatedTokenData {
21
+ auth: Record<string, string>;
22
+ expiresIn: number;
23
+ type: "dedicated";
24
+ }
25
+
26
+ export type TokenData = SharedTokenData | DedicatedTokenData;
27
+
28
+ export type CloudPlatform = "imessage" | "whatsapp_business";
29
+
30
+ export interface PlatformStatus {
31
+ enabled: boolean;
32
+ }
33
+
34
+ export type PlatformsData = Record<CloudPlatform, PlatformStatus>;
35
+
36
+ export interface ImessageInfoData {
37
+ type: "shared" | "dedicated";
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Error
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export class SpectrumCloudError extends Error {
45
+ readonly status: number;
46
+ readonly code: string;
47
+
48
+ constructor(status: number, code: string, message: string) {
49
+ super(message);
50
+ this.name = "SpectrumCloudError";
51
+ this.status = status;
52
+ this.code = code;
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Internal helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ interface SuccessResponse<T> {
61
+ data: T;
62
+ succeed: true;
63
+ }
64
+
65
+ interface ErrorBody {
66
+ code: string;
67
+ message: string;
68
+ succeed: false;
69
+ }
70
+
71
+ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
72
+ const response = await fetch(`${SPECTRUM_CLOUD_URL}${path}`, init);
73
+
74
+ if (!response.ok) {
75
+ const body = await response.text().catch(() => "");
76
+ try {
77
+ const parsed = JSON.parse(body) as ErrorBody;
78
+ throw new SpectrumCloudError(
79
+ response.status,
80
+ parsed.code,
81
+ parsed.message
82
+ );
83
+ } catch (error) {
84
+ if (error instanceof SpectrumCloudError) {
85
+ throw error;
86
+ }
87
+ throw new SpectrumCloudError(
88
+ response.status,
89
+ "UNKNOWN",
90
+ body || response.statusText
91
+ );
92
+ }
93
+ }
94
+
95
+ const json = (await response.json()) as SuccessResponse<T>;
96
+ if (!json.succeed) {
97
+ throw new SpectrumCloudError(
98
+ response.status,
99
+ "UNKNOWN",
100
+ "Server returned succeed=false"
101
+ );
102
+ }
103
+
104
+ return json.data;
105
+ };
106
+
107
+ const basicAuth = (projectId: string, projectSecret: string): string =>
108
+ `Basic ${btoa(`${projectId}:${projectSecret}`)}`;
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Cloud API client
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export const cloud = {
115
+ getSubscription: (projectId: string): Promise<SubscriptionData> =>
116
+ request(`/projects/${projectId}/billing/subscription`),
117
+
118
+ issueImessageTokens: (
119
+ projectId: string,
120
+ projectSecret: string
121
+ ): Promise<TokenData> =>
122
+ request(`/projects/${projectId}/imessage/tokens`, {
123
+ method: "POST",
124
+ headers: { Authorization: basicAuth(projectId, projectSecret) },
125
+ }),
126
+
127
+ getImessageInfo: (projectId: string): Promise<ImessageInfoData> =>
128
+ request(`/projects/${projectId}/imessage/`),
129
+
130
+ getPlatforms: (projectId: string): Promise<PlatformsData> =>
131
+ request(`/projects/${projectId}/platforms/`),
132
+
133
+ togglePlatform: (
134
+ projectId: string,
135
+ projectSecret: string,
136
+ platform: CloudPlatform,
137
+ enabled: boolean
138
+ ): Promise<PlatformsData> =>
139
+ request(`/projects/${projectId}/platforms/`, {
140
+ method: "PATCH",
141
+ headers: {
142
+ Authorization: basicAuth(projectId, projectSecret),
143
+ "Content-Type": "application/json",
144
+ },
145
+ body: JSON.stringify({ platform, enabled }),
146
+ }),
147
+ };