openclaw-seatalk 0.2.0 → 0.3.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/README.md CHANGED
@@ -26,8 +26,9 @@ OpenClaw channel plugin for [SeaTalk](https://seatalk.io/) messaging.
26
26
  - **Dual gateway mode** — **webhook** (direct HTTP server) or **relay** (WebSocket client via [seatalk-relay](https://github.com/lf4096/seatalk-relay))
27
27
  - **Security** — SHA256 signature verification for all incoming events
28
28
  - **Token management** — automatic access token obtain, cache, and refresh
29
+ - **Outbound coalescing** — consecutive reply payloads are merged into a single message with automatic markdown-aware chunking at 4000 chars; configurable via `outboundCoalescing`
29
30
  - **Deduplication** — event ID dedup + per-sender debounce buffer (thread-aware)
30
- - **Access control** — DM policy (`open`/`allowlist`), group policy (`disabled`/`allowlist`/`open`), per-group and per-sender allow-lists
31
+ - **Access control** — DM policy (`open`/`allowlist`/`pairing`), group policy (`disabled`/`allowlist`/`open`), per-group and per-sender allow-lists
31
32
  - **Email resolution** — email-to-employee_code lookup for outbound message targets
32
33
  - **Multi-account** — multiple SeaTalk bot apps in one OpenClaw instance
33
34
  - **Health probing** — connection health check on startup
@@ -87,14 +88,11 @@ openclaw plugins install -l .
87
88
 
88
89
  ## Upgrading
89
90
 
90
- Regular upgrade:
91
-
92
91
  ```bash
93
- openclaw update
92
+ openclaw plugins update openclaw-seatalk
93
+ openclaw gateway restart
94
94
  ```
95
95
 
96
- `openclaw update` automatically upgrades both OpenClaw and installed plugins.
97
-
98
96
  Upgrading OpenClaw across the 2026.3.22 SDK boundary (e.g. 2026.3.13 -> 2026.3.22):
99
97
 
100
98
  ```bash
@@ -152,8 +150,8 @@ Or edit the OpenClaw config file directly (`~/.openclaw/openclaw.json`).
152
150
  signingSecret: "your_signing_secret",
153
151
  webhookPort: 3210,
154
152
  webhookPath: "/callback",
155
- dmPolicy: "open", // or "allowlist"
156
- // allowFrom: ["12345678", "alice@company.com"],
153
+ dmPolicy: "open", // or "allowlist" | "pairing"
154
+ // allowFrom: ["e_12345678", "alice@company.com"],
157
155
  },
158
156
  },
159
157
  }
@@ -200,12 +198,14 @@ Or edit the OpenClaw config file directly (`~/.openclaw/openclaw.json`).
200
198
  | `webhookPort` | number | `8080` | HTTP port (webhook mode only) |
201
199
  | `webhookPath` | string | `"/callback"` | HTTP path (webhook mode only) |
202
200
  | `relayUrl` | string | — | WebSocket URL (relay mode only) |
203
- | `dmPolicy` | `"open"` \| `"allowlist"` | `"allowlist"` | Who can DM the bot |
201
+ | `dmPolicy` | `"open"` \| `"allowlist"` \| `"pairing"` | `"allowlist"` | Who can DM the bot (`pairing`: approve via CLI) |
204
202
  | `allowFrom` | string[] | — | Allowed DM senders (employee codes or emails) |
205
203
  | `groupPolicy` | `"disabled"` \| `"allowlist"` \| `"open"` | `"disabled"` | Group chat policy |
206
204
  | `groupAllowFrom` | string[] | — | Allowed group IDs (when `groupPolicy: "allowlist"`) |
207
205
  | `groupSenderAllowFrom` | string[] | — | Allowed senders within groups (employee codes or emails) |
206
+ | `outboundCoalescing` | boolean | `true` | Merge consecutive reply payloads into a single message (4000-char chunking) |
208
207
  | `processingIndicator` | `"typing"` \| `"off"` | `"typing"` | Show typing status while processing |
208
+ | `mediaAllowHosts` | string[] | `["openapi.seatalk.io"]` | Allowed hostnames for inbound media downloads (HTTPS only) |
209
209
  | `tools.groupInfo` | boolean | `true` | Enable `seatalk` tool `group_info` action |
210
210
  | `tools.groupHistory` | boolean | `true` | Enable `seatalk` tool `group_history` action |
211
211
  | `tools.groupList` | boolean | `true` | Enable `seatalk` tool `group_list` action |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-seatalk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw SeaTalk channel plugin",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -46,7 +46,6 @@
46
46
  },
47
47
  "openclaw": {
48
48
  "extensions": ["./index.ts"],
49
- "setupEntry": "./setup-entry.ts",
50
49
  "channel": {
51
50
  "id": "seatalk",
52
51
  "label": "SeaTalk",
@@ -58,6 +57,9 @@
58
57
  "install": {
59
58
  "npmSpec": "openclaw-seatalk",
60
59
  "defaultChoice": "npm"
60
+ },
61
+ "compat": {
62
+ "pluginApi": ">=2026.3.22"
61
63
  }
62
64
  }
63
65
  }
package/src/access.ts ADDED
@@ -0,0 +1,46 @@
1
+ export function checkGroupAccess(params: {
2
+ groupPolicy: string;
3
+ groupAllowFrom?: string[];
4
+ groupSenderAllowFrom?: string[];
5
+ groupId: string;
6
+ senderEmployeeCode: string;
7
+ senderEmail?: string;
8
+ }): { allowed: boolean; reason?: string } {
9
+ const {
10
+ groupPolicy,
11
+ groupAllowFrom,
12
+ groupSenderAllowFrom,
13
+ groupId,
14
+ senderEmployeeCode,
15
+ senderEmail,
16
+ } = params;
17
+
18
+ if (groupPolicy === "disabled") {
19
+ return { allowed: false, reason: "groupPolicy is disabled" };
20
+ }
21
+
22
+ if (groupPolicy === "allowlist") {
23
+ const list = groupAllowFrom ?? [];
24
+ if (!list.includes(groupId)) {
25
+ return { allowed: false, reason: `group ${groupId} not in groupAllowFrom` };
26
+ }
27
+ }
28
+
29
+ if (groupSenderAllowFrom && groupSenderAllowFrom.length > 0) {
30
+ const match = groupSenderAllowFrom.some((entry) => {
31
+ const e = entry.trim();
32
+ if (e === "*") return true;
33
+ if (e === senderEmployeeCode) return true;
34
+ if (senderEmail && e.toLowerCase() === senderEmail.toLowerCase()) return true;
35
+ return false;
36
+ });
37
+ if (!match) {
38
+ return {
39
+ allowed: false,
40
+ reason: `sender ${senderEmployeeCode} not in groupSenderAllowFrom`,
41
+ };
42
+ }
43
+ }
44
+
45
+ return { allowed: true };
46
+ }
package/src/bot.ts CHANGED
@@ -1,13 +1,23 @@
1
1
  import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
2
3
  import {
3
- resolveSendableOutboundReplyParts,
4
- sendMediaWithLeadingCaption,
5
- } from "openclaw/plugin-sdk/reply-payload";
4
+ DM_GROUP_ACCESS_REASON,
5
+ resolveDmGroupAccessWithLists,
6
+ } from "openclaw/plugin-sdk/channel-policy";
7
+ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
8
+ import { checkGroupAccess } from "./access.js";
6
9
  import { resolveSeaTalkAccount } from "./accounts.js";
7
10
  import type { SeaTalkClient } from "./client.js";
11
+ import {
12
+ type MessageResolveContext,
13
+ deliverMediaReplies,
14
+ resolveForwardedMessages,
15
+ resolveQuotedMessage,
16
+ } from "./inbound-resolve.js";
8
17
  import { buildSeaTalkMediaPayload, resolveInboundMedia } from "./media.js";
18
+ import { createOutboundCoalescer } from "./outbound-coalescer.js";
9
19
  import { getSeatalkRuntime } from "./runtime.js";
10
- import { sendGroupTextMessage, sendMediaToTarget, sendTextMessage } from "./send.js";
20
+ import { sendGroupTextMessage, sendTextMessage } from "./send.js";
11
21
  import type {
12
22
  SeaTalkCallbackRequest,
13
23
  SeaTalkGroupMessageEvent,
@@ -16,6 +26,20 @@ import type {
16
26
  SeaTalkMessageEvent,
17
27
  } from "./types.js";
18
28
 
29
+ function isSeaTalkSenderAllowed(
30
+ employeeCode: string,
31
+ email: string | undefined,
32
+ allowFrom: string[],
33
+ ): boolean {
34
+ return allowFrom.some((entry) => {
35
+ const e = entry.trim();
36
+ if (e === "*") return true;
37
+ if (e === employeeCode) return true;
38
+ if (email && e.toLowerCase() === email.toLowerCase()) return true;
39
+ return false;
40
+ });
41
+ }
42
+
19
43
  export function dispatchSeaTalkEvent(params: {
20
44
  cfg: OpenClawConfig;
21
45
  event: SeaTalkCallbackRequest;
@@ -87,6 +111,9 @@ function tryRecordEvent(eventId: string): boolean {
87
111
  const DEBOUNCE_SLIDE_MS = 1500;
88
112
  const DEBOUNCE_HARD_CAP_MS = 5000;
89
113
 
114
+ const SEATALK_TEXT_CHUNK_LIMIT = 4000;
115
+ const OUTBOUND_COALESCE_IDLE_MS = 1000;
116
+
90
117
  type DmBufferEntry = {
91
118
  kind: "dm";
92
119
  event: SeaTalkCallbackRequest;
@@ -191,76 +218,6 @@ function pushToBuffer(key: string, entry: BufferEntry, context: DebounceContext)
191
218
  scheduleFlush(key, state);
192
219
  }
193
220
 
194
- async function resolveQuotedMessage(params: {
195
- client: SeaTalkClient;
196
- quotedMessageId: string;
197
- log: (msg: string) => void;
198
- }): Promise<{ text: string; media: SeaTalkMediaInfo[] } | null> {
199
- const { client, quotedMessageId, log } = params;
200
- try {
201
- const data = await client.getMessageByMessageId(quotedMessageId);
202
- const sender =
203
- (data.sender as { employee_code?: string } | undefined)?.employee_code ?? "unknown";
204
- const tag = data.tag as string | undefined;
205
-
206
- const media: SeaTalkMediaInfo[] = [];
207
- let content = "";
208
-
209
- if (tag === "text") {
210
- const textObj = data.text as { plain_text?: string; content?: string } | undefined;
211
- content = textObj?.plain_text ?? textObj?.content ?? "";
212
- } else if (tag === "image" || tag === "file" || tag === "video") {
213
- const fakeMsg: SeaTalkMessage = {
214
- message_id: quotedMessageId,
215
- tag,
216
- image:
217
- tag === "image" ? (data.image as { content: string } | undefined) : undefined,
218
- file:
219
- tag === "file"
220
- ? (data.file as { content: string; filename: string } | undefined)
221
- : undefined,
222
- video:
223
- tag === "video" ? (data.video as { content: string } | undefined) : undefined,
224
- };
225
- const resolved = await resolveInboundMedia({ message: fakeMsg, client, log });
226
- if (resolved) {
227
- media.push(resolved);
228
- content = resolved.placeholder;
229
- } else {
230
- content = `<media:${tag}>`;
231
- }
232
- } else {
233
- content = `<unsupported:${tag ?? "unknown"}>`;
234
- }
235
-
236
- return { text: `[Quoted from ${sender}: ${content}]`, media };
237
- } catch (err) {
238
- log(`seatalk: failed to resolve quoted message ${quotedMessageId}: ${String(err)}`);
239
- return null;
240
- }
241
- }
242
-
243
- async function deliverMediaReplies(params: {
244
- mediaUrls: string[];
245
- client: SeaTalkClient;
246
- to: string;
247
- threadId?: string;
248
- isGroup: boolean;
249
- log: (msg: string) => void;
250
- }): Promise<void> {
251
- const { mediaUrls, client, to, threadId, isGroup, log } = params;
252
- await sendMediaWithLeadingCaption({
253
- mediaUrls,
254
- caption: "",
255
- send: async ({ mediaUrl }) => {
256
- await sendMediaToTarget({ client, to, mediaUrl, threadId, isGroup });
257
- },
258
- onError: async ({ error, mediaUrl }) => {
259
- log(`seatalk: failed to send media ${mediaUrl}: ${String(error)}`);
260
- },
261
- });
262
- }
263
-
264
221
  async function processBufferedDmEvents(
265
222
  entries: DmBufferEntry[],
266
223
  context: DebounceContext,
@@ -273,8 +230,64 @@ async function processBufferedDmEvents(
273
230
  const employeeCode = first.employee_code;
274
231
  const email = first.email;
275
232
 
233
+ const account = resolveSeaTalkAccount({ cfg, accountId });
234
+ const seatalkCfg = account.config;
235
+
236
+ const core = getSeatalkRuntime();
237
+ const dmPolicy = seatalkCfg?.dmPolicy ?? "allowlist";
238
+ const configAllowFrom = (seatalkCfg?.allowFrom ?? []).map((v) => String(v));
239
+
240
+ const pairing = createChannelPairingController({ core, channel: "seatalk", accountId });
241
+ const storeAllowFrom =
242
+ dmPolicy === "pairing" ? await pairing.readAllowFromStore().catch(() => []) : [];
243
+
244
+ const accessDecision = resolveDmGroupAccessWithLists({
245
+ isGroup: false,
246
+ dmPolicy,
247
+ groupPolicy: "disabled",
248
+ allowFrom: configAllowFrom,
249
+ groupAllowFrom: [],
250
+ storeAllowFrom,
251
+ isSenderAllowed: (list) => isSeaTalkSenderAllowed(employeeCode, email, list),
252
+ });
253
+
254
+ if (accessDecision.decision === "pairing") {
255
+ const result = await pairing.issueChallenge({
256
+ senderId: employeeCode,
257
+ senderIdLine: `Your SeaTalk employee code: ${employeeCode}`,
258
+ meta: email ? { email } : undefined,
259
+ onCreated: ({ code }) => {
260
+ log(`seatalk[${accountId}]: pairing request sender=${employeeCode} code=${code}`);
261
+ },
262
+ sendPairingReply: async (text) => {
263
+ await sendTextMessage(client, employeeCode, text, 1, first.message.thread_id);
264
+ },
265
+ onReplyError: (err) => {
266
+ log(
267
+ `seatalk[${accountId}]: pairing reply failed for ${employeeCode}: ${String(err)}`,
268
+ );
269
+ },
270
+ });
271
+ if (!result.created) {
272
+ log(`seatalk[${accountId}]: pairing already pending for ${employeeCode}`);
273
+ }
274
+ return;
275
+ }
276
+
277
+ if (accessDecision.decision !== "allow") {
278
+ if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
279
+ log(`seatalk[${accountId}]: blocked DM from ${employeeCode} (dmPolicy=disabled)`);
280
+ } else {
281
+ log(`seatalk[${accountId}]: sender ${employeeCode} not in allowlist, dropping`);
282
+ }
283
+ return;
284
+ }
285
+
286
+ const mediaAllowHosts = seatalkCfg?.mediaAllowHosts;
287
+ const resolveCtx: MessageResolveContext = { client, mediaAllowHosts, log };
288
+
276
289
  const textParts: string[] = [];
277
- const mediaMessages: SeaTalkMessage[] = [];
290
+ const mediaList: SeaTalkMediaInfo[] = [];
278
291
 
279
292
  for (const { parsedEvent } of entries) {
280
293
  const msg = parsedEvent.message;
@@ -285,52 +298,44 @@ async function processBufferedDmEvents(
285
298
  break;
286
299
  case "image":
287
300
  case "file":
288
- case "video":
289
- mediaMessages.push(msg);
301
+ case "video": {
302
+ const media = await resolveInboundMedia({
303
+ message: msg,
304
+ client,
305
+ mediaAllowHosts,
306
+ log,
307
+ });
308
+ if (media) mediaList.push(media);
290
309
  break;
291
- case "combined_forwarded_chat_history":
292
- log(`seatalk[${accountId}]: skipping combined_forwarded_chat_history`);
310
+ }
311
+ case "combined_forwarded_chat_history": {
312
+ const fwd = msg.combined_forwarded_chat_history?.content;
313
+ if (fwd) {
314
+ const result = await resolveForwardedMessages(fwd, resolveCtx);
315
+ mediaList.push(...result.media);
316
+ textParts.push(
317
+ result.lines.length > 0
318
+ ? `[Forwarded messages]\n${result.lines.join("\n")}`
319
+ : "[Forwarded messages]",
320
+ );
321
+ }
293
322
  break;
323
+ }
294
324
  }
295
325
  }
296
326
 
297
- const account = resolveSeaTalkAccount({ cfg, accountId });
298
- const seatalkCfg = account.config;
299
-
300
- const dmPolicy = seatalkCfg?.dmPolicy ?? "allowlist";
301
- const allowFrom = seatalkCfg?.allowFrom ?? [];
302
-
303
- if (dmPolicy === "allowlist") {
304
- const allowed =
305
- allowFrom.length === 0
306
- ? false
307
- : allowFrom.some((entry) => {
308
- const e = entry.trim();
309
- if (e === "*") return true;
310
- if (e === employeeCode) return true;
311
- if (email && e.toLowerCase() === email.toLowerCase()) return true;
312
- return false;
313
- });
314
-
315
- if (!allowed) {
316
- log(`seatalk[${accountId}]: sender ${employeeCode} not in allowlist, dropping`);
317
- return;
318
- }
319
- }
320
-
321
- const mediaList: SeaTalkMediaInfo[] = [];
322
- for (const msg of mediaMessages) {
323
- const media = await resolveInboundMedia({ message: msg, client, log });
324
- if (media) mediaList.push(media);
325
- }
326
-
327
327
  const seenQuotedIds = new Set<string>();
328
328
  const quotedTexts: string[] = [];
329
329
  for (const { parsedEvent } of entries) {
330
330
  const qid = parsedEvent.message.quoted_message_id;
331
331
  if (!qid || seenQuotedIds.has(qid)) continue;
332
332
  seenQuotedIds.add(qid);
333
- const quoted = await resolveQuotedMessage({ client, quotedMessageId: qid, log });
333
+ const quoted = await resolveQuotedMessage({
334
+ client,
335
+ quotedMessageId: qid,
336
+ mediaAllowHosts,
337
+ log,
338
+ });
334
339
  if (quoted) {
335
340
  quotedTexts.push(quoted.text);
336
341
  mediaList.push(...quoted.media);
@@ -358,8 +363,6 @@ async function processBufferedDmEvents(
358
363
  const threadId = first.message.thread_id;
359
364
 
360
365
  try {
361
- const core = getSeatalkRuntime();
362
-
363
366
  const seatalkFrom = `seatalk:${employeeCode}`;
364
367
  const seatalkTo = employeeCode;
365
368
 
@@ -379,13 +382,16 @@ async function processBufferedDmEvents(
379
382
  contextKey: `seatalk:message:${employeeCode}:${messageId}`,
380
383
  });
381
384
 
385
+ const eventTimestamp = entries[0].event.timestamp;
386
+ const messageTimestamp = eventTimestamp ? new Date(eventTimestamp * 1000) : new Date();
387
+
382
388
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
383
389
  const bodyForAgent = `${senderName}: ${messageText}`;
384
390
 
385
391
  const body = core.channel.reply.formatAgentEnvelope({
386
392
  channel: "SeaTalk",
387
393
  from: employeeCode,
388
- timestamp: new Date(),
394
+ timestamp: messageTimestamp,
389
395
  envelope: envelopeOptions,
390
396
  body: bodyForAgent,
391
397
  });
@@ -411,7 +417,7 @@ async function processBufferedDmEvents(
411
417
  Surface: "seatalk" as const,
412
418
  MessageSid: messageId,
413
419
  MessageThreadId: threadId || undefined,
414
- Timestamp: Date.now(),
420
+ Timestamp: eventTimestamp ? eventTimestamp * 1000 : Date.now(),
415
421
  WasMentioned: false,
416
422
  CommandAuthorized: true,
417
423
  OriginatingChannel: "seatalk" as const,
@@ -427,6 +433,21 @@ async function processBufferedDmEvents(
427
433
  .catch((err) => log(`seatalk[${accountId}]: typing failed: ${String(err)}`));
428
434
  }
429
435
 
436
+ const coalescingEnabled = seatalkCfg?.outboundCoalescing !== false;
437
+ const sendDmText = (text: string) =>
438
+ sendTextMessage(client, employeeCode, text, 1, threadId);
439
+ const chunkText = (text: string, limit: number) =>
440
+ core.channel.text.chunkMarkdownText(text, limit);
441
+ const coalescer = coalescingEnabled
442
+ ? createOutboundCoalescer({
443
+ send: sendDmText,
444
+ chunkText,
445
+ maxLength: SEATALK_TEXT_CHUNK_LIMIT,
446
+ joiner: "\n\n",
447
+ idleFlushMs: OUTBOUND_COALESCE_IDLE_MS,
448
+ })
449
+ : null;
450
+
430
451
  const typingResult = core.channel.reply.createReplyDispatcherWithTyping({
431
452
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
432
453
  deliver: async (payload) => {
@@ -437,10 +458,18 @@ async function processBufferedDmEvents(
437
458
  log(
438
459
  `seatalk[${accountId}]: inline deliver DM to ${employeeCode} threadId=${threadId || "none"}`,
439
460
  );
440
- await sendTextMessage(client, employeeCode, reply.trimmedText, 1, threadId);
461
+ if (coalescer) {
462
+ coalescer.append(reply.trimmedText);
463
+ } else {
464
+ const chunks = chunkText(reply.trimmedText, SEATALK_TEXT_CHUNK_LIMIT);
465
+ for (const chunk of chunks) {
466
+ await sendDmText(chunk);
467
+ }
468
+ }
441
469
  }
442
470
 
443
471
  if (reply.hasMedia) {
472
+ if (coalescer) await coalescer.flush();
444
473
  await deliverMediaReplies({
445
474
  mediaUrls: reply.mediaUrls,
446
475
  client,
@@ -476,6 +505,10 @@ async function processBufferedDmEvents(
476
505
  );
477
506
  } finally {
478
507
  typingResult.markDispatchIdle();
508
+ if (coalescer) {
509
+ await typingResult.dispatcher.waitForIdle();
510
+ await coalescer.flush();
511
+ }
479
512
  }
480
513
  } catch (err) {
481
514
  error(`seatalk[${accountId}]: failed to dispatch message: ${String(err)}`);
@@ -506,7 +539,6 @@ export async function handleSeaTalkMessage(params: {
506
539
  log(
507
540
  `seatalk[${accountId}]: received ${msgEvent.message.tag} from ${msgEvent.employee_code} (threadId=${msgEvent.message.thread_id || "none"})`,
508
541
  );
509
-
510
542
  const key = dmDebounceKey(accountId, msgEvent.employee_code, msgEvent.message.thread_id);
511
543
  pushToBuffer(
512
544
  key,
@@ -515,53 +547,6 @@ export async function handleSeaTalkMessage(params: {
515
547
  );
516
548
  }
517
549
 
518
- function checkGroupAccess(params: {
519
- groupPolicy: string;
520
- groupAllowFrom?: string[];
521
- groupSenderAllowFrom?: string[];
522
- groupId: string;
523
- senderEmployeeCode: string;
524
- senderEmail?: string;
525
- }): { allowed: boolean; reason?: string } {
526
- const {
527
- groupPolicy,
528
- groupAllowFrom,
529
- groupSenderAllowFrom,
530
- groupId,
531
- senderEmployeeCode,
532
- senderEmail,
533
- } = params;
534
-
535
- if (groupPolicy === "disabled") {
536
- return { allowed: false, reason: "groupPolicy is disabled" };
537
- }
538
-
539
- if (groupPolicy === "allowlist") {
540
- const list = groupAllowFrom ?? [];
541
- if (!list.includes(groupId)) {
542
- return { allowed: false, reason: `group ${groupId} not in groupAllowFrom` };
543
- }
544
- }
545
-
546
- if (groupSenderAllowFrom && groupSenderAllowFrom.length > 0) {
547
- const match = groupSenderAllowFrom.some((entry) => {
548
- const e = entry.trim();
549
- if (e === "*") return true;
550
- if (e === senderEmployeeCode) return true;
551
- if (senderEmail && e.toLowerCase() === senderEmail.toLowerCase()) return true;
552
- return false;
553
- });
554
- if (!match) {
555
- return {
556
- allowed: false,
557
- reason: `sender ${senderEmployeeCode} not in groupSenderAllowFrom`,
558
- };
559
- }
560
- }
561
-
562
- return { allowed: true };
563
- }
564
-
565
550
  export async function handleSeaTalkGroupMessage(params: {
566
551
  cfg: OpenClawConfig;
567
552
  event: SeaTalkCallbackRequest;
@@ -641,8 +626,13 @@ async function processBufferedGroupEvents(
641
626
  const senderEmail = sender.email;
642
627
  const threadId = msg.thread_id;
643
628
 
629
+ const account = resolveSeaTalkAccount({ cfg, accountId });
630
+ const seatalkCfg = account.config;
631
+ const mediaAllowHosts = seatalkCfg?.mediaAllowHosts;
632
+ const resolveCtx: MessageResolveContext = { client, mediaAllowHosts, log };
633
+
644
634
  const textParts: string[] = [];
645
- const mediaMessages: SeaTalkMessage[] = [];
635
+ const mediaList: SeaTalkMediaInfo[] = [];
646
636
 
647
637
  for (const { groupEvent } of entries) {
648
638
  const m = groupEvent.message;
@@ -653,27 +643,41 @@ async function processBufferedGroupEvents(
653
643
  break;
654
644
  case "image":
655
645
  case "file":
656
- case "video":
657
- mediaMessages.push(m);
646
+ case "video": {
647
+ const media = await resolveInboundMedia({
648
+ message: m,
649
+ client,
650
+ mediaAllowHosts,
651
+ log,
652
+ });
653
+ if (media) mediaList.push(media);
658
654
  break;
659
- case "combined_forwarded_chat_history":
660
- log(
661
- `seatalk[${accountId}]: skipping combined_forwarded_chat_history in group ${groupId}`,
662
- );
655
+ }
656
+ case "combined_forwarded_chat_history": {
657
+ const fwd = m.combined_forwarded_chat_history?.content;
658
+ if (fwd) {
659
+ const result = await resolveForwardedMessages(fwd, resolveCtx);
660
+ mediaList.push(...result.media);
661
+ textParts.push(
662
+ result.lines.length > 0
663
+ ? `[Forwarded messages]\n${result.lines.join("\n")}`
664
+ : "[Forwarded messages]",
665
+ );
666
+ }
663
667
  break;
668
+ }
664
669
  }
665
670
  }
666
671
 
667
- const mediaList: SeaTalkMediaInfo[] = [];
668
- for (const m of mediaMessages) {
669
- const media = await resolveInboundMedia({ message: m, client, log });
670
- if (media) mediaList.push(media);
671
- }
672
-
673
672
  const quotedMessageId = first.groupEvent.message.quoted_message_id;
674
673
  let quotedText: string | null = null;
675
674
  if (quotedMessageId) {
676
- const quoted = await resolveQuotedMessage({ client, quotedMessageId, log });
675
+ const quoted = await resolveQuotedMessage({
676
+ client,
677
+ quotedMessageId,
678
+ mediaAllowHosts,
679
+ log,
680
+ });
677
681
  if (quoted) {
678
682
  quotedText = quoted.text;
679
683
  mediaList.push(...quoted.media);
@@ -721,18 +725,18 @@ async function processBufferedGroupEvents(
721
725
  { sessionKey: route.sessionKey, contextKey: `seatalk:group:${groupId}:${messageId}` },
722
726
  );
723
727
 
728
+ const sentAt = first.groupEvent.message.message_sent_time;
729
+ const messageTimestamp = sentAt ? new Date(sentAt * 1000) : new Date();
730
+
724
731
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
725
732
  const body = core.channel.reply.formatAgentEnvelope({
726
733
  channel: "SeaTalk",
727
734
  from: employeeCode,
728
- timestamp: new Date(),
735
+ timestamp: messageTimestamp,
729
736
  envelope: envelopeOptions,
730
737
  body: `${senderName}: ${messageText}`,
731
738
  });
732
739
 
733
- const account = resolveSeaTalkAccount({ cfg, accountId });
734
- const seatalkCfg = account.config;
735
-
736
740
  const metadata: Record<string, string> = { groupId };
737
741
  if (threadId) metadata.threadId = threadId;
738
742
  if (quotedMessageId) metadata.quotedMessageId = quotedMessageId;
@@ -753,7 +757,7 @@ async function processBufferedGroupEvents(
753
757
  Surface: "seatalk" as const,
754
758
  MessageSid: messageId,
755
759
  MessageThreadId: threadId || undefined,
756
- Timestamp: Date.now(),
760
+ Timestamp: sentAt ? sentAt * 1000 : Date.now(),
757
761
  WasMentioned: wasMentioned,
758
762
  CommandAuthorized: true,
759
763
  OriginatingChannel: "seatalk" as const,
@@ -771,6 +775,21 @@ async function processBufferedGroupEvents(
771
775
 
772
776
  const replyThreadId = threadId || undefined;
773
777
 
778
+ const groupCoalescingEnabled = seatalkCfg?.outboundCoalescing !== false;
779
+ const sendGroupText = (text: string) =>
780
+ sendGroupTextMessage(client, groupId, text, 1, replyThreadId);
781
+ const chunkGroupText = (text: string, limit: number) =>
782
+ core.channel.text.chunkMarkdownText(text, limit);
783
+ const groupCoalescer = groupCoalescingEnabled
784
+ ? createOutboundCoalescer({
785
+ send: sendGroupText,
786
+ chunkText: chunkGroupText,
787
+ maxLength: SEATALK_TEXT_CHUNK_LIMIT,
788
+ joiner: "\n\n",
789
+ idleFlushMs: OUTBOUND_COALESCE_IDLE_MS,
790
+ })
791
+ : null;
792
+
774
793
  const typingResult = core.channel.reply.createReplyDispatcherWithTyping({
775
794
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
776
795
  deliver: async (payload) => {
@@ -778,16 +797,18 @@ async function processBufferedGroupEvents(
778
797
  if (!reply.hasText && !reply.hasMedia) return;
779
798
 
780
799
  if (reply.hasText) {
781
- await sendGroupTextMessage(
782
- client,
783
- groupId,
784
- reply.trimmedText,
785
- 1,
786
- replyThreadId,
787
- );
800
+ if (groupCoalescer) {
801
+ groupCoalescer.append(reply.trimmedText);
802
+ } else {
803
+ const chunks = chunkGroupText(reply.trimmedText, SEATALK_TEXT_CHUNK_LIMIT);
804
+ for (const chunk of chunks) {
805
+ await sendGroupText(chunk);
806
+ }
807
+ }
788
808
  }
789
809
 
790
810
  if (reply.hasMedia) {
811
+ if (groupCoalescer) await groupCoalescer.flush();
791
812
  await deliverMediaReplies({
792
813
  mediaUrls: reply.mediaUrls,
793
814
  client,
@@ -823,6 +844,10 @@ async function processBufferedGroupEvents(
823
844
  );
824
845
  } finally {
825
846
  typingResult.markDispatchIdle();
847
+ if (groupCoalescer) {
848
+ await typingResult.dispatcher.waitForIdle();
849
+ await groupCoalescer.flush();
850
+ }
826
851
  }
827
852
  } catch (err) {
828
853
  error(`seatalk[${accountId}]: failed to dispatch group message: ${String(err)}`);
package/src/channel.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core";
2
+ import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
3
+ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/core";
3
4
  import {
4
5
  listSeaTalkAccountIds,
5
6
  resolveDefaultSeaTalkAccountId,
@@ -8,6 +9,7 @@ import {
8
9
  import { resolveSeaTalkClient } from "./client.js";
9
10
  import { seatalkOutbound } from "./outbound.js";
10
11
  import { probeSeaTalk } from "./probe.js";
12
+ import { sendTextMessage } from "./send.js";
11
13
  import { seatalkSetupWizard } from "./setup-surface.js";
12
14
  import { looksLikeEmail, looksLikeSeaTalkId, normalizeSeaTalkTarget } from "./targets.js";
13
15
  import type { ResolvedSeaTalkAccount, SeaTalkConfig } from "./types.js";
@@ -26,6 +28,17 @@ const meta = {
26
28
  export const seatalkPlugin: ChannelPlugin<ResolvedSeaTalkAccount> = {
27
29
  id: "seatalk",
28
30
  meta,
31
+ pairing: {
32
+ idLabel: "employeeCode",
33
+ normalizeAllowEntry: createPairingPrefixStripper(/^(seatalk|st):/i),
34
+ notifyApproval: async ({ cfg, id }) => {
35
+ const accountId = resolveDefaultSeaTalkAccountId(cfg);
36
+ const account = resolveSeaTalkAccount({ cfg, accountId });
37
+ const client = resolveSeaTalkClient(account);
38
+ if (!client) return;
39
+ await sendTextMessage(client, id, PAIRING_APPROVED_MESSAGE, 1);
40
+ },
41
+ },
29
42
  capabilities: {
30
43
  chatTypes: ["direct", "group"],
31
44
  polls: false,
@@ -49,12 +62,13 @@ export const seatalkPlugin: ChannelPlugin<ResolvedSeaTalkAccount> = {
49
62
  relayUrl: { type: "string" },
50
63
  webhookPort: { type: "integer", minimum: 1 },
51
64
  webhookPath: { type: "string" },
52
- dmPolicy: { type: "string", enum: ["open", "allowlist"] },
65
+ dmPolicy: { type: "string", enum: ["open", "allowlist", "pairing"] },
53
66
  allowFrom: { type: "array", items: { type: "string" } },
54
67
  groupPolicy: { type: "string", enum: ["disabled", "allowlist", "open"] },
55
68
  groupAllowFrom: { type: "array", items: { type: "string" } },
56
69
  groupSenderAllowFrom: { type: "array", items: { type: "string" } },
57
70
  processingIndicator: { type: "string", enum: ["typing", "off"] },
71
+ mediaAllowHosts: { type: "array", items: { type: "string" } },
58
72
  tools: {
59
73
  type: "object",
60
74
  properties: {
@@ -78,7 +92,7 @@ export const seatalkPlugin: ChannelPlugin<ResolvedSeaTalkAccount> = {
78
92
  relayUrl: { type: "string" },
79
93
  webhookPort: { type: "integer", minimum: 1 },
80
94
  webhookPath: { type: "string" },
81
- dmPolicy: { type: "string", enum: ["open", "allowlist"] },
95
+ dmPolicy: { type: "string", enum: ["open", "allowlist", "pairing"] },
82
96
  allowFrom: { type: "array", items: { type: "string" } },
83
97
  groupPolicy: {
84
98
  type: "string",
@@ -87,6 +101,7 @@ export const seatalkPlugin: ChannelPlugin<ResolvedSeaTalkAccount> = {
87
101
  groupAllowFrom: { type: "array", items: { type: "string" } },
88
102
  groupSenderAllowFrom: { type: "array", items: { type: "string" } },
89
103
  processingIndicator: { type: "string", enum: ["typing", "off"] },
104
+ mediaAllowHosts: { type: "array", items: { type: "string" } },
90
105
  },
91
106
  },
92
107
  },
@@ -183,7 +198,7 @@ export const seatalkPlugin: ChannelPlugin<ResolvedSeaTalkAccount> = {
183
198
  const dmPolicy = seatalkCfg?.dmPolicy ?? "allowlist";
184
199
  if (dmPolicy !== "open") return [];
185
200
  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.`,
201
+ `- SeaTalk[${account.accountId}]: dmPolicy="open" allows any subscriber to message the bot. Set channels.seatalk.dmPolicy to "allowlist" or "pairing" to restrict senders.`,
187
202
  ];
188
203
  },
189
204
  },
package/src/client.ts CHANGED
@@ -3,6 +3,7 @@ import type { SeaTalkTokenInfo } from "./types.js";
3
3
  const BASE_URL = "https://openapi.seatalk.io";
4
4
  const HTTP_TIMEOUT_MS = 10_000;
5
5
  const TOKEN_REFRESH_MARGIN_S = 600;
6
+ const RATE_LIMIT_RETRY_DELAYS_MS = [10_000, 60_000];
6
7
 
7
8
  export class SeaTalkClient {
8
9
  private appId: string;
@@ -93,11 +94,13 @@ export class SeaTalkClient {
93
94
  path: string,
94
95
  body?: unknown,
95
96
  retry = true,
97
+ rateLimitAttempt = 0,
96
98
  ): Promise<T> {
97
99
  const token = await this.getAccessToken();
98
100
  const controller = new AbortController();
99
101
  const timeout = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
100
102
 
103
+ let xRid: string | undefined;
101
104
  try {
102
105
  const res = await fetch(`${BASE_URL}${path}`, {
103
106
  method,
@@ -109,7 +112,7 @@ export class SeaTalkClient {
109
112
  signal: controller.signal,
110
113
  });
111
114
 
112
- const xRid = res.headers.get("x-rid") ?? undefined;
115
+ xRid = res.headers.get("x-rid") ?? undefined;
113
116
 
114
117
  if (!res.ok) {
115
118
  throw Object.assign(
@@ -126,10 +129,17 @@ export class SeaTalkClient {
126
129
  }
127
130
 
128
131
  if (data.code === 101) {
129
- throw Object.assign(new Error("SeaTalk rate limit exceeded"), {
130
- code: 101,
131
- xRid,
132
- });
132
+ if (rateLimitAttempt < RATE_LIMIT_RETRY_DELAYS_MS.length) {
133
+ const delay = RATE_LIMIT_RETRY_DELAYS_MS[rateLimitAttempt];
134
+ await new Promise((r) => setTimeout(r, delay));
135
+ return this.apiCall<T>(method, path, body, retry, rateLimitAttempt + 1);
136
+ }
137
+ throw Object.assign(
138
+ new Error(
139
+ `SeaTalk rate limit exceeded after ${rateLimitAttempt + 1} attempts (x-rid: ${xRid})`,
140
+ ),
141
+ { code: 101, xRid },
142
+ );
133
143
  }
134
144
 
135
145
  if (data.code !== 0) {
@@ -142,6 +152,11 @@ export class SeaTalkClient {
142
152
  }
143
153
 
144
154
  return data;
155
+ } catch (err) {
156
+ if (xRid && err instanceof Error && !err.message.includes("x-rid:")) {
157
+ err.message += ` (x-rid: ${xRid})`;
158
+ }
159
+ throw err;
145
160
  } finally {
146
161
  clearTimeout(timeout);
147
162
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  export { z };
3
3
 
4
- const DmPolicySchema = z.enum(["open", "allowlist"]);
4
+ const DmPolicySchema = z.enum(["open", "allowlist", "pairing"]);
5
5
  const GatewayModeSchema = z.enum(["webhook", "relay"]);
6
6
  const GroupPolicySchema = z.enum(["disabled", "allowlist", "open"]);
7
7
  const ProcessingIndicatorSchema = z.enum(["typing", "off"]);
@@ -32,6 +32,8 @@ export const SeaTalkAccountConfigSchema = z
32
32
  groupAllowFrom: z.array(z.string()).optional(),
33
33
  groupSenderAllowFrom: z.array(z.string()).optional(),
34
34
  processingIndicator: ProcessingIndicatorSchema.optional(),
35
+ mediaAllowHosts: z.array(z.string()).optional(),
36
+ outboundCoalescing: z.boolean().optional(),
35
37
  })
36
38
  .strict();
37
39
 
@@ -51,6 +53,8 @@ export const SeaTalkConfigSchema = z
51
53
  groupAllowFrom: z.array(z.string()).optional(),
52
54
  groupSenderAllowFrom: z.array(z.string()).optional(),
53
55
  processingIndicator: ProcessingIndicatorSchema.optional().default("typing"),
56
+ mediaAllowHosts: z.array(z.string()).optional(),
57
+ outboundCoalescing: z.boolean().optional().default(true),
54
58
  tools: SeaTalkToolsConfigSchema.optional(),
55
59
  accounts: z.record(z.string(), SeaTalkAccountConfigSchema.optional()).optional(),
56
60
  })
@@ -0,0 +1,147 @@
1
+ import { sendMediaWithLeadingCaption } from "openclaw/plugin-sdk/reply-payload";
2
+ import type { SeaTalkClient } from "./client.js";
3
+ import { resolveInboundMedia } from "./media.js";
4
+ import { sendMediaToTarget } from "./send.js";
5
+ import type { SeaTalkMediaInfo, SeaTalkMessage } from "./types.js";
6
+
7
+ export type MessageResolveContext = {
8
+ client: SeaTalkClient;
9
+ mediaAllowHosts?: string[] | null;
10
+ log: (msg: string) => void;
11
+ };
12
+
13
+ function formatSenderPrefix(data: Record<string, unknown>): string {
14
+ const sender = data.sender as { email?: string; employee_code?: string } | undefined;
15
+ const sentTime = data.message_sent_time as number | undefined;
16
+ const parts: string[] = [];
17
+ if (sender?.email) parts.push(sender.email);
18
+ else if (sender?.employee_code) parts.push(sender.employee_code);
19
+ if (sentTime) parts.push(new Date(sentTime * 1000).toISOString());
20
+ return parts.length > 0 ? `[${parts.join(" ")}] ` : "";
21
+ }
22
+
23
+ function toSeaTalkMessage(data: Record<string, unknown>): SeaTalkMessage {
24
+ const tag = data.tag as SeaTalkMessage["tag"];
25
+ return {
26
+ message_id: (data.message_id as string) ?? "",
27
+ tag,
28
+ text: data.text as SeaTalkMessage["text"],
29
+ image: data.image as SeaTalkMessage["image"],
30
+ file: data.file as SeaTalkMessage["file"],
31
+ video: data.video as SeaTalkMessage["video"],
32
+ combined_forwarded_chat_history:
33
+ data.combined_forwarded_chat_history as SeaTalkMessage["combined_forwarded_chat_history"],
34
+ };
35
+ }
36
+
37
+ export async function resolveMessageContent(
38
+ data: Record<string, unknown>,
39
+ ctx: MessageResolveContext,
40
+ ): Promise<{ text: string; media: SeaTalkMediaInfo[] }> {
41
+ const tag = data.tag as string | undefined;
42
+ const media: SeaTalkMediaInfo[] = [];
43
+
44
+ if (tag === "text") {
45
+ const textObj = data.text as { plain_text?: string; content?: string } | undefined;
46
+ return { text: textObj?.plain_text ?? textObj?.content ?? "", media };
47
+ }
48
+
49
+ if (tag === "image" || tag === "file" || tag === "video") {
50
+ const resolved = await resolveInboundMedia({
51
+ message: toSeaTalkMessage(data),
52
+ client: ctx.client,
53
+ mediaAllowHosts: ctx.mediaAllowHosts,
54
+ log: ctx.log,
55
+ });
56
+ if (resolved) {
57
+ media.push(resolved);
58
+ return { text: resolved.placeholder, media };
59
+ }
60
+ return { text: `<media:${tag}>`, media };
61
+ }
62
+
63
+ if (tag === "combined_forwarded_chat_history") {
64
+ const fwd = (data.combined_forwarded_chat_history as { content?: unknown[] } | undefined)
65
+ ?.content;
66
+ if (fwd) {
67
+ const result = await resolveForwardedMessages(fwd, ctx);
68
+ media.push(...result.media);
69
+ return {
70
+ text:
71
+ result.lines.length > 0
72
+ ? `[Forwarded messages]\n${result.lines.join("\n")}`
73
+ : "[Forwarded messages]",
74
+ media,
75
+ };
76
+ }
77
+ return { text: "[Forwarded messages]", media };
78
+ }
79
+
80
+ return { text: `<unsupported:${tag ?? "unknown"}>`, media };
81
+ }
82
+
83
+ export async function resolveForwardedMessages(
84
+ content: unknown[],
85
+ ctx: MessageResolveContext,
86
+ ): Promise<{ lines: string[]; media: SeaTalkMediaInfo[] }> {
87
+ const lines: string[] = [];
88
+ const media: SeaTalkMediaInfo[] = [];
89
+ for (const item of content) {
90
+ if (Array.isArray(item)) {
91
+ const nested = await resolveForwardedMessages(item, ctx);
92
+ lines.push(...nested.lines);
93
+ media.push(...nested.media);
94
+ continue;
95
+ }
96
+ if (!item || typeof item !== "object") continue;
97
+
98
+ const rec = item as Record<string, unknown>;
99
+ const prefix = formatSenderPrefix(rec);
100
+ const result = await resolveMessageContent(rec, ctx);
101
+ media.push(...result.media);
102
+ if (result.text) lines.push(`${prefix}${result.text}`);
103
+ }
104
+ return { lines, media };
105
+ }
106
+
107
+ export async function resolveQuotedMessage(params: {
108
+ client: SeaTalkClient;
109
+ quotedMessageId: string;
110
+ mediaAllowHosts?: string[] | null;
111
+ log: (msg: string) => void;
112
+ }): Promise<{ text: string; media: SeaTalkMediaInfo[] } | null> {
113
+ const { client, quotedMessageId, mediaAllowHosts, log } = params;
114
+ try {
115
+ const data = await client.getMessageByMessageId(quotedMessageId);
116
+ const senderObj = data.sender as { employee_code?: string; email?: string } | undefined;
117
+ const senderCode = senderObj?.employee_code ?? "unknown";
118
+ const sender = senderObj?.email ? `${senderCode} (${senderObj.email})` : senderCode;
119
+
120
+ const result = await resolveMessageContent(data, { client, mediaAllowHosts, log });
121
+ return { text: `[Quoted from ${sender}: ${result.text}]`, media: result.media };
122
+ } catch (err) {
123
+ log(`seatalk: failed to resolve quoted message ${quotedMessageId}: ${String(err)}`);
124
+ return null;
125
+ }
126
+ }
127
+
128
+ export async function deliverMediaReplies(params: {
129
+ mediaUrls: string[];
130
+ client: SeaTalkClient;
131
+ to: string;
132
+ threadId?: string;
133
+ isGroup: boolean;
134
+ log: (msg: string) => void;
135
+ }): Promise<void> {
136
+ const { mediaUrls, client, to, threadId, isGroup, log } = params;
137
+ await sendMediaWithLeadingCaption({
138
+ mediaUrls,
139
+ caption: "",
140
+ send: async ({ mediaUrl }) => {
141
+ await sendMediaToTarget({ client, to, mediaUrl, threadId, isGroup });
142
+ },
143
+ onError: async ({ error, mediaUrl }) => {
144
+ log(`seatalk: failed to send media ${mediaUrl}: ${String(error)}`);
145
+ },
146
+ });
147
+ }
package/src/media.ts CHANGED
@@ -15,13 +15,42 @@ const MAX_OUTBOUND_RAW_BYTES = 3.75 * 1024 * 1024; // ~3.75MB raw → 5MB base64
15
15
  const SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10MB
16
16
  const MAX_INBOUND_SAVE_BYTES = 250 * 1024 * 1024; // 250MB
17
17
 
18
+ const DEFAULT_MEDIA_ALLOWED_HOSTS = ["openapi.seatalk.io"] as const;
19
+
20
+ export function resolveMediaAllowedHosts(configured?: string[] | null): Set<string> {
21
+ const raw = configured && configured.length > 0 ? configured : [...DEFAULT_MEDIA_ALLOWED_HOSTS];
22
+ return new Set(raw.map((h) => h.trim().toLowerCase()).filter(Boolean));
23
+ }
24
+
25
+ function gateInboundMediaUrl(
26
+ urlString: string,
27
+ allowedHosts: Set<string>,
28
+ ): { ok: true; hostname: string } | { ok: false; detail: string } {
29
+ let parsed: URL;
30
+ try {
31
+ parsed = new URL(urlString);
32
+ } catch {
33
+ return { ok: false, detail: "invalid URL" };
34
+ }
35
+ if (parsed.protocol !== "https:") {
36
+ return { ok: false, detail: `only https allowed (got ${parsed.protocol})` };
37
+ }
38
+ const hostname = parsed.hostname.toLowerCase();
39
+ if (!allowedHosts.has(hostname)) {
40
+ return { ok: false, detail: `host not in allowlist (${hostname})` };
41
+ }
42
+ return { ok: true, hostname };
43
+ }
44
+
18
45
  export async function resolveInboundMedia(params: {
19
46
  message: SeaTalkMessage;
20
47
  client: SeaTalkClient;
48
+ mediaAllowHosts?: string[] | null;
21
49
  log?: (msg: string) => void;
22
50
  }): Promise<SeaTalkMediaInfo | null> {
23
- const { message, client, log } = params;
51
+ const { message, client, log, mediaAllowHosts } = params;
24
52
  const core = getSeatalkRuntime();
53
+ const allowedHosts = resolveMediaAllowedHosts(mediaAllowHosts);
25
54
 
26
55
  let url: string | undefined;
27
56
  let filename: string | undefined;
@@ -46,6 +75,13 @@ export async function resolveInboundMedia(params: {
46
75
 
47
76
  if (!url) return null;
48
77
 
78
+ const gate = gateInboundMediaUrl(url, allowedHosts);
79
+ if (!gate.ok) {
80
+ log?.(`seatalk: rejected inbound ${message.tag} media before download: ${gate.detail}`);
81
+ return null;
82
+ }
83
+ log?.(`seatalk: inbound ${message.tag} media url host=${gate.hostname}`);
84
+
49
85
  const MAX_RETRY = 1;
50
86
 
51
87
  for (let attempt = 0; attempt <= MAX_RETRY; attempt++) {
@@ -57,12 +93,15 @@ export async function resolveInboundMedia(params: {
57
93
  (!contentType || contentType === "application/octet-stream") &&
58
94
  result.buffer.length < SMALL_FILE_THRESHOLD
59
95
  ) {
60
- contentType = await core.media.detectMime({ buffer: result.buffer });
96
+ const detected = await core.media.detectMime({ buffer: result.buffer });
97
+ if (detected) {
98
+ contentType = detected;
99
+ }
61
100
  }
62
101
 
63
102
  const saved = await core.channel.media.saveMediaBuffer(
64
103
  result.buffer,
65
- contentType,
104
+ contentType ?? "application/octet-stream",
66
105
  "inbound",
67
106
  MAX_INBOUND_SAVE_BYTES,
68
107
  filename,
@@ -0,0 +1,84 @@
1
+ export type OutboundCoalescer = {
2
+ append: (text: string) => void;
3
+ flush: () => Promise<void>;
4
+ hasBuffered: () => boolean;
5
+ };
6
+
7
+ export function createOutboundCoalescer(params: {
8
+ send: (text: string) => Promise<void>;
9
+ chunkText: (text: string, limit: number) => string[];
10
+ maxLength: number;
11
+ joiner: string;
12
+ idleFlushMs?: number;
13
+ }): OutboundCoalescer {
14
+ const { send, chunkText, maxLength, joiner, idleFlushMs } = params;
15
+
16
+ let buffer = "";
17
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
18
+ let sendChain: Promise<void> = Promise.resolve();
19
+
20
+ const clearIdleTimer = () => {
21
+ if (!idleTimer) return;
22
+ clearTimeout(idleTimer);
23
+ idleTimer = undefined;
24
+ };
25
+
26
+ const sendBuffered = () => {
27
+ if (!buffer) return;
28
+ const text = buffer;
29
+ buffer = "";
30
+ const chunks = text.length > maxLength ? chunkText(text, maxLength) : [text];
31
+ const doSend = async () => {
32
+ for (const chunk of chunks) {
33
+ await send(chunk);
34
+ }
35
+ };
36
+ // Use rejection handler to recover from prior failures so the chain never stays broken.
37
+ sendChain = sendChain.then(doSend, doSend);
38
+ // Prevent unhandled-rejection warnings when triggered by idle timer.
39
+ sendChain.catch(() => {});
40
+ };
41
+
42
+ const scheduleIdleFlush = () => {
43
+ if (!idleFlushMs || idleFlushMs <= 0) return;
44
+ clearIdleTimer();
45
+ idleTimer = setTimeout(() => {
46
+ idleTimer = undefined;
47
+ sendBuffered();
48
+ }, idleFlushMs);
49
+ };
50
+
51
+ const append = (text: string) => {
52
+ if (!text) return;
53
+ clearIdleTimer();
54
+
55
+ if (!buffer) {
56
+ buffer = text;
57
+ scheduleIdleFlush();
58
+ return;
59
+ }
60
+
61
+ const next = `${buffer}${joiner}${text}`;
62
+ if (next.length > maxLength) {
63
+ sendBuffered();
64
+ buffer = text;
65
+ scheduleIdleFlush();
66
+ return;
67
+ }
68
+
69
+ buffer = next;
70
+ scheduleIdleFlush();
71
+ };
72
+
73
+ const flush = async () => {
74
+ clearIdleTimer();
75
+ sendBuffered();
76
+ await sendChain;
77
+ };
78
+
79
+ return {
80
+ append,
81
+ flush,
82
+ hasBuffered: () => buffer.length > 0,
83
+ };
84
+ }
package/src/types.ts CHANGED
@@ -64,6 +64,7 @@ export type SeaTalkMessage = {
64
64
  image?: { content: string };
65
65
  file?: { content: string; filename: string };
66
66
  video?: { content: string };
67
+ combined_forwarded_chat_history?: { content: unknown[] };
67
68
  };
68
69
 
69
70
  export type SeaTalkMediaInfo = {