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 +9 -9
- package/package.json +4 -2
- package/src/access.ts +46 -0
- package/src/bot.ts +214 -189
- package/src/channel.ts +19 -4
- package/src/client.ts +20 -5
- package/src/config-schema.ts +5 -1
- package/src/inbound-resolve.ts +147 -0
- package/src/media.ts +42 -3
- package/src/outbound-coalescer.ts +84 -0
- package/src/types.ts +1 -0
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: ["
|
|
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.
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
} from "openclaw/plugin-sdk/
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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({
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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({
|
|
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:
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
reply.trimmedText,
|
|
785
|
-
|
|
786
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
-
|
|
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