openclaw-seatalk 0.1.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/LICENSE +201 -0
- package/README.md +260 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +54 -0
- package/src/accounts.ts +94 -0
- package/src/bot.ts +698 -0
- package/src/channel.ts +359 -0
- package/src/client.ts +329 -0
- package/src/config-schema.ts +71 -0
- package/src/media.ts +163 -0
- package/src/monitor.ts +205 -0
- package/src/onboarding.ts +507 -0
- package/src/outbound.ts +120 -0
- package/src/probe.ts +34 -0
- package/src/relay-client.ts +204 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +71 -0
- package/src/targets.ts +27 -0
- package/src/tool-schema.ts +60 -0
- package/src/tool.ts +180 -0
- package/src/types.ts +94 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
3
|
+
import {
|
|
4
|
+
listSeaTalkAccountIds,
|
|
5
|
+
resolveDefaultSeaTalkAccountId,
|
|
6
|
+
resolveSeaTalkAccount,
|
|
7
|
+
} from "./accounts.js";
|
|
8
|
+
import { resolveSeaTalkClient } from "./client.js";
|
|
9
|
+
import { seatalkOnboardingAdapter } from "./onboarding.js";
|
|
10
|
+
import { seatalkOutbound } from "./outbound.js";
|
|
11
|
+
import { probeSeaTalk } from "./probe.js";
|
|
12
|
+
import { looksLikeEmail, looksLikeSeaTalkId, normalizeSeaTalkTarget } from "./targets.js";
|
|
13
|
+
import type { ResolvedSeaTalkAccount, SeaTalkConfig } from "./types.js";
|
|
14
|
+
|
|
15
|
+
const meta: ChannelMeta = {
|
|
16
|
+
id: "seatalk",
|
|
17
|
+
label: "SeaTalk",
|
|
18
|
+
selectionLabel: "SeaTalk (plugin)",
|
|
19
|
+
blurb: "SeaTalk internal messaging integration.",
|
|
20
|
+
docsPath: "/channels/seatalk",
|
|
21
|
+
aliases: [],
|
|
22
|
+
order: 70,
|
|
23
|
+
quickstartAllowFrom: true,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const seatalkPlugin: ChannelPlugin<ResolvedSeaTalkAccount> = {
|
|
27
|
+
id: "seatalk",
|
|
28
|
+
meta,
|
|
29
|
+
capabilities: {
|
|
30
|
+
chatTypes: ["direct", "group"],
|
|
31
|
+
polls: false,
|
|
32
|
+
threads: true,
|
|
33
|
+
media: true,
|
|
34
|
+
reactions: false,
|
|
35
|
+
edit: false,
|
|
36
|
+
reply: false,
|
|
37
|
+
},
|
|
38
|
+
reload: { configPrefixes: ["channels.seatalk"] },
|
|
39
|
+
configSchema: {
|
|
40
|
+
schema: {
|
|
41
|
+
type: "object",
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
properties: {
|
|
44
|
+
enabled: { type: "boolean" },
|
|
45
|
+
appId: { type: "string" },
|
|
46
|
+
appSecret: { type: "string" },
|
|
47
|
+
signingSecret: { type: "string" },
|
|
48
|
+
mode: { type: "string", enum: ["webhook", "relay"] },
|
|
49
|
+
relayUrl: { type: "string" },
|
|
50
|
+
webhookPort: { type: "integer", minimum: 1 },
|
|
51
|
+
webhookPath: { type: "string" },
|
|
52
|
+
dmPolicy: { type: "string", enum: ["open", "allowlist"] },
|
|
53
|
+
allowFrom: { type: "array", items: { type: "string" } },
|
|
54
|
+
groupPolicy: { type: "string", enum: ["disabled", "allowlist", "open"] },
|
|
55
|
+
groupAllowFrom: { type: "array", items: { type: "string" } },
|
|
56
|
+
groupSenderAllowFrom: { type: "array", items: { type: "string" } },
|
|
57
|
+
processingIndicator: { type: "string", enum: ["typing", "off"] },
|
|
58
|
+
tools: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
groupInfo: { type: "boolean" },
|
|
62
|
+
groupHistory: { type: "boolean" },
|
|
63
|
+
groupList: { type: "boolean" },
|
|
64
|
+
threadHistory: { type: "boolean" },
|
|
65
|
+
getMessage: { type: "boolean" },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
accounts: {
|
|
69
|
+
type: "object",
|
|
70
|
+
additionalProperties: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
enabled: { type: "boolean" },
|
|
74
|
+
appId: { type: "string" },
|
|
75
|
+
appSecret: { type: "string" },
|
|
76
|
+
signingSecret: { type: "string" },
|
|
77
|
+
mode: { type: "string", enum: ["webhook", "relay"] },
|
|
78
|
+
relayUrl: { type: "string" },
|
|
79
|
+
webhookPort: { type: "integer", minimum: 1 },
|
|
80
|
+
webhookPath: { type: "string" },
|
|
81
|
+
dmPolicy: { type: "string", enum: ["open", "allowlist"] },
|
|
82
|
+
allowFrom: { type: "array", items: { type: "string" } },
|
|
83
|
+
groupPolicy: {
|
|
84
|
+
type: "string",
|
|
85
|
+
enum: ["disabled", "allowlist", "open"],
|
|
86
|
+
},
|
|
87
|
+
groupAllowFrom: { type: "array", items: { type: "string" } },
|
|
88
|
+
groupSenderAllowFrom: { type: "array", items: { type: "string" } },
|
|
89
|
+
processingIndicator: { type: "string", enum: ["typing", "off"] },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
config: {
|
|
97
|
+
listAccountIds: (cfg) => listSeaTalkAccountIds(cfg),
|
|
98
|
+
resolveAccount: (cfg, accountId) => resolveSeaTalkAccount({ cfg, accountId }),
|
|
99
|
+
defaultAccountId: (cfg) => resolveDefaultSeaTalkAccountId(cfg),
|
|
100
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
101
|
+
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
102
|
+
|
|
103
|
+
if (isDefault) {
|
|
104
|
+
return {
|
|
105
|
+
...cfg,
|
|
106
|
+
channels: {
|
|
107
|
+
...cfg.channels,
|
|
108
|
+
seatalk: {
|
|
109
|
+
...cfg.channels?.seatalk,
|
|
110
|
+
enabled,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const seatalkCfg = cfg.channels?.seatalk as SeaTalkConfig | undefined;
|
|
117
|
+
return {
|
|
118
|
+
...cfg,
|
|
119
|
+
channels: {
|
|
120
|
+
...cfg.channels,
|
|
121
|
+
seatalk: {
|
|
122
|
+
...seatalkCfg,
|
|
123
|
+
accounts: {
|
|
124
|
+
...seatalkCfg?.accounts,
|
|
125
|
+
[accountId]: {
|
|
126
|
+
...seatalkCfg?.accounts?.[accountId],
|
|
127
|
+
enabled,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
deleteAccount: ({ cfg, accountId }) => {
|
|
135
|
+
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
136
|
+
|
|
137
|
+
if (isDefault) {
|
|
138
|
+
const next = { ...cfg } as ClawdbotConfig;
|
|
139
|
+
const nextChannels = { ...cfg.channels } as Record<string, unknown>;
|
|
140
|
+
nextChannels.seatalk = undefined;
|
|
141
|
+
const hasOtherChannels = Object.values(nextChannels).some((v) => v !== undefined);
|
|
142
|
+
next.channels = hasOtherChannels ? nextChannels : undefined;
|
|
143
|
+
return next;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const seatalkCfg = cfg.channels?.seatalk as SeaTalkConfig | undefined;
|
|
147
|
+
const accounts = { ...seatalkCfg?.accounts };
|
|
148
|
+
delete accounts[accountId];
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
...cfg,
|
|
152
|
+
channels: {
|
|
153
|
+
...cfg.channels,
|
|
154
|
+
seatalk: {
|
|
155
|
+
...seatalkCfg,
|
|
156
|
+
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
isConfigured: (account) => account.configured,
|
|
162
|
+
describeAccount: (account) => ({
|
|
163
|
+
accountId: account.accountId,
|
|
164
|
+
enabled: account.enabled,
|
|
165
|
+
configured: account.configured,
|
|
166
|
+
appId: account.appId,
|
|
167
|
+
mode: account.mode,
|
|
168
|
+
...(account.mode === "relay"
|
|
169
|
+
? { relayUrl: account.relayUrl }
|
|
170
|
+
: { webhookPort: account.webhookPort }),
|
|
171
|
+
}),
|
|
172
|
+
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
173
|
+
const account = resolveSeaTalkAccount({ cfg, accountId });
|
|
174
|
+
return (account.config?.allowFrom ?? []).map((entry) => String(entry));
|
|
175
|
+
},
|
|
176
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
177
|
+
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
|
|
178
|
+
},
|
|
179
|
+
security: {
|
|
180
|
+
collectWarnings: ({ cfg, accountId }) => {
|
|
181
|
+
const account = resolveSeaTalkAccount({ cfg, accountId });
|
|
182
|
+
const seatalkCfg = account.config;
|
|
183
|
+
const dmPolicy = seatalkCfg?.dmPolicy ?? "allowlist";
|
|
184
|
+
if (dmPolicy !== "open") return [];
|
|
185
|
+
return [
|
|
186
|
+
`- SeaTalk[${account.accountId}]: dmPolicy="open" allows any subscriber to message the bot. Set channels.seatalk.dmPolicy="allowlist" + channels.seatalk.allowFrom to restrict senders.`,
|
|
187
|
+
];
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
setup: {
|
|
191
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
192
|
+
applyAccountConfig: ({ cfg, accountId }) => {
|
|
193
|
+
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
|
|
194
|
+
|
|
195
|
+
if (isDefault) {
|
|
196
|
+
return {
|
|
197
|
+
...cfg,
|
|
198
|
+
channels: {
|
|
199
|
+
...cfg.channels,
|
|
200
|
+
seatalk: {
|
|
201
|
+
...cfg.channels?.seatalk,
|
|
202
|
+
enabled: true,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const seatalkCfg = cfg.channels?.seatalk as SeaTalkConfig | undefined;
|
|
209
|
+
return {
|
|
210
|
+
...cfg,
|
|
211
|
+
channels: {
|
|
212
|
+
...cfg.channels,
|
|
213
|
+
seatalk: {
|
|
214
|
+
...seatalkCfg,
|
|
215
|
+
accounts: {
|
|
216
|
+
...seatalkCfg?.accounts,
|
|
217
|
+
[accountId]: {
|
|
218
|
+
...seatalkCfg?.accounts?.[accountId],
|
|
219
|
+
enabled: true,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
onboarding: seatalkOnboardingAdapter,
|
|
228
|
+
messaging: {
|
|
229
|
+
normalizeTarget: (raw) => normalizeSeaTalkTarget(raw) ?? undefined,
|
|
230
|
+
targetResolver: {
|
|
231
|
+
looksLikeId: looksLikeSeaTalkId,
|
|
232
|
+
hint: "<employee_code> or <email>",
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
resolver: {
|
|
236
|
+
resolveTargets: async ({ cfg, accountId, inputs }) => {
|
|
237
|
+
const emailInputs = inputs.filter((i) => looksLikeEmail(i));
|
|
238
|
+
if (emailInputs.length === 0) {
|
|
239
|
+
return inputs.map((input) => ({ input, resolved: true, id: input }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const passNonEmails = (note: string) =>
|
|
243
|
+
inputs.map((input) => {
|
|
244
|
+
const isEmail = looksLikeEmail(input);
|
|
245
|
+
return {
|
|
246
|
+
input,
|
|
247
|
+
resolved: !isEmail,
|
|
248
|
+
id: isEmail ? undefined : input,
|
|
249
|
+
note: isEmail ? note : undefined,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const account = resolveSeaTalkAccount({ cfg, accountId });
|
|
254
|
+
const client = resolveSeaTalkClient(account);
|
|
255
|
+
if (!client) {
|
|
256
|
+
return passNonEmails("SeaTalk client not available");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const emailToCode = new Map<string, string>();
|
|
260
|
+
try {
|
|
261
|
+
const results = await client.getEmployeeCodeByEmail(emailInputs);
|
|
262
|
+
for (const r of results) {
|
|
263
|
+
if (r.employeeCode && r.status === 2) {
|
|
264
|
+
emailToCode.set(r.email.toLowerCase(), r.employeeCode);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
return passNonEmails("Failed to resolve email");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return inputs.map((input) => {
|
|
272
|
+
if (!looksLikeEmail(input)) {
|
|
273
|
+
return { input, resolved: true, id: input };
|
|
274
|
+
}
|
|
275
|
+
const code = emailToCode.get(input.toLowerCase());
|
|
276
|
+
if (code) {
|
|
277
|
+
return { input, resolved: true, id: code, name: input };
|
|
278
|
+
}
|
|
279
|
+
return { input, resolved: false, note: "No active employee found for this email" };
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
outbound: seatalkOutbound,
|
|
284
|
+
status: {
|
|
285
|
+
defaultRuntime: {
|
|
286
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
287
|
+
running: false,
|
|
288
|
+
lastStartAt: null,
|
|
289
|
+
lastStopAt: null,
|
|
290
|
+
lastError: null,
|
|
291
|
+
port: null,
|
|
292
|
+
},
|
|
293
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
294
|
+
configured: snapshot.configured ?? false,
|
|
295
|
+
running: snapshot.running ?? false,
|
|
296
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
297
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
298
|
+
lastError: snapshot.lastError ?? null,
|
|
299
|
+
port: snapshot.port ?? null,
|
|
300
|
+
probe: snapshot.probe,
|
|
301
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
302
|
+
}),
|
|
303
|
+
probeAccount: ({ account }) => probeSeaTalk(account),
|
|
304
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
305
|
+
accountId: account.accountId,
|
|
306
|
+
enabled: account.enabled,
|
|
307
|
+
configured: account.configured,
|
|
308
|
+
appId: account.appId,
|
|
309
|
+
mode: account.mode,
|
|
310
|
+
...(account.mode === "relay"
|
|
311
|
+
? { relayUrl: account.relayUrl }
|
|
312
|
+
: { webhookPort: account.webhookPort }),
|
|
313
|
+
running: runtime?.running ?? false,
|
|
314
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
315
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
316
|
+
lastError: runtime?.lastError ?? null,
|
|
317
|
+
port: runtime?.port ?? null,
|
|
318
|
+
probe,
|
|
319
|
+
}),
|
|
320
|
+
},
|
|
321
|
+
gateway: {
|
|
322
|
+
startAccount: async (ctx) => {
|
|
323
|
+
const account = resolveSeaTalkAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
|
|
324
|
+
const mode = account.mode;
|
|
325
|
+
|
|
326
|
+
if (mode === "relay") {
|
|
327
|
+
if (!account.relayUrl) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`SeaTalk account "${ctx.accountId}" mode=relay but relayUrl is not configured`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
ctx.setStatus({ accountId: ctx.accountId, mode: "relay" });
|
|
333
|
+
ctx.log?.info(
|
|
334
|
+
`starting seatalk[${ctx.accountId}] (relay client → ${account.relayUrl})`,
|
|
335
|
+
);
|
|
336
|
+
const { connectSeaTalkRelay } = await import("./relay-client.js");
|
|
337
|
+
return connectSeaTalkRelay({
|
|
338
|
+
config: ctx.cfg,
|
|
339
|
+
runtime: ctx.runtime,
|
|
340
|
+
abortSignal: ctx.abortSignal,
|
|
341
|
+
accountId: ctx.accountId,
|
|
342
|
+
relayUrl: account.relayUrl,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
ctx.setStatus({ accountId: ctx.accountId, port: account.webhookPort });
|
|
347
|
+
ctx.log?.info(
|
|
348
|
+
`starting seatalk[${ctx.accountId}] (webhook on port ${account.webhookPort})`,
|
|
349
|
+
);
|
|
350
|
+
const { monitorSeaTalkProvider } = await import("./monitor.js");
|
|
351
|
+
return monitorSeaTalkProvider({
|
|
352
|
+
config: ctx.cfg,
|
|
353
|
+
runtime: ctx.runtime,
|
|
354
|
+
abortSignal: ctx.abortSignal,
|
|
355
|
+
accountId: ctx.accountId,
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { SeaTalkTokenInfo } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const BASE_URL = "https://openapi.seatalk.io";
|
|
4
|
+
const HTTP_TIMEOUT_MS = 10_000;
|
|
5
|
+
const TOKEN_REFRESH_MARGIN_S = 30;
|
|
6
|
+
|
|
7
|
+
export class SeaTalkClient {
|
|
8
|
+
private appId: string;
|
|
9
|
+
private appSecret: string;
|
|
10
|
+
private tokenInfo: SeaTalkTokenInfo | null = null;
|
|
11
|
+
private tokenPromise: Promise<SeaTalkTokenInfo> | null = null;
|
|
12
|
+
|
|
13
|
+
constructor(appId: string, appSecret: string) {
|
|
14
|
+
this.appId = appId;
|
|
15
|
+
this.appSecret = appSecret;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getAccessToken(): Promise<string> {
|
|
19
|
+
if (this.tokenInfo) {
|
|
20
|
+
const now = Math.floor(Date.now() / 1000);
|
|
21
|
+
if (this.tokenInfo.expireAt - now > TOKEN_REFRESH_MARGIN_S) {
|
|
22
|
+
return this.tokenInfo.token;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return (await this.refreshToken()).token;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async refreshToken(): Promise<SeaTalkTokenInfo> {
|
|
29
|
+
if (this.tokenPromise) {
|
|
30
|
+
return this.tokenPromise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.tokenPromise = this._fetchToken();
|
|
34
|
+
try {
|
|
35
|
+
const info = await this.tokenPromise;
|
|
36
|
+
this.tokenInfo = info;
|
|
37
|
+
return info;
|
|
38
|
+
} finally {
|
|
39
|
+
this.tokenPromise = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async _fetchToken(): Promise<SeaTalkTokenInfo> {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timeout = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${BASE_URL}/auth/app_access_token`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
app_id: this.appId,
|
|
53
|
+
app_secret: this.appSecret,
|
|
54
|
+
}),
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const xRid = res.headers.get("x-rid") ?? undefined;
|
|
60
|
+
throw new Error(
|
|
61
|
+
`SeaTalk token request failed: HTTP ${res.status} (x-rid: ${xRid})`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const data = (await res.json()) as {
|
|
66
|
+
code: number;
|
|
67
|
+
app_access_token?: string;
|
|
68
|
+
expire?: number;
|
|
69
|
+
message?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (data.code !== 0) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`SeaTalk token error: code=${data.code} message=${data.message ?? "unknown"}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!data.app_access_token || !data.expire) {
|
|
79
|
+
throw new Error("SeaTalk token response missing token or expire");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
token: data.app_access_token,
|
|
84
|
+
expireAt: data.expire,
|
|
85
|
+
};
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async apiCall<T = Record<string, unknown>>(
|
|
92
|
+
method: string,
|
|
93
|
+
path: string,
|
|
94
|
+
body?: unknown,
|
|
95
|
+
retry = true,
|
|
96
|
+
): Promise<T> {
|
|
97
|
+
const token = await this.getAccessToken();
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timeout = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
103
|
+
method,
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Bearer ${token}`,
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
},
|
|
108
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const xRid = res.headers.get("x-rid") ?? undefined;
|
|
113
|
+
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
throw Object.assign(
|
|
116
|
+
new Error(`SeaTalk API error: HTTP ${res.status} (x-rid: ${xRid})`),
|
|
117
|
+
{ httpStatus: res.status, xRid },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const data = (await res.json()) as { code: number; message?: string } & T;
|
|
122
|
+
|
|
123
|
+
if (data.code === 100 && retry) {
|
|
124
|
+
await this.refreshToken();
|
|
125
|
+
return this.apiCall<T>(method, path, body, false);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (data.code === 101) {
|
|
129
|
+
throw Object.assign(new Error("SeaTalk rate limit exceeded"), {
|
|
130
|
+
code: 101,
|
|
131
|
+
xRid,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (data.code !== 0) {
|
|
136
|
+
throw Object.assign(
|
|
137
|
+
new Error(
|
|
138
|
+
`SeaTalk API error: code=${data.code} message=${data.message ?? "unknown"} (x-rid: ${xRid})`,
|
|
139
|
+
),
|
|
140
|
+
{ code: data.code, xRid },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return data;
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async sendSingleChat(
|
|
151
|
+
employeeCode: string,
|
|
152
|
+
message: Record<string, unknown>,
|
|
153
|
+
threadId?: string,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const msg = threadId ? { ...message, thread_id: threadId } : message;
|
|
156
|
+
await this.apiCall("POST", "/messaging/v2/single_chat", {
|
|
157
|
+
employee_code: employeeCode,
|
|
158
|
+
message: msg,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async sendGroupChat(
|
|
163
|
+
groupId: string,
|
|
164
|
+
message: Record<string, unknown>,
|
|
165
|
+
threadId?: string,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const msg = threadId ? { ...message, thread_id: threadId } : message;
|
|
168
|
+
await this.apiCall("POST", "/messaging/v2/group_chat", {
|
|
169
|
+
group_id: groupId,
|
|
170
|
+
message: msg,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async setSingleChatTyping(employeeCode: string, threadId?: string): Promise<void> {
|
|
175
|
+
const body: Record<string, string> = { employee_code: employeeCode };
|
|
176
|
+
if (threadId) body.thread_id = threadId;
|
|
177
|
+
await this.apiCall("POST", "/messaging/v2/single_chat_typing", body);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async setGroupChatTyping(groupId: string, threadId?: string): Promise<void> {
|
|
181
|
+
const body: Record<string, string> = { group_id: groupId };
|
|
182
|
+
if (threadId) body.thread_id = threadId;
|
|
183
|
+
await this.apiCall("POST", "/messaging/v2/group_chat_typing", body);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async downloadMedia(url: string): Promise<{ buffer: Buffer; contentType: string }> {
|
|
187
|
+
const token = await this.getAccessToken();
|
|
188
|
+
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
const timeout = setTimeout(() => controller.abort(), 60_000);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(url, {
|
|
194
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
195
|
+
signal: controller.signal,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
throw new Error(`SeaTalk media download failed: HTTP ${res.status}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
203
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
204
|
+
return {
|
|
205
|
+
buffer: Buffer.from(arrayBuffer),
|
|
206
|
+
contentType,
|
|
207
|
+
};
|
|
208
|
+
} finally {
|
|
209
|
+
clearTimeout(timeout);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getEmployeeCodeByEmail(
|
|
214
|
+
emails: string[],
|
|
215
|
+
): Promise<Array<{ email: string; employeeCode: string | null; status: number }>> {
|
|
216
|
+
const BATCH_LIMIT = 500;
|
|
217
|
+
const results: Array<{ email: string; employeeCode: string | null; status: number }> = [];
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < emails.length; i += BATCH_LIMIT) {
|
|
220
|
+
const batch = emails.slice(i, i + BATCH_LIMIT);
|
|
221
|
+
const data = await this.apiCall<{
|
|
222
|
+
employees: Array<{
|
|
223
|
+
code: number;
|
|
224
|
+
email: string;
|
|
225
|
+
employee_code: string | null;
|
|
226
|
+
employee_status: number;
|
|
227
|
+
}>;
|
|
228
|
+
}>("POST", "/contacts/v2/get_employee_code_with_email", { emails: batch });
|
|
229
|
+
|
|
230
|
+
for (const e of data.employees ?? []) {
|
|
231
|
+
results.push({
|
|
232
|
+
email: e.email,
|
|
233
|
+
employeeCode: e.employee_code,
|
|
234
|
+
status: e.employee_status,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return results;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async getGroupChatHistory(
|
|
243
|
+
groupId: string,
|
|
244
|
+
opts?: { pageSize?: number; cursor?: string },
|
|
245
|
+
): Promise<Record<string, unknown>> {
|
|
246
|
+
const params = new URLSearchParams({
|
|
247
|
+
group_id: groupId,
|
|
248
|
+
page_size: String(opts?.pageSize ?? 50),
|
|
249
|
+
});
|
|
250
|
+
if (opts?.cursor) params.set("cursor", opts.cursor);
|
|
251
|
+
return this.apiCall("GET", `/messaging/v2/group_chat/history?${params}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async getJoinedGroupChats(opts?: { pageSize?: number; cursor?: string }): Promise<
|
|
255
|
+
Record<string, unknown>
|
|
256
|
+
> {
|
|
257
|
+
const params = new URLSearchParams();
|
|
258
|
+
if (opts?.pageSize) params.set("page_size", String(opts.pageSize));
|
|
259
|
+
if (opts?.cursor) params.set("cursor", opts.cursor);
|
|
260
|
+
const qs = params.toString();
|
|
261
|
+
return this.apiCall("GET", `/messaging/v2/group_chat/joined${qs ? `?${qs}` : ""}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async getGroupChatInfo(groupId: string): Promise<Record<string, unknown>> {
|
|
265
|
+
return this.apiCall(
|
|
266
|
+
"GET",
|
|
267
|
+
`/messaging/v2/group_chat/info?group_id=${encodeURIComponent(groupId)}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async getDmThread(
|
|
272
|
+
employeeCode: string,
|
|
273
|
+
threadId: string,
|
|
274
|
+
opts?: { pageSize?: number; cursor?: string },
|
|
275
|
+
): Promise<Record<string, unknown>> {
|
|
276
|
+
const params = new URLSearchParams({
|
|
277
|
+
employee_code: employeeCode,
|
|
278
|
+
thread_id: threadId,
|
|
279
|
+
});
|
|
280
|
+
if (opts?.pageSize) params.set("page_size", String(opts.pageSize));
|
|
281
|
+
if (opts?.cursor) params.set("cursor", opts.cursor);
|
|
282
|
+
return this.apiCall("GET", `/messaging/v2/single_chat/get_thread_by_thread_id?${params}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async getGroupThread(
|
|
286
|
+
groupId: string,
|
|
287
|
+
threadId: string,
|
|
288
|
+
opts?: { pageSize?: number; cursor?: string },
|
|
289
|
+
): Promise<Record<string, unknown>> {
|
|
290
|
+
const params = new URLSearchParams({
|
|
291
|
+
group_id: groupId,
|
|
292
|
+
thread_id: threadId,
|
|
293
|
+
});
|
|
294
|
+
if (opts?.pageSize) params.set("page_size", String(opts.pageSize));
|
|
295
|
+
if (opts?.cursor) params.set("cursor", opts.cursor);
|
|
296
|
+
return this.apiCall("GET", `/messaging/v2/group_chat/get_thread_by_thread_id?${params}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async getMessageByMessageId(messageId: string): Promise<Record<string, unknown>> {
|
|
300
|
+
return this.apiCall(
|
|
301
|
+
"GET",
|
|
302
|
+
`/messaging/v2/get_message_by_message_id?message_id=${encodeURIComponent(messageId)}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
getAppId(): string {
|
|
307
|
+
return this.appId;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const clientCache = new Map<string, SeaTalkClient>();
|
|
312
|
+
|
|
313
|
+
export function getSeaTalkClient(appId: string, appSecret: string): SeaTalkClient {
|
|
314
|
+
const key = `${appId}:${appSecret}`;
|
|
315
|
+
let client = clientCache.get(key);
|
|
316
|
+
if (!client) {
|
|
317
|
+
client = new SeaTalkClient(appId, appSecret);
|
|
318
|
+
clientCache.set(key, client);
|
|
319
|
+
}
|
|
320
|
+
return client;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function resolveSeaTalkClient(params: {
|
|
324
|
+
appId?: string;
|
|
325
|
+
appSecret?: string;
|
|
326
|
+
}): SeaTalkClient | null {
|
|
327
|
+
if (!params.appId || !params.appSecret) return null;
|
|
328
|
+
return getSeaTalkClient(params.appId, params.appSecret);
|
|
329
|
+
}
|