spectrum-ts 0.1.2 → 0.2.1
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/chunk-HXM64ENV.js +67 -0
- package/dist/{chunk-UZ2CXPOD.js → chunk-LIRM7SBA.js} +3 -2
- package/dist/index.d.ts +40 -3
- package/dist/index.js +7 -1
- package/dist/providers/imessage/index.d.ts +1 -1
- package/dist/providers/imessage/index.js +8 -33
- package/dist/providers/terminal/index.d.ts +1 -1
- package/dist/providers/terminal/index.js +1 -1
- package/dist/providers/whatsapp-business/index.d.ts +41 -0
- package/dist/providers/whatsapp-business/index.js +280 -0
- package/dist/{types-eXHZpal1.d.ts → types-DQE0dQT4.d.ts} +1 -1
- package/package.json +8 -2
- package/src/index.ts +12 -0
- package/src/platform/define.ts +3 -4
- package/src/platform/types.ts +5 -1
- package/src/providers/imessage/auth.ts +9 -55
- package/src/providers/whatsapp-business/index.ts +77 -0
- package/src/providers/whatsapp-business/messages.ts +240 -0
- package/src/providers/whatsapp-business/types.ts +19 -0
- package/src/utils/cloud.ts +147 -1
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
};
|
|
@@ -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
|
};
|
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-
|
|
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-
|
|
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
|
-
|
|
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-HXM64ENV.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-
|
|
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-
|
|
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-HXM64ENV.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-
|
|
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
|
|
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
|
|
37
|
+
tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
63
38
|
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
64
39
|
scheduleRenewal();
|
|
65
40
|
} catch {
|
|
@@ -74,13 +49,13 @@ async function createCloudClients(projectId, projectSecret) {
|
|
|
74
49
|
if (Date.now() < tokenExpiresAt - EXPIRY_BUFFER_MS) {
|
|
75
50
|
return;
|
|
76
51
|
}
|
|
77
|
-
tokenData = await
|
|
52
|
+
tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
78
53
|
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1e3;
|
|
79
54
|
scheduleRenewal();
|
|
80
55
|
};
|
|
81
56
|
const buildClients = () => {
|
|
82
57
|
if (tokenData.type === "shared") {
|
|
83
|
-
const address = process.env.SPECTRUM_IMESSAGE_ADDRESS ?? "
|
|
58
|
+
const address = process.env.SPECTRUM_IMESSAGE_ADDRESS ?? "imessage.spectrum.photon.codes:443";
|
|
84
59
|
return [
|
|
85
60
|
createClient({
|
|
86
61
|
address,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { b as Platform, a as PlatformDef, P as ProviderMessage } from '../../types-
|
|
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';
|
|
@@ -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
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -31,7 +36,8 @@
|
|
|
31
36
|
"dev": "tsup --watch"
|
|
32
37
|
},
|
|
33
38
|
"dependencies": {
|
|
34
|
-
"@photon-ai/advanced-imessage": "^0.
|
|
39
|
+
"@photon-ai/advanced-imessage": "^0.4.2",
|
|
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";
|
package/src/platform/define.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/platform/types.ts
CHANGED
|
@@ -423,7 +423,11 @@ export interface SpectrumLike<
|
|
|
423
423
|
// ---------------------------------------------------------------------------
|
|
424
424
|
|
|
425
425
|
export interface Platform<Def extends AnyPlatformDef> {
|
|
426
|
-
config(
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
56
|
+
tokenData = await cloud.issueImessageTokens(projectId, projectSecret);
|
|
103
57
|
tokenExpiresAt = Date.now() + tokenData.expiresIn * 1000;
|
|
104
58
|
scheduleRenewal();
|
|
105
59
|
};
|
|
@@ -108,7 +62,7 @@ export async function createCloudClients(
|
|
|
108
62
|
if (tokenData.type === "shared") {
|
|
109
63
|
const address =
|
|
110
64
|
process.env.SPECTRUM_IMESSAGE_ADDRESS ??
|
|
111
|
-
"
|
|
65
|
+
"imessage.spectrum.photon.codes:443";
|
|
112
66
|
|
|
113
67
|
return [
|
|
114
68
|
createClient({
|
|
@@ -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/utils/cloud.ts
CHANGED
|
@@ -1 +1,147 @@
|
|
|
1
|
-
export const SPECTRUM_CLOUD_URL = `https://${process.env.SPECTRUM_CLOUD_URL ?? "spectrum
|
|
1
|
+
export const SPECTRUM_CLOUD_URL = `https://${process.env.SPECTRUM_CLOUD_URL ?? "spectrum.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
|
+
};
|