spectrum-ts 0.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-ts",
3
- "version": "0.1.1",
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
  };
@@ -140,8 +140,8 @@ export interface PlatformDef<
140
140
  lifecycle: {
141
141
  createClient: (ctx: {
142
142
  config: z.infer<_ConfigSchema>;
143
- projectId: string;
144
- projectSecret: string;
143
+ projectId: string | undefined;
144
+ projectSecret: string | undefined;
145
145
  }) => Promise<_Client>;
146
146
  destroyClient: (ctx: { client: _Client }) => Promise<void>;
147
147
  };
@@ -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
  };
@@ -87,6 +87,14 @@ export const imessage = definePlatform("iMessage", {
87
87
  );
88
88
  }
89
89
 
90
+ if (!(projectId && projectSecret)) {
91
+ throw new Error(
92
+ "iMessage requires projectId and projectSecret. " +
93
+ "Either pass credentials to Spectrum(), use local mode: imessage.config({ local: true }), " +
94
+ "or provide explicit client config: imessage.config({ clients: [...] })"
95
+ );
96
+ }
97
+
90
98
  return await createCloudClients(projectId, projectSecret);
91
99
  },
92
100
 
@@ -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
+ >;
package/src/spectrum.ts CHANGED
@@ -47,11 +47,18 @@ export type SpectrumInstance<
47
47
  // Config validation
48
48
  // ---------------------------------------------------------------------------
49
49
 
50
- const spectrumConfigSchema = z.object({
51
- projectId: z.string().min(1),
52
- projectSecret: z.string().min(1),
53
- providers: z.array(z.custom<PlatformProviderConfig>()),
54
- });
50
+ const spectrumConfigSchema = z.union([
51
+ z.object({
52
+ projectId: z.string().min(1),
53
+ projectSecret: z.string().min(1),
54
+ providers: z.array(z.custom<PlatformProviderConfig>()),
55
+ }),
56
+ z.object({
57
+ projectId: z.undefined().optional(),
58
+ projectSecret: z.undefined().optional(),
59
+ providers: z.array(z.custom<PlatformProviderConfig>()),
60
+ }),
61
+ ]);
55
62
 
56
63
  // ---------------------------------------------------------------------------
57
64
  // Spectrum() factory
@@ -60,15 +67,21 @@ const spectrumConfigSchema = z.object({
60
67
  export async function Spectrum<
61
68
  const Providers extends PlatformProviderConfig[],
62
69
  >(
63
- projectId: string,
64
- projectSecret: string,
65
- options: { providers: [...Providers] }
70
+ options:
71
+ | {
72
+ projectId: string;
73
+ projectSecret: string;
74
+ providers: [...Providers];
75
+ }
76
+ | {
77
+ projectId?: never;
78
+ projectSecret?: never;
79
+ providers: [...Providers];
80
+ }
66
81
  ): Promise<SpectrumInstance<Providers>> {
67
- spectrumConfigSchema.parse({
68
- projectId,
69
- projectSecret,
70
- providers: options.providers,
71
- });
82
+ spectrumConfigSchema.parse(options);
83
+
84
+ const { projectId, projectSecret, providers } = options;
72
85
 
73
86
  const platformStates = new Map<
74
87
  string,
@@ -81,7 +94,7 @@ export async function Spectrum<
81
94
  let stopped = false;
82
95
 
83
96
  // Initialize all provider clients eagerly
84
- for (const provider of options.providers) {
97
+ for (const provider of providers) {
85
98
  const providerConfig = provider as PlatformProviderConfig;
86
99
  const def = providerConfig.__definition;
87
100
  const userConfig = def.config.parse(providerConfig.config);
@@ -344,7 +357,7 @@ export async function Spectrum<
344
357
  );
345
358
 
346
359
  const base = {
347
- __providers: options.providers,
360
+ __providers: providers,
348
361
  __internal: { platforms: platformStates },
349
362
  messages,
350
363
  stop: stopOnce,