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.
@@ -0,0 +1,507 @@
1
+ import type {
2
+ ChannelOnboardingAdapter,
3
+ ChannelOnboardingDmPolicy,
4
+ ClawdbotConfig,
5
+ DmPolicy,
6
+ WizardPrompter,
7
+ } from "openclaw/plugin-sdk";
8
+ import { DEFAULT_ACCOUNT_ID, addWildcardAllowFrom } from "openclaw/plugin-sdk";
9
+ import { resolveSeaTalkCredentials } from "./accounts.js";
10
+ import { probeSeaTalk } from "./probe.js";
11
+ import type { SeaTalkConfig } from "./types.js";
12
+
13
+ const channel = "seatalk" as const;
14
+
15
+ function setSeaTalkDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
16
+ const allowFrom =
17
+ dmPolicy === "open"
18
+ ? addWildcardAllowFrom(cfg.channels?.seatalk?.allowFrom)?.map((entry) => String(entry))
19
+ : undefined;
20
+ return {
21
+ ...cfg,
22
+ channels: {
23
+ ...cfg.channels,
24
+ seatalk: {
25
+ ...cfg.channels?.seatalk,
26
+ dmPolicy,
27
+ ...(allowFrom ? { allowFrom } : {}),
28
+ },
29
+ },
30
+ };
31
+ }
32
+
33
+ function setSeaTalkAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
34
+ return {
35
+ ...cfg,
36
+ channels: {
37
+ ...cfg.channels,
38
+ seatalk: {
39
+ ...cfg.channels?.seatalk,
40
+ allowFrom,
41
+ },
42
+ },
43
+ };
44
+ }
45
+
46
+ function parseAllowFromInput(raw: string): string[] {
47
+ return raw
48
+ .split(/[\n,;]+/g)
49
+ .map((entry) => entry.trim())
50
+ .filter(Boolean);
51
+ }
52
+
53
+ async function promptSeaTalkAllowFrom(params: {
54
+ cfg: ClawdbotConfig;
55
+ prompter: WizardPrompter;
56
+ }): Promise<ClawdbotConfig> {
57
+ const existing = params.cfg.channels?.seatalk?.allowFrom ?? [];
58
+ await params.prompter.note(
59
+ [
60
+ "Allowlist SeaTalk DMs by email or employee_code.",
61
+ "Examples:",
62
+ "- alice@company.com",
63
+ "- e_12345678",
64
+ ].join("\n"),
65
+ "SeaTalk allowlist",
66
+ );
67
+
68
+ while (true) {
69
+ const entry = await params.prompter.text({
70
+ message: "SeaTalk allowFrom (emails or employee_codes)",
71
+ placeholder: "alice@company.com, e_xxxxx",
72
+ initialValue: existing[0] ? String(existing[0]) : undefined,
73
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
74
+ });
75
+ const parts = parseAllowFromInput(String(entry));
76
+ if (parts.length === 0) {
77
+ await params.prompter.note("Enter at least one user.", "SeaTalk allowlist");
78
+ continue;
79
+ }
80
+
81
+ const unique = [
82
+ ...new Set([
83
+ ...existing.map((v: string | number) => String(v).trim()).filter(Boolean),
84
+ ...parts,
85
+ ]),
86
+ ];
87
+ return setSeaTalkAllowFrom(params.cfg, unique);
88
+ }
89
+ }
90
+
91
+ async function promptCredentials(prompter: WizardPrompter): Promise<{
92
+ appId: string;
93
+ appSecret: string;
94
+ signingSecret: string;
95
+ }> {
96
+ const appId = String(
97
+ await prompter.text({
98
+ message: "Enter SeaTalk App ID",
99
+ validate: (value) => (value?.trim() ? undefined : "Required"),
100
+ }),
101
+ ).trim();
102
+ const appSecret = String(
103
+ await prompter.text({
104
+ message: "Enter SeaTalk App Secret",
105
+ validate: (value) => (value?.trim() ? undefined : "Required"),
106
+ }),
107
+ ).trim();
108
+ const signingSecret = String(
109
+ await prompter.text({
110
+ message: "Enter SeaTalk Signing Secret",
111
+ validate: (value) => (value?.trim() ? undefined : "Required"),
112
+ }),
113
+ ).trim();
114
+ return { appId, appSecret, signingSecret };
115
+ }
116
+
117
+ async function noteSeaTalkCredentialHelp(prompter: WizardPrompter): Promise<void> {
118
+ await prompter.note(
119
+ [
120
+ "1) Go to SeaTalk Open Platform (open.seatalk.io)",
121
+ "2) Create a Bot App",
122
+ "3) Get App ID and App Secret from Basic Info & Credentials",
123
+ "4) Get Signing Secret from Event Callback settings",
124
+ "5) Enable Bot capability and set status to Online",
125
+ '6) Enable "Send Message to Bot User" permission',
126
+ "Tip: you can also set SEATALK_APP_ID / SEATALK_APP_SECRET / SEATALK_SIGNING_SECRET env vars.",
127
+ ].join("\n"),
128
+ "SeaTalk credentials",
129
+ );
130
+ }
131
+
132
+ const dmPolicy: ChannelOnboardingDmPolicy = {
133
+ label: "SeaTalk",
134
+ channel,
135
+ policyKey: "channels.seatalk.dmPolicy",
136
+ allowFromKey: "channels.seatalk.allowFrom",
137
+ getCurrent: (cfg) =>
138
+ (cfg.channels?.seatalk as SeaTalkConfig | undefined)?.dmPolicy ?? "allowlist",
139
+ setPolicy: (cfg, policy) => setSeaTalkDmPolicy(cfg, policy),
140
+ promptAllowFrom: promptSeaTalkAllowFrom,
141
+ };
142
+
143
+ export const seatalkOnboardingAdapter: ChannelOnboardingAdapter = {
144
+ channel,
145
+ getStatus: async ({ cfg }) => {
146
+ const seatalkCfg = cfg.channels?.seatalk as SeaTalkConfig | undefined;
147
+ const configured = Boolean(resolveSeaTalkCredentials(seatalkCfg));
148
+
149
+ let probeResult = null;
150
+ if (configured && seatalkCfg) {
151
+ try {
152
+ probeResult = await probeSeaTalk({
153
+ appId: seatalkCfg.appId,
154
+ appSecret: seatalkCfg.appSecret,
155
+ });
156
+ } catch {
157
+ // ignore
158
+ }
159
+ }
160
+
161
+ const statusLines: string[] = [];
162
+ if (!configured) {
163
+ statusLines.push("SeaTalk: needs app credentials");
164
+ } else if (probeResult?.ok) {
165
+ statusLines.push(
166
+ `SeaTalk: connected (appId: ${probeResult.appId}, latency: ${probeResult.latencyMs}ms)`,
167
+ );
168
+ } else {
169
+ statusLines.push("SeaTalk: configured (connection not verified)");
170
+ }
171
+
172
+ return {
173
+ channel,
174
+ configured,
175
+ statusLines,
176
+ selectionHint: configured ? "configured" : "needs app creds",
177
+ quickstartScore: configured ? 2 : 0,
178
+ };
179
+ },
180
+
181
+ configure: async ({ cfg, prompter, forceAllowFrom }) => {
182
+ const seatalkCfg = cfg.channels?.seatalk as SeaTalkConfig | undefined;
183
+ const resolved = resolveSeaTalkCredentials(seatalkCfg);
184
+ const hasConfigCreds = Boolean(
185
+ seatalkCfg?.appId?.trim() &&
186
+ seatalkCfg?.appSecret?.trim() &&
187
+ seatalkCfg?.signingSecret?.trim(),
188
+ );
189
+ const canUseEnv = Boolean(
190
+ !hasConfigCreds &&
191
+ process.env.SEATALK_APP_ID?.trim() &&
192
+ process.env.SEATALK_APP_SECRET?.trim() &&
193
+ process.env.SEATALK_SIGNING_SECRET?.trim(),
194
+ );
195
+
196
+ let next = cfg;
197
+ let appId: string | null = null;
198
+ let appSecret: string | null = null;
199
+ let signingSecret: string | null = null;
200
+
201
+ if (!resolved) {
202
+ await noteSeaTalkCredentialHelp(prompter);
203
+ }
204
+
205
+ if (canUseEnv) {
206
+ const keepEnv = await prompter.confirm({
207
+ message:
208
+ "SEATALK_APP_ID + SEATALK_APP_SECRET + SEATALK_SIGNING_SECRET detected. Use env vars?",
209
+ initialValue: true,
210
+ });
211
+ if (keepEnv) {
212
+ next = {
213
+ ...next,
214
+ channels: {
215
+ ...next.channels,
216
+ seatalk: {
217
+ ...next.channels?.seatalk,
218
+ enabled: true,
219
+ dmPolicy:
220
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.dmPolicy ??
221
+ "allowlist",
222
+ },
223
+ },
224
+ };
225
+ } else {
226
+ ({ appId, appSecret, signingSecret } = await promptCredentials(prompter));
227
+ }
228
+ } else if (hasConfigCreds) {
229
+ const keep = await prompter.confirm({
230
+ message: "SeaTalk credentials already configured. Keep them?",
231
+ initialValue: true,
232
+ });
233
+ if (!keep) {
234
+ ({ appId, appSecret, signingSecret } = await promptCredentials(prompter));
235
+ }
236
+ } else {
237
+ ({ appId, appSecret, signingSecret } = await promptCredentials(prompter));
238
+ }
239
+
240
+ if (appId && appSecret && signingSecret) {
241
+ next = {
242
+ ...next,
243
+ channels: {
244
+ ...next.channels,
245
+ seatalk: {
246
+ ...next.channels?.seatalk,
247
+ enabled: true,
248
+ appId,
249
+ appSecret,
250
+ signingSecret,
251
+ dmPolicy:
252
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.dmPolicy ??
253
+ "allowlist",
254
+ },
255
+ },
256
+ };
257
+
258
+ try {
259
+ const probe = await probeSeaTalk({ appId, appSecret });
260
+ if (probe.ok) {
261
+ await prompter.note(
262
+ `Connected successfully (latency: ${probe.latencyMs}ms)`,
263
+ "SeaTalk connection test",
264
+ );
265
+ } else {
266
+ await prompter.note(
267
+ `Connection failed: ${probe.error ?? "unknown error"}`,
268
+ "SeaTalk connection test",
269
+ );
270
+ }
271
+ } catch (err) {
272
+ await prompter.note(
273
+ `Connection test failed: ${String(err)}`,
274
+ "SeaTalk connection test",
275
+ );
276
+ }
277
+
278
+ await prompter.note(
279
+ [
280
+ "Important reminders:",
281
+ '- Bot App must be set to "Online" status in SeaTalk Open Platform',
282
+ '- "Send Message to Bot User" permission must be enabled',
283
+ "- Configure the callback URL in Event Callback settings",
284
+ ].join("\n"),
285
+ "SeaTalk setup",
286
+ );
287
+ }
288
+
289
+ const currentMode =
290
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.mode ?? "webhook";
291
+ const modeChoice = await prompter.select({
292
+ message: "Gateway mode",
293
+ options: [
294
+ { value: "webhook", label: "Webhook — receive event callbacks directly (default)" },
295
+ { value: "relay", label: "Relay — connect to a relay service as client" },
296
+ ],
297
+ initialValue: currentMode,
298
+ });
299
+ const mode = String(modeChoice) as "webhook" | "relay";
300
+
301
+ next = {
302
+ ...next,
303
+ channels: {
304
+ ...next.channels,
305
+ seatalk: {
306
+ ...next.channels?.seatalk,
307
+ mode,
308
+ },
309
+ },
310
+ };
311
+
312
+ if (mode === "relay") {
313
+ const currentRelayUrl =
314
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.relayUrl ?? "";
315
+ const relayUrlInput = await prompter.text({
316
+ message: "Relay WebSocket URL",
317
+ placeholder: "ws://relay.example.com:8080/ws",
318
+ initialValue: currentRelayUrl || undefined,
319
+ validate: (value) => {
320
+ const v = String(value ?? "").trim();
321
+ if (!v) return "Required";
322
+ if (!v.startsWith("ws://") && !v.startsWith("wss://"))
323
+ return "Must be a ws:// or wss:// URL";
324
+ return undefined;
325
+ },
326
+ });
327
+ const relayUrl = String(relayUrlInput).trim();
328
+ next = {
329
+ ...next,
330
+ channels: {
331
+ ...next.channels,
332
+ seatalk: {
333
+ ...next.channels?.seatalk,
334
+ relayUrl,
335
+ },
336
+ },
337
+ };
338
+ } else {
339
+ const currentPort =
340
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.webhookPort ?? 8080;
341
+ const portInput = await prompter.text({
342
+ message: "Webhook port",
343
+ initialValue: String(currentPort),
344
+ validate: (value) => {
345
+ const n = Number(value);
346
+ return n > 0 && n < 65536 ? undefined : "Enter a valid port number (1-65535)";
347
+ },
348
+ });
349
+ const port = Number(portInput);
350
+ if (port && port !== currentPort) {
351
+ next = {
352
+ ...next,
353
+ channels: {
354
+ ...next.channels,
355
+ seatalk: {
356
+ ...next.channels?.seatalk,
357
+ webhookPort: port,
358
+ },
359
+ },
360
+ };
361
+ }
362
+
363
+ const currentPath =
364
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.webhookPath ?? "/callback";
365
+ const pathInput = await prompter.text({
366
+ message: "Webhook path",
367
+ initialValue: currentPath,
368
+ validate: (value) => {
369
+ const v = String(value ?? "").trim();
370
+ if (!v) return "Required";
371
+ if (!v.startsWith("/")) return "Path must start with /";
372
+ return undefined;
373
+ },
374
+ });
375
+ const webhookPath = String(pathInput ?? currentPath).trim();
376
+ if (webhookPath && webhookPath !== currentPath) {
377
+ next = {
378
+ ...next,
379
+ channels: {
380
+ ...next.channels,
381
+ seatalk: {
382
+ ...next.channels?.seatalk,
383
+ webhookPath,
384
+ },
385
+ },
386
+ };
387
+ }
388
+ }
389
+
390
+ const groupPolicyChoice = await prompter.select({
391
+ message: "Group chat policy",
392
+ options: [
393
+ { value: "disabled", label: "Disabled — ignore all group messages (default)" },
394
+ { value: "allowlist", label: "Allowlist — respond only in specific groups" },
395
+ { value: "open", label: "Open — respond in all groups the bot joins" },
396
+ ],
397
+ initialValue:
398
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.groupPolicy ?? "disabled",
399
+ });
400
+ const groupPolicy = String(groupPolicyChoice) as "disabled" | "allowlist" | "open";
401
+
402
+ next = {
403
+ ...next,
404
+ channels: {
405
+ ...next.channels,
406
+ seatalk: {
407
+ ...next.channels?.seatalk,
408
+ groupPolicy,
409
+ },
410
+ },
411
+ };
412
+
413
+ if (groupPolicy === "allowlist") {
414
+ const existingGroups =
415
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.groupAllowFrom ?? [];
416
+ const groupInput = await prompter.text({
417
+ message: "Allowed group IDs (comma-separated)",
418
+ placeholder: "group_abc123, group_def456",
419
+ initialValue: existingGroups.length > 0 ? existingGroups.join(", ") : undefined,
420
+ validate: (value) =>
421
+ String(value ?? "").trim() ? undefined : "Enter at least one group ID",
422
+ });
423
+ const groupAllowFrom = parseAllowFromInput(String(groupInput));
424
+ next = {
425
+ ...next,
426
+ channels: {
427
+ ...next.channels,
428
+ seatalk: {
429
+ ...next.channels?.seatalk,
430
+ groupAllowFrom,
431
+ },
432
+ },
433
+ };
434
+ }
435
+
436
+ if (groupPolicy !== "disabled") {
437
+ const wantSenderFilter = await prompter.confirm({
438
+ message: "Restrict which users can trigger the bot in groups? (sender allowlist)",
439
+ initialValue: true,
440
+ });
441
+ if (wantSenderFilter) {
442
+ const existingSenders =
443
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.groupSenderAllowFrom ??
444
+ [];
445
+ const senderInput = await prompter.text({
446
+ message: "Sender allowlist (emails or employee_codes, comma-separated)",
447
+ placeholder: "alice@company.com, e_12345678",
448
+ initialValue:
449
+ existingSenders.length > 0 ? existingSenders.join(", ") : undefined,
450
+ validate: (value) =>
451
+ String(value ?? "").trim() ? undefined : "Enter at least one user",
452
+ });
453
+ const groupSenderAllowFrom = parseAllowFromInput(String(senderInput));
454
+ next = {
455
+ ...next,
456
+ channels: {
457
+ ...next.channels,
458
+ seatalk: {
459
+ ...next.channels?.seatalk,
460
+ groupSenderAllowFrom,
461
+ },
462
+ },
463
+ };
464
+ }
465
+ }
466
+
467
+ const typingChoice = await prompter.select({
468
+ message: "Typing indicator",
469
+ options: [
470
+ {
471
+ value: "typing",
472
+ label: "Typing — show typing status while processing (default)",
473
+ },
474
+ { value: "off", label: "Off — no typing indicator" },
475
+ ],
476
+ initialValue:
477
+ (next.channels?.seatalk as SeaTalkConfig | undefined)?.processingIndicator ??
478
+ "typing",
479
+ });
480
+ next = {
481
+ ...next,
482
+ channels: {
483
+ ...next.channels,
484
+ seatalk: {
485
+ ...next.channels?.seatalk,
486
+ processingIndicator: String(typingChoice),
487
+ },
488
+ },
489
+ };
490
+
491
+ if (forceAllowFrom) {
492
+ next = await promptSeaTalkAllowFrom({ cfg: next, prompter });
493
+ }
494
+
495
+ return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
496
+ },
497
+
498
+ dmPolicy,
499
+
500
+ disable: (cfg) => ({
501
+ ...cfg,
502
+ channels: {
503
+ ...cfg.channels,
504
+ seatalk: { ...cfg.channels?.seatalk, enabled: false },
505
+ },
506
+ }),
507
+ };
@@ -0,0 +1,120 @@
1
+ import type { ChannelOutboundAdapter, ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { resolveSeaTalkAccount } from "./accounts.js";
3
+ import { type SeaTalkClient, resolveSeaTalkClient } from "./client.js";
4
+ import { prepareOutboundMedia } from "./media.js";
5
+ import { getSeatalkRuntime } from "./runtime.js";
6
+ import {
7
+ sendFileMessage,
8
+ sendGroupTextMessage,
9
+ sendImageMessage,
10
+ sendTextMessage,
11
+ } from "./send.js";
12
+ import { isGroupTarget, looksLikeEmail, parseGroupTarget } from "./targets.js";
13
+
14
+ function requireClient(cfg: ClawdbotConfig, accountId?: string): SeaTalkClient {
15
+ const account = resolveSeaTalkAccount({ cfg, accountId });
16
+ const client = resolveSeaTalkClient(account);
17
+ if (!client) {
18
+ throw new Error(`SeaTalk client not available for account ${account.accountId}`);
19
+ }
20
+ return client;
21
+ }
22
+
23
+ async function resolveEmployeeCode(client: SeaTalkClient, to: string): Promise<string> {
24
+ if (!looksLikeEmail(to)) return to;
25
+ const results = await client.getEmployeeCodeByEmail([to]);
26
+ const active = results.find((r) => r.employeeCode && r.status === 2);
27
+ if (active?.employeeCode) return active.employeeCode;
28
+ throw new Error(`No active SeaTalk employee found for email: ${to}`);
29
+ }
30
+
31
+ function resolveThreadId(threadId?: string | number | null): string | undefined {
32
+ if (threadId === null || threadId === undefined) return undefined;
33
+ return String(threadId);
34
+ }
35
+
36
+ export const seatalkOutbound: ChannelOutboundAdapter = {
37
+ deliveryMode: "direct",
38
+ chunker: (text, limit) => getSeatalkRuntime().channel.text.chunkMarkdownText(text, limit),
39
+ chunkerMode: "markdown",
40
+ textChunkLimit: 4000,
41
+
42
+ sendText: async ({ cfg, to, text, accountId, threadId }) => {
43
+ const client = requireClient(cfg, accountId ?? undefined);
44
+ const tid = resolveThreadId(threadId);
45
+
46
+ if (isGroupTarget(to)) {
47
+ const groupId = parseGroupTarget(to);
48
+ await sendGroupTextMessage(client, groupId, text, 1, tid);
49
+ return { channel: "seatalk", messageId: "", chatId: to };
50
+ }
51
+
52
+ const employeeCode = await resolveEmployeeCode(client, to);
53
+ await sendTextMessage(client, employeeCode, text, 1, tid);
54
+ return { channel: "seatalk", messageId: "", chatId: employeeCode };
55
+ },
56
+
57
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId, threadId }) => {
58
+ const client = requireClient(cfg, accountId ?? undefined);
59
+ const tid = resolveThreadId(threadId);
60
+
61
+ if (isGroupTarget(to)) {
62
+ const groupId = parseGroupTarget(to);
63
+ if (text?.trim()) {
64
+ await sendGroupTextMessage(client, groupId, text, 1, tid);
65
+ }
66
+ if (mediaUrl) {
67
+ try {
68
+ const media = await prepareOutboundMedia(mediaUrl);
69
+ if (media) {
70
+ if (media.sendAs === "image") {
71
+ await client.sendGroupChat(
72
+ groupId,
73
+ { tag: "image", image: { content: media.base64 } },
74
+ tid,
75
+ );
76
+ } else {
77
+ await client.sendGroupChat(
78
+ groupId,
79
+ {
80
+ tag: "file",
81
+ file: {
82
+ content: media.base64,
83
+ filename: media.filename || "file",
84
+ },
85
+ },
86
+ tid,
87
+ );
88
+ }
89
+ }
90
+ } catch (err) {
91
+ const fallbackText = `[Media send failed: ${err instanceof Error ? err.message : String(err)}]`;
92
+ await sendGroupTextMessage(client, groupId, fallbackText, 2, tid);
93
+ }
94
+ }
95
+ return { channel: "seatalk", messageId: "", chatId: to };
96
+ }
97
+
98
+ const employeeCode = await resolveEmployeeCode(client, to);
99
+ if (text?.trim()) {
100
+ await sendTextMessage(client, employeeCode, text, 1, tid);
101
+ }
102
+ if (mediaUrl) {
103
+ try {
104
+ const media = await prepareOutboundMedia(mediaUrl);
105
+ if (media) {
106
+ if (media.sendAs === "image") {
107
+ await sendImageMessage(client, employeeCode, media.base64, tid);
108
+ } else {
109
+ const filename = media.filename || "file";
110
+ await sendFileMessage(client, employeeCode, media.base64, filename, tid);
111
+ }
112
+ }
113
+ } catch (err) {
114
+ const fallbackText = `[Media send failed: ${err instanceof Error ? err.message : String(err)}]`;
115
+ await sendTextMessage(client, employeeCode, fallbackText, 2, tid);
116
+ }
117
+ }
118
+ return { channel: "seatalk", messageId: "", chatId: employeeCode };
119
+ },
120
+ };
package/src/probe.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { SeaTalkClient } from "./client.js";
2
+ import type { SeaTalkProbeResult } from "./types.js";
3
+
4
+ export async function probeSeaTalk(params?: {
5
+ appId?: string;
6
+ appSecret?: string;
7
+ }): Promise<SeaTalkProbeResult> {
8
+ if (!params?.appId || !params?.appSecret) {
9
+ return {
10
+ ok: false,
11
+ error: "missing credentials (appId, appSecret)",
12
+ };
13
+ }
14
+
15
+ try {
16
+ const client = new SeaTalkClient(params.appId, params.appSecret);
17
+ const start = Date.now();
18
+ const tokenInfo = await client.refreshToken();
19
+ const latencyMs = Date.now() - start;
20
+
21
+ return {
22
+ ok: true,
23
+ appId: params.appId,
24
+ tokenExpire: tokenInfo.expireAt,
25
+ latencyMs,
26
+ };
27
+ } catch (err) {
28
+ return {
29
+ ok: false,
30
+ appId: params.appId,
31
+ error: err instanceof Error ? err.message : String(err),
32
+ };
33
+ }
34
+ }