openclaw-seatalk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +260 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +54 -0
- package/src/accounts.ts +94 -0
- package/src/bot.ts +698 -0
- package/src/channel.ts +359 -0
- package/src/client.ts +329 -0
- package/src/config-schema.ts +71 -0
- package/src/media.ts +163 -0
- package/src/monitor.ts +205 -0
- package/src/onboarding.ts +507 -0
- package/src/outbound.ts +120 -0
- package/src/probe.ts +34 -0
- package/src/relay-client.ts +204 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +71 -0
- package/src/targets.ts +27 -0
- package/src/tool-schema.ts +60 -0
- package/src/tool.ts +180 -0
- package/src/types.ts +94 -0
package/src/bot.ts
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveSeaTalkAccount } from "./accounts.js";
|
|
3
|
+
import type { SeaTalkClient } from "./client.js";
|
|
4
|
+
import { buildSeaTalkMediaPayload, resolveInboundMedia } from "./media.js";
|
|
5
|
+
import { getSeatalkRuntime } from "./runtime.js";
|
|
6
|
+
import { sendGroupTextMessage, sendTextMessage } from "./send.js";
|
|
7
|
+
import type {
|
|
8
|
+
SeaTalkCallbackRequest,
|
|
9
|
+
SeaTalkGroupMessageEvent,
|
|
10
|
+
SeaTalkMediaInfo,
|
|
11
|
+
SeaTalkMessage,
|
|
12
|
+
SeaTalkMessageEvent,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
export function dispatchSeaTalkEvent(params: {
|
|
16
|
+
cfg: ClawdbotConfig;
|
|
17
|
+
event: SeaTalkCallbackRequest;
|
|
18
|
+
client: SeaTalkClient;
|
|
19
|
+
runtime?: RuntimeEnv;
|
|
20
|
+
accountId: string;
|
|
21
|
+
}): void {
|
|
22
|
+
const { cfg, event, client, runtime, accountId } = params;
|
|
23
|
+
const log = runtime?.log ?? console.log;
|
|
24
|
+
const error = runtime?.error ?? console.error;
|
|
25
|
+
const handle = (fn: () => Promise<void>) =>
|
|
26
|
+
fn().catch((err) => error(`seatalk[${accountId}]: event error: ${String(err)}`));
|
|
27
|
+
|
|
28
|
+
switch (event.event_type) {
|
|
29
|
+
case "message_from_bot_subscriber":
|
|
30
|
+
handle(() => handleSeaTalkMessage({ cfg, event, client, runtime, accountId }));
|
|
31
|
+
break;
|
|
32
|
+
case "new_mentioned_message_received_from_group_chat":
|
|
33
|
+
case "new_message_received_from_thread":
|
|
34
|
+
handle(() => handleSeaTalkGroupMessage({ cfg, event, client, runtime, accountId }));
|
|
35
|
+
break;
|
|
36
|
+
case "new_bot_subscriber": {
|
|
37
|
+
const ec = (event.event as { employee_code?: string })?.employee_code;
|
|
38
|
+
log(`seatalk[${accountId}]: new subscriber: ${ec}`);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "bot_added_to_group_chat": {
|
|
42
|
+
const gid = (event.event as { group_id?: string })?.group_id;
|
|
43
|
+
log(`seatalk[${accountId}]: bot added to group: ${gid}`);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "bot_removed_from_group_chat": {
|
|
47
|
+
const gid = (event.event as { group_id?: string })?.group_id;
|
|
48
|
+
log(`seatalk[${accountId}]: bot removed from group: ${gid}`);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
default:
|
|
52
|
+
log(`seatalk[${accountId}]: unhandled event type: ${event.event_type}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DEDUP_TTL_MS = 30 * 60 * 1000;
|
|
57
|
+
const DEDUP_MAX_SIZE = 1_000;
|
|
58
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
59
|
+
const processedEventIds = new Map<string, number>();
|
|
60
|
+
let lastCleanupTime = Date.now();
|
|
61
|
+
|
|
62
|
+
function tryRecordEvent(eventId: string): boolean {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
|
|
65
|
+
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
|
|
66
|
+
for (const [id, ts] of processedEventIds) {
|
|
67
|
+
if (now - ts > DEDUP_TTL_MS) processedEventIds.delete(id);
|
|
68
|
+
}
|
|
69
|
+
lastCleanupTime = now;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (processedEventIds.has(eventId)) return false;
|
|
73
|
+
|
|
74
|
+
if (processedEventIds.size >= DEDUP_MAX_SIZE) {
|
|
75
|
+
const first = processedEventIds.keys().next().value!;
|
|
76
|
+
processedEventIds.delete(first);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
processedEventIds.set(eventId, now);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DEBOUNCE_SLIDE_MS = 1500;
|
|
84
|
+
const DEBOUNCE_HARD_CAP_MS = 5000;
|
|
85
|
+
|
|
86
|
+
type BufferEntry = {
|
|
87
|
+
event: SeaTalkCallbackRequest;
|
|
88
|
+
parsedEvent: SeaTalkMessageEvent;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type DebounceState = {
|
|
92
|
+
entries: BufferEntry[];
|
|
93
|
+
timer: ReturnType<typeof setTimeout>;
|
|
94
|
+
firstEventAt: number;
|
|
95
|
+
context: DebounceContext;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type DebounceContext = {
|
|
99
|
+
cfg: ClawdbotConfig;
|
|
100
|
+
client: SeaTalkClient;
|
|
101
|
+
runtime?: RuntimeEnv;
|
|
102
|
+
accountId: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const debounceBuffers = new Map<string, DebounceState>();
|
|
106
|
+
|
|
107
|
+
function dmDebounceKey(accountId: string, employeeCode: string, threadId?: string): string {
|
|
108
|
+
return threadId
|
|
109
|
+
? `${accountId}:dm:${employeeCode}:t:${threadId}`
|
|
110
|
+
: `${accountId}:dm:${employeeCode}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scheduleFlush(key: string, state: DebounceState): void {
|
|
114
|
+
clearTimeout(state.timer);
|
|
115
|
+
|
|
116
|
+
const elapsed = Date.now() - state.firstEventAt;
|
|
117
|
+
const remaining = DEBOUNCE_HARD_CAP_MS - elapsed;
|
|
118
|
+
|
|
119
|
+
if (remaining <= 0) {
|
|
120
|
+
flushBuffer(key);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const delay = Math.min(DEBOUNCE_SLIDE_MS, remaining);
|
|
125
|
+
state.timer = setTimeout(() => flushBuffer(key), delay);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function flushBuffer(key: string): void {
|
|
129
|
+
const state = debounceBuffers.get(key);
|
|
130
|
+
if (!state) return;
|
|
131
|
+
debounceBuffers.delete(key);
|
|
132
|
+
|
|
133
|
+
const entries = state.entries;
|
|
134
|
+
if (entries.length === 0) return;
|
|
135
|
+
|
|
136
|
+
processBufferedEvents(entries, state.context).catch((err) => {
|
|
137
|
+
const error = state.context.runtime?.error ?? console.error;
|
|
138
|
+
error(`seatalk[${state.context.accountId}]: flush error: ${String(err)}`);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pushToBuffer(key: string, entry: BufferEntry, context: DebounceContext): void {
|
|
143
|
+
let state = debounceBuffers.get(key);
|
|
144
|
+
if (!state) {
|
|
145
|
+
state = {
|
|
146
|
+
entries: [],
|
|
147
|
+
timer: setTimeout(() => flushBuffer(key), DEBOUNCE_SLIDE_MS),
|
|
148
|
+
firstEventAt: Date.now(),
|
|
149
|
+
context,
|
|
150
|
+
};
|
|
151
|
+
debounceBuffers.set(key, state);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
state.entries.push(entry);
|
|
155
|
+
scheduleFlush(key, state);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveQuotedMessage(params: {
|
|
159
|
+
client: SeaTalkClient;
|
|
160
|
+
quotedMessageId: string;
|
|
161
|
+
log: (msg: string) => void;
|
|
162
|
+
}): Promise<{ text: string; media: SeaTalkMediaInfo[] } | null> {
|
|
163
|
+
const { client, quotedMessageId, log } = params;
|
|
164
|
+
try {
|
|
165
|
+
const data = await client.getMessageByMessageId(quotedMessageId);
|
|
166
|
+
const sender =
|
|
167
|
+
(data.sender as { employee_code?: string } | undefined)?.employee_code ?? "unknown";
|
|
168
|
+
const tag = data.tag as string | undefined;
|
|
169
|
+
|
|
170
|
+
const media: SeaTalkMediaInfo[] = [];
|
|
171
|
+
let content = "";
|
|
172
|
+
|
|
173
|
+
if (tag === "text") {
|
|
174
|
+
const textObj = data.text as { plain_text?: string; content?: string } | undefined;
|
|
175
|
+
content = textObj?.plain_text ?? textObj?.content ?? "";
|
|
176
|
+
} else if (tag === "image" || tag === "file" || tag === "video") {
|
|
177
|
+
const fakeMsg: SeaTalkMessage = {
|
|
178
|
+
message_id: quotedMessageId,
|
|
179
|
+
tag,
|
|
180
|
+
image:
|
|
181
|
+
tag === "image" ? (data.image as { content: string } | undefined) : undefined,
|
|
182
|
+
file:
|
|
183
|
+
tag === "file"
|
|
184
|
+
? (data.file as { content: string; filename: string } | undefined)
|
|
185
|
+
: undefined,
|
|
186
|
+
video:
|
|
187
|
+
tag === "video" ? (data.video as { content: string } | undefined) : undefined,
|
|
188
|
+
};
|
|
189
|
+
const resolved = await resolveInboundMedia({ message: fakeMsg, client, log });
|
|
190
|
+
if (resolved) {
|
|
191
|
+
media.push(resolved);
|
|
192
|
+
content = resolved.placeholder;
|
|
193
|
+
} else {
|
|
194
|
+
content = `<media:${tag}>`;
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
content = `<unsupported:${tag ?? "unknown"}>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { text: `[Quoted from ${sender}: ${content}]`, media };
|
|
201
|
+
} catch (err) {
|
|
202
|
+
log(`seatalk: failed to resolve quoted message ${quotedMessageId}: ${String(err)}`);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function processBufferedEvents(
|
|
208
|
+
entries: BufferEntry[],
|
|
209
|
+
context: DebounceContext,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const { cfg, client, runtime, accountId } = context;
|
|
212
|
+
const log = runtime?.log ?? console.log;
|
|
213
|
+
const error = runtime?.error ?? console.error;
|
|
214
|
+
|
|
215
|
+
const first = entries[0].parsedEvent;
|
|
216
|
+
const employeeCode = first.employee_code;
|
|
217
|
+
const email = first.email;
|
|
218
|
+
|
|
219
|
+
const textParts: string[] = [];
|
|
220
|
+
const mediaMessages: SeaTalkMessage[] = [];
|
|
221
|
+
|
|
222
|
+
for (const { parsedEvent } of entries) {
|
|
223
|
+
const msg = parsedEvent.message;
|
|
224
|
+
switch (msg.tag) {
|
|
225
|
+
case "text":
|
|
226
|
+
if (msg.text?.plain_text || msg.text?.content)
|
|
227
|
+
textParts.push(msg.text.plain_text ?? msg.text.content ?? "");
|
|
228
|
+
break;
|
|
229
|
+
case "image":
|
|
230
|
+
case "file":
|
|
231
|
+
case "video":
|
|
232
|
+
mediaMessages.push(msg);
|
|
233
|
+
break;
|
|
234
|
+
case "combined_forwarded_chat_history":
|
|
235
|
+
log(`seatalk[${accountId}]: skipping combined_forwarded_chat_history`);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const account = resolveSeaTalkAccount({ cfg, accountId });
|
|
241
|
+
const seatalkCfg = account.config;
|
|
242
|
+
|
|
243
|
+
const dmPolicy = seatalkCfg?.dmPolicy ?? "allowlist";
|
|
244
|
+
const allowFrom = seatalkCfg?.allowFrom ?? [];
|
|
245
|
+
|
|
246
|
+
if (dmPolicy === "allowlist") {
|
|
247
|
+
const allowed =
|
|
248
|
+
allowFrom.length === 0
|
|
249
|
+
? false
|
|
250
|
+
: allowFrom.some((entry) => {
|
|
251
|
+
const e = entry.trim();
|
|
252
|
+
if (e === "*") return true;
|
|
253
|
+
if (e === employeeCode) return true;
|
|
254
|
+
if (email && e.toLowerCase() === email.toLowerCase()) return true;
|
|
255
|
+
return false;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!allowed) {
|
|
259
|
+
log(`seatalk[${accountId}]: sender ${employeeCode} not in allowlist, dropping`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const mediaList: SeaTalkMediaInfo[] = [];
|
|
265
|
+
for (const msg of mediaMessages) {
|
|
266
|
+
const media = await resolveInboundMedia({ message: msg, client, log });
|
|
267
|
+
if (media) mediaList.push(media);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const quotedMessageId = first.message.quoted_message_id;
|
|
271
|
+
let quotedText: string | null = null;
|
|
272
|
+
if (quotedMessageId) {
|
|
273
|
+
const quoted = await resolveQuotedMessage({ client, quotedMessageId, log });
|
|
274
|
+
if (quoted) {
|
|
275
|
+
quotedText = quoted.text;
|
|
276
|
+
mediaList.push(...quoted.media);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const mediaPayload = buildSeaTalkMediaPayload(mediaList);
|
|
281
|
+
|
|
282
|
+
let messageText = textParts.join("\n");
|
|
283
|
+
if (quotedText) {
|
|
284
|
+
messageText = messageText ? `${quotedText}\n${messageText}` : quotedText;
|
|
285
|
+
}
|
|
286
|
+
if (!messageText && mediaList.length > 0) {
|
|
287
|
+
messageText = mediaList.map((m) => m.placeholder).join(" ");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!messageText && mediaList.length === 0) {
|
|
291
|
+
log(`seatalk[${accountId}]: skipping empty message from ${employeeCode}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const senderName = employeeCode + (email ? ` (${email})` : "");
|
|
296
|
+
const messageId = first.message.message_id;
|
|
297
|
+
const threadId = first.message.thread_id;
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const core = getSeatalkRuntime();
|
|
301
|
+
|
|
302
|
+
const seatalkFrom = `seatalk:${employeeCode}`;
|
|
303
|
+
const seatalkTo = employeeCode;
|
|
304
|
+
|
|
305
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
306
|
+
cfg,
|
|
307
|
+
channel: "seatalk",
|
|
308
|
+
accountId,
|
|
309
|
+
peer: {
|
|
310
|
+
kind: "direct",
|
|
311
|
+
id: employeeCode,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const preview = messageText.replace(/\s+/g, " ").slice(0, 160);
|
|
316
|
+
core.system.enqueueSystemEvent(`SeaTalk[${accountId}] DM from ${senderName}: ${preview}`, {
|
|
317
|
+
sessionKey: route.sessionKey,
|
|
318
|
+
contextKey: `seatalk:message:${employeeCode}:${messageId}`,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
322
|
+
const bodyForAgent = `${senderName}: ${messageText}`;
|
|
323
|
+
|
|
324
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
325
|
+
channel: "SeaTalk",
|
|
326
|
+
from: employeeCode,
|
|
327
|
+
timestamp: new Date(),
|
|
328
|
+
envelope: envelopeOptions,
|
|
329
|
+
body: bodyForAgent,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const metadata: Record<string, string> = {};
|
|
333
|
+
if (threadId) metadata.threadId = threadId;
|
|
334
|
+
if (quotedMessageId) metadata.quotedMessageId = quotedMessageId;
|
|
335
|
+
|
|
336
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
337
|
+
Body: body,
|
|
338
|
+
BodyForAgent: messageText,
|
|
339
|
+
RawBody: messageText,
|
|
340
|
+
CommandBody: messageText,
|
|
341
|
+
From: seatalkFrom,
|
|
342
|
+
To: seatalkTo,
|
|
343
|
+
SessionKey: route.sessionKey,
|
|
344
|
+
AccountId: route.accountId,
|
|
345
|
+
ChatType: "direct" as const,
|
|
346
|
+
SenderName: senderName,
|
|
347
|
+
SenderId: employeeCode,
|
|
348
|
+
Provider: "seatalk" as const,
|
|
349
|
+
Surface: "seatalk" as const,
|
|
350
|
+
MessageSid: messageId,
|
|
351
|
+
MessageThreadId: threadId || undefined,
|
|
352
|
+
Timestamp: Date.now(),
|
|
353
|
+
WasMentioned: false,
|
|
354
|
+
CommandAuthorized: true,
|
|
355
|
+
OriginatingChannel: "seatalk" as const,
|
|
356
|
+
OriginatingTo: seatalkTo,
|
|
357
|
+
...(Object.keys(metadata).length > 0 ? { Metadata: metadata } : {}),
|
|
358
|
+
...mediaPayload,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const processingIndicator = account.config?.processingIndicator ?? "typing";
|
|
362
|
+
if (processingIndicator === "typing") {
|
|
363
|
+
client
|
|
364
|
+
.setSingleChatTyping(employeeCode, threadId)
|
|
365
|
+
.catch((err) => log(`seatalk[${accountId}]: typing failed: ${String(err)}`));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const typingResult = core.channel.reply.createReplyDispatcherWithTyping({
|
|
369
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
370
|
+
deliver: async (payload) => {
|
|
371
|
+
const text = payload.text?.trim();
|
|
372
|
+
if (text) {
|
|
373
|
+
log(
|
|
374
|
+
`seatalk[${accountId}]: inline deliver DM to ${employeeCode} threadId=${threadId || "none"}`,
|
|
375
|
+
);
|
|
376
|
+
await sendTextMessage(client, employeeCode, text, 1, threadId);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
onError: (err) => {
|
|
380
|
+
error(`seatalk[${accountId}]: reply delivery failed: ${String(err)}`);
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const replyOptions = {
|
|
385
|
+
agentId: route.agentId,
|
|
386
|
+
...typingResult.replyOptions,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
log(`seatalk[${accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
393
|
+
ctx: ctxPayload,
|
|
394
|
+
cfg,
|
|
395
|
+
dispatcher: typingResult.dispatcher,
|
|
396
|
+
replyOptions,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
log(
|
|
400
|
+
`seatalk[${accountId}]: dispatch complete (queuedFinal=${queuedFinal}, counts=${JSON.stringify(counts)})`,
|
|
401
|
+
);
|
|
402
|
+
} finally {
|
|
403
|
+
typingResult.markDispatchIdle();
|
|
404
|
+
}
|
|
405
|
+
} catch (err) {
|
|
406
|
+
error(`seatalk[${accountId}]: failed to dispatch message: ${String(err)}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function handleSeaTalkMessage(params: {
|
|
411
|
+
cfg: ClawdbotConfig;
|
|
412
|
+
event: SeaTalkCallbackRequest;
|
|
413
|
+
client: SeaTalkClient;
|
|
414
|
+
runtime?: RuntimeEnv;
|
|
415
|
+
accountId: string;
|
|
416
|
+
}): Promise<void> {
|
|
417
|
+
const { cfg, event, client, runtime, accountId } = params;
|
|
418
|
+
const log = runtime?.log ?? console.log;
|
|
419
|
+
|
|
420
|
+
if (!tryRecordEvent(event.event_id)) {
|
|
421
|
+
log(`seatalk[${accountId}]: skipping duplicate event ${event.event_id}`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const msgEvent = event.event as unknown as SeaTalkMessageEvent;
|
|
426
|
+
if (!msgEvent?.employee_code || !msgEvent?.message) {
|
|
427
|
+
log(`seatalk[${accountId}]: malformed message event, skipping`);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
log(
|
|
432
|
+
`seatalk[${accountId}]: received ${msgEvent.message.tag} from ${msgEvent.employee_code} (threadId=${msgEvent.message.thread_id || "none"})`,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const key = dmDebounceKey(accountId, msgEvent.employee_code, msgEvent.message.thread_id);
|
|
436
|
+
pushToBuffer(key, { event, parsedEvent: msgEvent }, { cfg, client, runtime, accountId });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function extractGroupText(msg: SeaTalkMessage): string {
|
|
440
|
+
if (msg.tag === "text") {
|
|
441
|
+
return msg.text?.plain_text ?? msg.text?.content ?? "";
|
|
442
|
+
}
|
|
443
|
+
return "";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function checkGroupAccess(params: {
|
|
447
|
+
groupPolicy: string;
|
|
448
|
+
groupAllowFrom?: string[];
|
|
449
|
+
groupSenderAllowFrom?: string[];
|
|
450
|
+
groupId: string;
|
|
451
|
+
senderEmployeeCode: string;
|
|
452
|
+
senderEmail?: string;
|
|
453
|
+
}): { allowed: boolean; reason?: string } {
|
|
454
|
+
const {
|
|
455
|
+
groupPolicy,
|
|
456
|
+
groupAllowFrom,
|
|
457
|
+
groupSenderAllowFrom,
|
|
458
|
+
groupId,
|
|
459
|
+
senderEmployeeCode,
|
|
460
|
+
senderEmail,
|
|
461
|
+
} = params;
|
|
462
|
+
|
|
463
|
+
if (groupPolicy === "disabled") {
|
|
464
|
+
return { allowed: false, reason: "groupPolicy is disabled" };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (groupPolicy === "allowlist") {
|
|
468
|
+
const list = groupAllowFrom ?? [];
|
|
469
|
+
if (!list.includes(groupId)) {
|
|
470
|
+
return { allowed: false, reason: `group ${groupId} not in groupAllowFrom` };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (groupSenderAllowFrom && groupSenderAllowFrom.length > 0) {
|
|
475
|
+
const match = groupSenderAllowFrom.some((entry) => {
|
|
476
|
+
const e = entry.trim();
|
|
477
|
+
if (e === "*") return true;
|
|
478
|
+
if (e === senderEmployeeCode) return true;
|
|
479
|
+
if (senderEmail && e.toLowerCase() === senderEmail.toLowerCase()) return true;
|
|
480
|
+
return false;
|
|
481
|
+
});
|
|
482
|
+
if (!match) {
|
|
483
|
+
return {
|
|
484
|
+
allowed: false,
|
|
485
|
+
reason: `sender ${senderEmployeeCode} not in groupSenderAllowFrom`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { allowed: true };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function handleSeaTalkGroupMessage(params: {
|
|
494
|
+
cfg: ClawdbotConfig;
|
|
495
|
+
event: SeaTalkCallbackRequest;
|
|
496
|
+
client: SeaTalkClient;
|
|
497
|
+
runtime?: RuntimeEnv;
|
|
498
|
+
accountId: string;
|
|
499
|
+
}): Promise<void> {
|
|
500
|
+
const { cfg, event, client, runtime, accountId } = params;
|
|
501
|
+
const log = runtime?.log ?? console.log;
|
|
502
|
+
const error = runtime?.error ?? console.error;
|
|
503
|
+
|
|
504
|
+
if (!tryRecordEvent(event.event_id)) {
|
|
505
|
+
log(`seatalk[${accountId}]: skipping duplicate group event ${event.event_id}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const groupEvent = event.event as unknown as SeaTalkGroupMessageEvent;
|
|
510
|
+
const groupId = groupEvent?.group_id;
|
|
511
|
+
const msg = groupEvent?.message;
|
|
512
|
+
const sender = msg?.sender;
|
|
513
|
+
|
|
514
|
+
if (!groupId || !msg || !sender?.employee_code) {
|
|
515
|
+
log(`seatalk[${accountId}]: malformed group message event, skipping`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (sender.sender_type === 2) {
|
|
520
|
+
log(`seatalk[${accountId}]: ignoring bot message in group ${groupId}`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const employeeCode = sender.employee_code;
|
|
525
|
+
const senderEmail = sender.email;
|
|
526
|
+
const threadId = msg.thread_id;
|
|
527
|
+
|
|
528
|
+
log(
|
|
529
|
+
`seatalk[${accountId}]: group ${groupId} ${msg.tag} from ${employeeCode} (event=${event.event_type})`,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const account = resolveSeaTalkAccount({ cfg, accountId });
|
|
533
|
+
const seatalkCfg = account.config;
|
|
534
|
+
|
|
535
|
+
const access = checkGroupAccess({
|
|
536
|
+
groupPolicy: seatalkCfg?.groupPolicy ?? "disabled",
|
|
537
|
+
groupAllowFrom: seatalkCfg?.groupAllowFrom,
|
|
538
|
+
groupSenderAllowFrom: seatalkCfg?.groupSenderAllowFrom,
|
|
539
|
+
groupId,
|
|
540
|
+
senderEmployeeCode: employeeCode,
|
|
541
|
+
senderEmail,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (!access.allowed) {
|
|
545
|
+
log(`seatalk[${accountId}]: group access denied: ${access.reason}`);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const mediaMessages: SeaTalkMessage[] = [];
|
|
550
|
+
let messageText = "";
|
|
551
|
+
|
|
552
|
+
if (msg.tag === "text") {
|
|
553
|
+
messageText = extractGroupText(msg);
|
|
554
|
+
} else if (msg.tag === "image" || msg.tag === "file" || msg.tag === "video") {
|
|
555
|
+
mediaMessages.push(msg);
|
|
556
|
+
} else if (msg.tag === "combined_forwarded_chat_history") {
|
|
557
|
+
log(`seatalk[${accountId}]: skipping combined_forwarded_chat_history in group ${groupId}`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const mediaList: SeaTalkMediaInfo[] = [];
|
|
562
|
+
for (const m of mediaMessages) {
|
|
563
|
+
const media = await resolveInboundMedia({ message: m, client, log });
|
|
564
|
+
if (media) mediaList.push(media);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const quotedMessageId = msg.quoted_message_id;
|
|
568
|
+
let quotedText: string | null = null;
|
|
569
|
+
if (quotedMessageId) {
|
|
570
|
+
const quoted = await resolveQuotedMessage({ client, quotedMessageId, log });
|
|
571
|
+
if (quoted) {
|
|
572
|
+
quotedText = quoted.text;
|
|
573
|
+
mediaList.push(...quoted.media);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const mediaPayload = buildSeaTalkMediaPayload(mediaList);
|
|
578
|
+
|
|
579
|
+
if (quotedText) {
|
|
580
|
+
messageText = messageText ? `${quotedText}\n${messageText}` : quotedText;
|
|
581
|
+
}
|
|
582
|
+
if (!messageText && mediaList.length > 0) {
|
|
583
|
+
messageText = mediaList.map((m) => m.placeholder).join(" ");
|
|
584
|
+
}
|
|
585
|
+
if (!messageText && mediaList.length === 0) {
|
|
586
|
+
log(
|
|
587
|
+
`seatalk[${accountId}]: skipping empty group message from ${employeeCode} in ${groupId}`,
|
|
588
|
+
);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const senderName = employeeCode + (senderEmail ? ` (${senderEmail})` : "");
|
|
593
|
+
const messageId = msg.message_id;
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
const core = getSeatalkRuntime();
|
|
597
|
+
|
|
598
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
599
|
+
cfg,
|
|
600
|
+
channel: "seatalk",
|
|
601
|
+
accountId,
|
|
602
|
+
peer: {
|
|
603
|
+
kind: "group",
|
|
604
|
+
id: groupId,
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const preview = messageText.replace(/\s+/g, " ").slice(0, 160);
|
|
609
|
+
core.system.enqueueSystemEvent(
|
|
610
|
+
`SeaTalk[${accountId}] Group(${groupId}) from ${senderName}: ${preview}`,
|
|
611
|
+
{ sessionKey: route.sessionKey, contextKey: `seatalk:group:${groupId}:${messageId}` },
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
615
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
616
|
+
channel: "SeaTalk",
|
|
617
|
+
from: employeeCode,
|
|
618
|
+
timestamp: new Date(),
|
|
619
|
+
envelope: envelopeOptions,
|
|
620
|
+
body: `${senderName}: ${messageText}`,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const metadata: Record<string, string> = { groupId };
|
|
624
|
+
if (threadId) metadata.threadId = threadId;
|
|
625
|
+
if (quotedMessageId) metadata.quotedMessageId = quotedMessageId;
|
|
626
|
+
|
|
627
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
628
|
+
Body: body,
|
|
629
|
+
BodyForAgent: messageText,
|
|
630
|
+
RawBody: messageText,
|
|
631
|
+
CommandBody: messageText,
|
|
632
|
+
From: `seatalk:${employeeCode}`,
|
|
633
|
+
To: `group:${groupId}`,
|
|
634
|
+
SessionKey: route.sessionKey,
|
|
635
|
+
AccountId: route.accountId,
|
|
636
|
+
ChatType: "group" as const,
|
|
637
|
+
SenderName: senderName,
|
|
638
|
+
SenderId: employeeCode,
|
|
639
|
+
Provider: "seatalk" as const,
|
|
640
|
+
Surface: "seatalk" as const,
|
|
641
|
+
MessageSid: messageId,
|
|
642
|
+
MessageThreadId: threadId || undefined,
|
|
643
|
+
Timestamp: Date.now(),
|
|
644
|
+
WasMentioned: event.event_type === "new_mentioned_message_received_from_group_chat",
|
|
645
|
+
CommandAuthorized: true,
|
|
646
|
+
OriginatingChannel: "seatalk" as const,
|
|
647
|
+
OriginatingTo: `group:${groupId}`,
|
|
648
|
+
Metadata: metadata,
|
|
649
|
+
...mediaPayload,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const processingIndicator = seatalkCfg?.processingIndicator ?? "typing";
|
|
653
|
+
if (processingIndicator === "typing") {
|
|
654
|
+
client
|
|
655
|
+
.setGroupChatTyping(groupId, threadId)
|
|
656
|
+
.catch((err) => log(`seatalk[${accountId}]: group typing failed: ${String(err)}`));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const replyThreadId = threadId || undefined;
|
|
660
|
+
|
|
661
|
+
const typingResult = core.channel.reply.createReplyDispatcherWithTyping({
|
|
662
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
663
|
+
deliver: async (payload) => {
|
|
664
|
+
const text = payload.text?.trim();
|
|
665
|
+
if (text) {
|
|
666
|
+
await sendGroupTextMessage(client, groupId, text, 1, replyThreadId);
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
onError: (err) => {
|
|
670
|
+
error(`seatalk[${accountId}]: group reply delivery failed: ${String(err)}`);
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const replyOptions = {
|
|
675
|
+
agentId: route.agentId,
|
|
676
|
+
...typingResult.replyOptions,
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
log(`seatalk[${accountId}]: dispatching group message (session=${route.sessionKey})`);
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
683
|
+
ctx: ctxPayload,
|
|
684
|
+
cfg,
|
|
685
|
+
dispatcher: typingResult.dispatcher,
|
|
686
|
+
replyOptions,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
log(
|
|
690
|
+
`seatalk[${accountId}]: group dispatch complete (queuedFinal=${queuedFinal}, counts=${JSON.stringify(counts)})`,
|
|
691
|
+
);
|
|
692
|
+
} finally {
|
|
693
|
+
typingResult.markDispatchIdle();
|
|
694
|
+
}
|
|
695
|
+
} catch (err) {
|
|
696
|
+
error(`seatalk[${accountId}]: failed to dispatch group message: ${String(err)}`);
|
|
697
|
+
}
|
|
698
|
+
}
|