multi-openim-channel 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 +21 -0
- package/README.md +255 -0
- package/SCHEMA.md +167 -0
- package/dist/channel.d.ts +57 -0
- package/dist/channel.js +104 -0
- package/dist/clients.d.ts +20 -0
- package/dist/clients.js +329 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +256 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.js +8 -0
- package/dist/friend-guard.d.ts +19 -0
- package/dist/friend-guard.js +66 -0
- package/dist/inbound.d.ts +17 -0
- package/dist/inbound.js +639 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +71 -0
- package/dist/media.d.ts +10 -0
- package/dist/media.js +157 -0
- package/dist/polyfills.d.ts +5 -0
- package/dist/polyfills.js +27 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +69 -0
- package/dist/targets.d.ts +7 -0
- package/dist/targets.js +38 -0
- package/dist/token-refresh.d.ts +50 -0
- package/dist/token-refresh.js +383 -0
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +153 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.js +4 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +68 -0
- package/openclaw.plugin.json +258 -0
- package/package.json +59 -0
package/dist/inbound.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound message dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline per message:
|
|
5
|
+
* 1. Filter (self-sent ignore, dedup, whitelist gating, group @-mention).
|
|
6
|
+
* 2. Extract a structured body (text + media items + quote unwrapping).
|
|
7
|
+
* 3. Optionally materialize image bytes for downstream multimodal agents.
|
|
8
|
+
* 4. Build a session key that always includes accountId for direct chats
|
|
9
|
+
* so two accounts in the same conversation never collide.
|
|
10
|
+
* 5. Hand off to the host runtime's reply pipeline with a deliver()
|
|
11
|
+
* callback that routes the agent reply back via the SDK.
|
|
12
|
+
*/
|
|
13
|
+
import { lookup } from "node:dns/promises";
|
|
14
|
+
import { isIP } from "node:net";
|
|
15
|
+
import { SessionType } from "@openim/client-sdk";
|
|
16
|
+
import { sendTextToTarget } from "./media.js";
|
|
17
|
+
import { parseTarget as parseChannelTarget } from "./targets.js";
|
|
18
|
+
import { CHANNEL_ID } from "./types.js";
|
|
19
|
+
import { formatSdkError, logTag } from "./utils.js";
|
|
20
|
+
/* ---------------- dedup (per-account insertion-order LRU) ---------------- */
|
|
21
|
+
const INBOUND_DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
22
|
+
const INBOUND_DEDUP_MAX_ENTRIES_PER_ACCOUNT = 4_096;
|
|
23
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
24
|
+
const MAX_TOTAL_INBOUND_IMAGE_BYTES = 40 * 1024 * 1024;
|
|
25
|
+
const IMAGE_FETCH_TIMEOUT_MS = 8_000;
|
|
26
|
+
/* ---------------- concurrency cap (per account) ---------------- */
|
|
27
|
+
const MAX_INBOUND_INFLIGHT_PER_ACCOUNT = 16;
|
|
28
|
+
const DROP_WARN_FLUSH_INTERVAL_MS = 30_000;
|
|
29
|
+
const inflightByAccount = new Map();
|
|
30
|
+
const dropAggByAccount = new Map();
|
|
31
|
+
function noteInboundDrop(ctx, accountId) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
let agg = dropAggByAccount.get(accountId);
|
|
34
|
+
if (!agg) {
|
|
35
|
+
agg = { count: 0, firstDropAt: now, lastFlushAt: 0 };
|
|
36
|
+
dropAggByAccount.set(accountId, agg);
|
|
37
|
+
}
|
|
38
|
+
if (agg.count === 0)
|
|
39
|
+
agg.firstDropAt = now;
|
|
40
|
+
agg.count++;
|
|
41
|
+
if (now - agg.lastFlushAt >= DROP_WARN_FLUSH_INTERVAL_MS) {
|
|
42
|
+
ctx.logger.warn?.(`${logTag("inbound")} rate-limited (account=${accountId} drops=${agg.count} since=${new Date(agg.firstDropAt).toISOString()})`);
|
|
43
|
+
agg.count = 0;
|
|
44
|
+
agg.lastFlushAt = now;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const inboundDedupByAccount = new Map();
|
|
48
|
+
function getDedupBucket(accountId) {
|
|
49
|
+
let bucket = inboundDedupByAccount.get(accountId);
|
|
50
|
+
if (!bucket) {
|
|
51
|
+
bucket = new Map();
|
|
52
|
+
inboundDedupByAccount.set(accountId, bucket);
|
|
53
|
+
}
|
|
54
|
+
return bucket;
|
|
55
|
+
}
|
|
56
|
+
function shouldProcessInboundMessage(accountId, msg) {
|
|
57
|
+
const idPart = String(msg.clientMsgID || msg.serverMsgID || `${msg.sendID}-${msg.seq ?? msg.createTime ?? 0}`);
|
|
58
|
+
if (!idPart)
|
|
59
|
+
return true;
|
|
60
|
+
const bucket = getDedupBucket(accountId);
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const last = bucket.get(idPart);
|
|
63
|
+
if (last !== undefined) {
|
|
64
|
+
if (now - last < INBOUND_DEDUP_TTL_MS) {
|
|
65
|
+
bucket.delete(idPart);
|
|
66
|
+
bucket.set(idPart, now);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
bucket.delete(idPart);
|
|
70
|
+
}
|
|
71
|
+
bucket.set(idPart, now);
|
|
72
|
+
if (bucket.size > INBOUND_DEDUP_MAX_ENTRIES_PER_ACCOUNT) {
|
|
73
|
+
const oldest = bucket.keys().next().value;
|
|
74
|
+
if (oldest !== undefined)
|
|
75
|
+
bucket.delete(oldest);
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
function shouldIgnoreSelfSentMessage(state, msg) {
|
|
80
|
+
const selfUserID = String(state.config.userID);
|
|
81
|
+
if (String(msg.sendID) !== selfUserID)
|
|
82
|
+
return false;
|
|
83
|
+
const isDirectSelfChat = msg.sessionType !== SessionType.Group && String(msg.recvID) === selfUserID;
|
|
84
|
+
if (!isDirectSelfChat)
|
|
85
|
+
return true;
|
|
86
|
+
const localPlatformID = Number(state.config.platformID);
|
|
87
|
+
const senderPlatformID = Number(msg.senderPlatformID);
|
|
88
|
+
if (!Number.isFinite(localPlatformID) || !Number.isFinite(senderPlatformID))
|
|
89
|
+
return true;
|
|
90
|
+
return localPlatformID === senderPlatformID;
|
|
91
|
+
}
|
|
92
|
+
function isGroupMessage(msg) {
|
|
93
|
+
return msg.sessionType === SessionType.Group && !!msg.groupID;
|
|
94
|
+
}
|
|
95
|
+
function isMentionedInGroup(msg, selfUserID) {
|
|
96
|
+
const list = msg.atTextElem?.atUserList;
|
|
97
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
98
|
+
return false;
|
|
99
|
+
const id = String(selfUserID);
|
|
100
|
+
return list.some((item) => String(item) === id);
|
|
101
|
+
}
|
|
102
|
+
function normalizeSenderId(raw) {
|
|
103
|
+
const target = parseChannelTarget(raw);
|
|
104
|
+
if (!target)
|
|
105
|
+
return "";
|
|
106
|
+
return target.id.toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
function isWhitelistedSender(state, msg) {
|
|
109
|
+
const whitelist = state.config.inboundWhitelist;
|
|
110
|
+
if (!Array.isArray(whitelist) || whitelist.length === 0)
|
|
111
|
+
return true;
|
|
112
|
+
const senderId = normalizeSenderId(msg.sendID);
|
|
113
|
+
if (!senderId)
|
|
114
|
+
return false;
|
|
115
|
+
return whitelist.some((id) => normalizeSenderId(id) === senderId);
|
|
116
|
+
}
|
|
117
|
+
function normalizeMimeType(value) {
|
|
118
|
+
const mime = String(value ?? "").trim().toLowerCase();
|
|
119
|
+
return mime.includes("/") ? mime : undefined;
|
|
120
|
+
}
|
|
121
|
+
function normalizeImageMimeType(value) {
|
|
122
|
+
const mime = String(value ?? "").trim().toLowerCase();
|
|
123
|
+
return mime.startsWith("image/") ? mime : undefined;
|
|
124
|
+
}
|
|
125
|
+
function normalizeString(value) {
|
|
126
|
+
const text = String(value ?? "").trim();
|
|
127
|
+
return text || undefined;
|
|
128
|
+
}
|
|
129
|
+
function normalizeSize(value) {
|
|
130
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
131
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
132
|
+
}
|
|
133
|
+
function summarizeMedia(item) {
|
|
134
|
+
if (item.kind === "image")
|
|
135
|
+
return item.url ? `[Image] ${item.url}` : "[Image message]";
|
|
136
|
+
if (item.kind === "video") {
|
|
137
|
+
const parts = ["[Video]"];
|
|
138
|
+
if (item.fileName)
|
|
139
|
+
parts.push(`name=${item.fileName}`);
|
|
140
|
+
if (item.url)
|
|
141
|
+
parts.push(`video=${item.url}`);
|
|
142
|
+
if (item.snapshotUrl)
|
|
143
|
+
parts.push(`snapshot=${item.snapshotUrl}`);
|
|
144
|
+
if (item.size)
|
|
145
|
+
parts.push(`size=${item.size}`);
|
|
146
|
+
return parts.join(" ");
|
|
147
|
+
}
|
|
148
|
+
const parts = ["[File]"];
|
|
149
|
+
if (item.fileName)
|
|
150
|
+
parts.push(`name=${item.fileName}`);
|
|
151
|
+
if (item.mimeType)
|
|
152
|
+
parts.push(`type=${item.mimeType}`);
|
|
153
|
+
if (item.url)
|
|
154
|
+
parts.push(`url=${item.url}`);
|
|
155
|
+
if (item.size)
|
|
156
|
+
parts.push(`size=${item.size}`);
|
|
157
|
+
return parts.join(" ");
|
|
158
|
+
}
|
|
159
|
+
function extractPictureMedia(msg) {
|
|
160
|
+
const pic = msg.pictureElem;
|
|
161
|
+
if (!pic)
|
|
162
|
+
return [];
|
|
163
|
+
const url = normalizeString(pic.sourcePicture?.url) || normalizeString(pic.bigPicture?.url) || normalizeString(pic.snapshotPicture?.url);
|
|
164
|
+
const mimeType = normalizeImageMimeType(pic.sourcePicture?.type) ||
|
|
165
|
+
normalizeImageMimeType(pic.bigPicture?.type) ||
|
|
166
|
+
normalizeImageMimeType(pic.snapshotPicture?.type);
|
|
167
|
+
return [{ kind: "image", url, mimeType }];
|
|
168
|
+
}
|
|
169
|
+
function extractVideoMedia(msg) {
|
|
170
|
+
const video = msg.videoElem;
|
|
171
|
+
if (!video)
|
|
172
|
+
return [];
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
kind: "video",
|
|
176
|
+
url: normalizeString(video.videoUrl),
|
|
177
|
+
snapshotUrl: normalizeString(video.snapshotUrl),
|
|
178
|
+
fileName: normalizeString(video.videoName ?? video.fileName ?? video.snapshotName),
|
|
179
|
+
size: normalizeSize(video.videoSize ?? video.duration),
|
|
180
|
+
mimeType: normalizeMimeType(video.videoType ?? video.type),
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
function extractFileMedia(msg) {
|
|
185
|
+
const file = msg.fileElem;
|
|
186
|
+
if (!file)
|
|
187
|
+
return [];
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
kind: "file",
|
|
191
|
+
url: normalizeString(file.sourceUrl),
|
|
192
|
+
fileName: normalizeString(file.fileName),
|
|
193
|
+
size: normalizeSize(file.fileSize),
|
|
194
|
+
mimeType: normalizeMimeType(file.fileType ?? file.type),
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
function mergeBodyParts(parts) {
|
|
199
|
+
const valid = parts.filter((p) => p && (p.body || (p.media && p.media.length > 0)));
|
|
200
|
+
if (valid.length === 0)
|
|
201
|
+
return { body: "", kind: "unknown" };
|
|
202
|
+
const bodies = valid.map((p) => p.body).filter(Boolean);
|
|
203
|
+
const media = valid.flatMap((p) => p.media ?? []);
|
|
204
|
+
if (valid.length === 1) {
|
|
205
|
+
const only = valid[0];
|
|
206
|
+
if (!only)
|
|
207
|
+
return { body: "", kind: "unknown" };
|
|
208
|
+
return {
|
|
209
|
+
body: bodies[0] || "",
|
|
210
|
+
kind: only.kind,
|
|
211
|
+
media: media.length > 0 ? media : undefined,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
body: bodies.join("\n"),
|
|
216
|
+
kind: "mixed",
|
|
217
|
+
media: media.length > 0 ? media : undefined,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function extractInboundBody(msg, depth = 0) {
|
|
221
|
+
const text = String(msg.textElem?.content ??
|
|
222
|
+
msg.atTextElem?.text ??
|
|
223
|
+
"").trim();
|
|
224
|
+
const imageMedia = extractPictureMedia(msg);
|
|
225
|
+
const videoMedia = extractVideoMedia(msg);
|
|
226
|
+
const fileMedia = extractFileMedia(msg);
|
|
227
|
+
const quoteElem = msg.quoteElem;
|
|
228
|
+
if (quoteElem?.quoteMessage) {
|
|
229
|
+
const quoted = quoteElem.quoteMessage;
|
|
230
|
+
const quotedSender = String(quoted.senderNickname || quoted.sendID || "unknown");
|
|
231
|
+
const quotedBody = depth < 2
|
|
232
|
+
? extractInboundBody(quoted, depth + 1)
|
|
233
|
+
: { body: "[quoted message]", kind: "mixed" };
|
|
234
|
+
const currentParts = [];
|
|
235
|
+
if (text)
|
|
236
|
+
currentParts.push(`Reply: ${text}`);
|
|
237
|
+
for (const item of [...imageMedia, ...videoMedia, ...fileMedia]) {
|
|
238
|
+
currentParts.push(`Reply attachment: ${summarizeMedia(item)}`);
|
|
239
|
+
}
|
|
240
|
+
const bodyLines = [`[Quote] ${quotedSender}: ${quotedBody.body || "[empty message]"}`];
|
|
241
|
+
if (currentParts.length > 0)
|
|
242
|
+
bodyLines.push(currentParts.join("\n"));
|
|
243
|
+
return {
|
|
244
|
+
body: bodyLines.join("\n"),
|
|
245
|
+
kind: currentParts.length > 0 ? "mixed" : quotedBody.kind,
|
|
246
|
+
media: [...imageMedia, ...videoMedia, ...fileMedia],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const parts = [];
|
|
250
|
+
if (text)
|
|
251
|
+
parts.push({ body: text, kind: "text" });
|
|
252
|
+
for (const item of imageMedia)
|
|
253
|
+
parts.push({ body: summarizeMedia(item), kind: "image", media: [item] });
|
|
254
|
+
for (const item of videoMedia)
|
|
255
|
+
parts.push({ body: summarizeMedia(item), kind: "video", media: [item] });
|
|
256
|
+
for (const item of fileMedia)
|
|
257
|
+
parts.push({ body: summarizeMedia(item), kind: "file", media: [item] });
|
|
258
|
+
const customElem = msg.customElem;
|
|
259
|
+
if (customElem?.data || customElem?.description || customElem?.extension) {
|
|
260
|
+
const txt = String(customElem.description ?? customElem.data ?? customElem.extension ?? "").trim() ||
|
|
261
|
+
"[Custom message]";
|
|
262
|
+
parts.push({ body: `[Custom message] ${txt}`, kind: "mixed" });
|
|
263
|
+
}
|
|
264
|
+
return mergeBodyParts(parts);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Reject URLs that resolve to private / link-local / loopback / metadata
|
|
268
|
+
* addresses. Inbound image URLs are peer-controlled, so without this guard
|
|
269
|
+
* a hostile sender could probe the host's internal network through the
|
|
270
|
+
* agent process.
|
|
271
|
+
*/
|
|
272
|
+
function isPrivateOrLinkLocalIp(addr) {
|
|
273
|
+
const v = isIP(addr);
|
|
274
|
+
if (v === 4) {
|
|
275
|
+
const parts = addr.split(".").map((x) => Number(x));
|
|
276
|
+
const a = parts[0] ?? 0;
|
|
277
|
+
const b = parts[1] ?? 0;
|
|
278
|
+
if (a === 0)
|
|
279
|
+
return true;
|
|
280
|
+
if (a === 10)
|
|
281
|
+
return true;
|
|
282
|
+
if (a === 127)
|
|
283
|
+
return true;
|
|
284
|
+
if (a === 169 && b === 254)
|
|
285
|
+
return true;
|
|
286
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
287
|
+
return true;
|
|
288
|
+
if (a === 192 && b === 168)
|
|
289
|
+
return true;
|
|
290
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
291
|
+
return true;
|
|
292
|
+
if (a >= 224)
|
|
293
|
+
return true;
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (v === 6) {
|
|
297
|
+
const lower = addr.toLowerCase();
|
|
298
|
+
if (lower === "::" || lower === "::1")
|
|
299
|
+
return true;
|
|
300
|
+
if (lower.startsWith("fe80:") || lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
|
304
|
+
return true;
|
|
305
|
+
if (lower.startsWith("ff"))
|
|
306
|
+
return true;
|
|
307
|
+
if (lower.startsWith("::ffff:")) {
|
|
308
|
+
const v4 = lower.slice("::ffff:".length);
|
|
309
|
+
if (isIP(v4) === 4)
|
|
310
|
+
return isPrivateOrLinkLocalIp(v4);
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
const DISALLOWED_HOSTNAMES = new Set([
|
|
317
|
+
"localhost",
|
|
318
|
+
"metadata",
|
|
319
|
+
"metadata.google.internal",
|
|
320
|
+
"instance-data",
|
|
321
|
+
"instance-data.ec2.internal",
|
|
322
|
+
]);
|
|
323
|
+
async function assertSafeImageUrl(url) {
|
|
324
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
325
|
+
throw new Error(`disallowed protocol: ${url.protocol}`);
|
|
326
|
+
}
|
|
327
|
+
const host = url.hostname.toLowerCase();
|
|
328
|
+
if (!host)
|
|
329
|
+
throw new Error("empty hostname");
|
|
330
|
+
if (DISALLOWED_HOSTNAMES.has(host)) {
|
|
331
|
+
throw new Error(`disallowed hostname: ${host}`);
|
|
332
|
+
}
|
|
333
|
+
if (isIP(host)) {
|
|
334
|
+
if (isPrivateOrLinkLocalIp(host)) {
|
|
335
|
+
throw new Error(`disallowed IP: ${host}`);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
let resolved;
|
|
340
|
+
try {
|
|
341
|
+
resolved = await lookup(host, { all: true });
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
throw new Error(`DNS lookup failed for ${host}: ${e.message}`);
|
|
345
|
+
}
|
|
346
|
+
for (const entry of resolved) {
|
|
347
|
+
if (isPrivateOrLinkLocalIp(entry.address)) {
|
|
348
|
+
throw new Error(`hostname ${host} resolves to disallowed IP ${entry.address}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function readBodyWithCap(response, controller) {
|
|
353
|
+
const body = response.body;
|
|
354
|
+
if (!body)
|
|
355
|
+
throw new Error("response has no body");
|
|
356
|
+
const reader = body.getReader();
|
|
357
|
+
const chunks = [];
|
|
358
|
+
let total = 0;
|
|
359
|
+
try {
|
|
360
|
+
while (true) {
|
|
361
|
+
const { done, value } = await reader.read();
|
|
362
|
+
if (done)
|
|
363
|
+
break;
|
|
364
|
+
if (!value)
|
|
365
|
+
continue;
|
|
366
|
+
total += value.byteLength;
|
|
367
|
+
if (total > MAX_IMAGE_BYTES) {
|
|
368
|
+
controller.abort();
|
|
369
|
+
throw new Error(`image too large: ${total} bytes (cap ${MAX_IMAGE_BYTES})`);
|
|
370
|
+
}
|
|
371
|
+
chunks.push(value);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
try {
|
|
376
|
+
reader.releaseLock();
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// ignore: cancellation can leave the lock in an odd state
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c)), total);
|
|
383
|
+
}
|
|
384
|
+
async function fetchImageAsContentPart(url, hintedMime) {
|
|
385
|
+
let parsed;
|
|
386
|
+
try {
|
|
387
|
+
parsed = new URL(url);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
throw new Error(`invalid image URL`);
|
|
391
|
+
}
|
|
392
|
+
await assertSafeImageUrl(parsed);
|
|
393
|
+
const controller = new AbortController();
|
|
394
|
+
const timer = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
|
|
395
|
+
let response;
|
|
396
|
+
try {
|
|
397
|
+
response = await fetch(parsed, { signal: controller.signal, redirect: "error" });
|
|
398
|
+
}
|
|
399
|
+
catch (e) {
|
|
400
|
+
if (controller.signal.aborted) {
|
|
401
|
+
throw new Error(`image fetch timeout after ${IMAGE_FETCH_TIMEOUT_MS}ms`);
|
|
402
|
+
}
|
|
403
|
+
throw e;
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
}
|
|
408
|
+
if (!response.ok) {
|
|
409
|
+
throw new Error(`image fetch failed: ${response.status} ${response.statusText}`);
|
|
410
|
+
}
|
|
411
|
+
const declaredLength = Number(response.headers.get("content-length") || 0);
|
|
412
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
|
|
413
|
+
throw new Error(`image too large: ${declaredLength} bytes (cap ${MAX_IMAGE_BYTES})`);
|
|
414
|
+
}
|
|
415
|
+
const buffer = await readBodyWithCap(response, controller);
|
|
416
|
+
const mimeType = normalizeImageMimeType(response.headers.get("content-type")) ??
|
|
417
|
+
normalizeImageMimeType(hintedMime) ??
|
|
418
|
+
"image/jpeg";
|
|
419
|
+
return {
|
|
420
|
+
type: "image",
|
|
421
|
+
data: buffer.toString("base64"),
|
|
422
|
+
mimeType,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
async function materializeInboundMedia(media) {
|
|
426
|
+
if (!Array.isArray(media) || media.length === 0)
|
|
427
|
+
return { images: [], warnings: [] };
|
|
428
|
+
const images = [];
|
|
429
|
+
const warnings = [];
|
|
430
|
+
let totalBytes = 0;
|
|
431
|
+
for (const item of media) {
|
|
432
|
+
try {
|
|
433
|
+
const url = item.kind === "image" ? item.url : item.kind === "video" ? item.snapshotUrl : undefined;
|
|
434
|
+
if (!url)
|
|
435
|
+
continue;
|
|
436
|
+
const part = await fetchImageAsContentPart(url, item.mimeType);
|
|
437
|
+
const approxBytes = part.data.length;
|
|
438
|
+
if (totalBytes + approxBytes > MAX_TOTAL_INBOUND_IMAGE_BYTES) {
|
|
439
|
+
warnings.push(`${summarizeMedia(item)} => skipped (cumulative image bytes ${totalBytes + approxBytes} exceeds ${MAX_TOTAL_INBOUND_IMAGE_BYTES})`);
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
totalBytes += approxBytes;
|
|
443
|
+
images.push(part);
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
warnings.push(`${summarizeMedia(item)} => ${formatSdkError(e)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return { images, warnings };
|
|
450
|
+
}
|
|
451
|
+
function getRuntime(api) {
|
|
452
|
+
const rt = api.runtime;
|
|
453
|
+
return rt && typeof rt === "object" ? rt : undefined;
|
|
454
|
+
}
|
|
455
|
+
function buildTextEnvelope(runtime, cfg, fromLabel, senderId, timestamp, bodyText, chatType) {
|
|
456
|
+
const reply = runtime?.channel?.reply;
|
|
457
|
+
if (!reply?.formatInboundEnvelope)
|
|
458
|
+
return bodyText;
|
|
459
|
+
const envelopeOptions = reply.resolveEnvelopeFormatOptions?.(cfg) ?? {};
|
|
460
|
+
const formatted = reply.formatInboundEnvelope({
|
|
461
|
+
channel: "Multi-OpenIM",
|
|
462
|
+
from: fromLabel,
|
|
463
|
+
timestamp,
|
|
464
|
+
body: bodyText,
|
|
465
|
+
chatType,
|
|
466
|
+
sender: { name: fromLabel, id: senderId },
|
|
467
|
+
envelope: envelopeOptions,
|
|
468
|
+
});
|
|
469
|
+
return typeof formatted === "string" && formatted ? formatted : bodyText;
|
|
470
|
+
}
|
|
471
|
+
async function sendReplyFromInbound(state, msg, text) {
|
|
472
|
+
const target = isGroupMessage(msg)
|
|
473
|
+
? { kind: "group", id: String(msg.groupID) }
|
|
474
|
+
: { kind: "user", id: String(msg.sendID) };
|
|
475
|
+
await sendTextToTarget(state, target, text);
|
|
476
|
+
}
|
|
477
|
+
export async function processInboundMessage(ctx, state, msg) {
|
|
478
|
+
const aid = state.config.accountId;
|
|
479
|
+
const inflight = inflightByAccount.get(aid) ?? 0;
|
|
480
|
+
if (inflight >= MAX_INBOUND_INFLIGHT_PER_ACCOUNT) {
|
|
481
|
+
noteInboundDrop(ctx, aid);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
inflightByAccount.set(aid, inflight + 1);
|
|
485
|
+
try {
|
|
486
|
+
await processInboundMessageImpl(ctx, state, msg);
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
const n = inflightByAccount.get(aid) ?? 1;
|
|
490
|
+
if (n <= 1)
|
|
491
|
+
inflightByAccount.delete(aid);
|
|
492
|
+
else
|
|
493
|
+
inflightByAccount.set(aid, n - 1);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function processInboundMessageImpl(ctx, state, msg) {
|
|
497
|
+
const api = ctx.api;
|
|
498
|
+
const runtime = getRuntime(api);
|
|
499
|
+
if (!runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
500
|
+
ctx.logger.warn?.(`${logTag("inbound")} runtime.channel.reply not available; skipping`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (shouldIgnoreSelfSentMessage(state, msg))
|
|
504
|
+
return;
|
|
505
|
+
if (!shouldProcessInboundMessage(state.config.accountId, msg))
|
|
506
|
+
return;
|
|
507
|
+
const inbound = extractInboundBody(msg);
|
|
508
|
+
if (!inbound.body && (!inbound.media || inbound.media.length === 0)) {
|
|
509
|
+
ctx.logger.info?.(`${logTag("inbound")} skip unsupported msg: contentType=${msg.contentType} clientMsgID=${msg.clientMsgID || "unknown"}`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const group = isGroupMessage(msg);
|
|
513
|
+
const mentioned = group && isMentionedInGroup(msg, state.config.userID);
|
|
514
|
+
const hasWhitelist = state.config.inboundWhitelist.length > 0;
|
|
515
|
+
if (hasWhitelist) {
|
|
516
|
+
if (!isWhitelistedSender(state, msg))
|
|
517
|
+
return;
|
|
518
|
+
if (group && !mentioned)
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
else if (group && state.config.requireMention && !mentioned) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const baseSessionKey = group
|
|
525
|
+
? `${CHANNEL_ID}:group:${msg.groupID}`.toLowerCase()
|
|
526
|
+
: `${CHANNEL_ID}:${msg.sendID}:${state.config.accountId}`.toLowerCase();
|
|
527
|
+
const cfg = api.config;
|
|
528
|
+
const routing = runtime.channel.routing;
|
|
529
|
+
const route = routing?.resolveAgentRoute?.({
|
|
530
|
+
cfg,
|
|
531
|
+
sessionKey: baseSessionKey,
|
|
532
|
+
channel: CHANNEL_ID,
|
|
533
|
+
accountId: state.config.accountId,
|
|
534
|
+
}) ?? { agentId: "main", sessionKey: baseSessionKey };
|
|
535
|
+
const sessionKey = group
|
|
536
|
+
? String(route?.sessionKey ?? baseSessionKey).trim() || baseSessionKey
|
|
537
|
+
: baseSessionKey;
|
|
538
|
+
const sessionRt = runtime.channel.session;
|
|
539
|
+
const sessionCfg = cfg?.session?.store;
|
|
540
|
+
const storePath = sessionRt?.resolveStorePath?.(sessionCfg, { agentId: route.agentId ?? "main" }) ?? "";
|
|
541
|
+
const chatType = group ? "group" : "direct";
|
|
542
|
+
const fromLabel = String(msg.senderNickname || msg.sendID);
|
|
543
|
+
const senderId = String(msg.sendID);
|
|
544
|
+
const timestamp = Number(msg.sendTime) || Date.now();
|
|
545
|
+
const mediaResult = await materializeInboundMedia(inbound.media);
|
|
546
|
+
const warningText = mediaResult.warnings.map((w) => `[Media fetch failed] ${w}`).join("\n");
|
|
547
|
+
const rawBody = warningText ? `${inbound.body}\n${warningText}` : inbound.body;
|
|
548
|
+
const body = buildTextEnvelope(runtime, cfg, fromLabel, senderId, timestamp, rawBody, chatType);
|
|
549
|
+
for (const w of mediaResult.warnings) {
|
|
550
|
+
ctx.logger.warn?.(`${logTag("inbound")} media fetch failed: ${w}`);
|
|
551
|
+
}
|
|
552
|
+
const ctxPayload = {
|
|
553
|
+
Body: body,
|
|
554
|
+
RawBody: rawBody,
|
|
555
|
+
From: group ? `${CHANNEL_ID}:group:${msg.groupID}` : `${CHANNEL_ID}:${msg.sendID}`,
|
|
556
|
+
To: `${CHANNEL_ID}:${state.config.userID}`,
|
|
557
|
+
SessionKey: sessionKey,
|
|
558
|
+
AccountId: state.config.accountId,
|
|
559
|
+
ChatType: chatType,
|
|
560
|
+
ConversationLabel: fromLabel,
|
|
561
|
+
SenderName: fromLabel,
|
|
562
|
+
SenderId: senderId,
|
|
563
|
+
Provider: CHANNEL_ID,
|
|
564
|
+
Surface: CHANNEL_ID,
|
|
565
|
+
MessageSid: msg.clientMsgID || `${CHANNEL_ID}-${Date.now()}`,
|
|
566
|
+
Timestamp: timestamp,
|
|
567
|
+
OriginatingChannel: CHANNEL_ID,
|
|
568
|
+
OriginatingTo: `${CHANNEL_ID}:${state.config.userID}`,
|
|
569
|
+
CommandAuthorized: true,
|
|
570
|
+
_multiOpenim: {
|
|
571
|
+
accountId: state.config.accountId,
|
|
572
|
+
isGroup: group,
|
|
573
|
+
senderId,
|
|
574
|
+
groupId: String(msg.groupID || ""),
|
|
575
|
+
messageKind: inbound.kind,
|
|
576
|
+
mediaCount: inbound.media?.length ?? 0,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
if (sessionRt?.recordInboundSession) {
|
|
580
|
+
await sessionRt.recordInboundSession({
|
|
581
|
+
storePath,
|
|
582
|
+
sessionKey,
|
|
583
|
+
ctx: ctxPayload,
|
|
584
|
+
updateLastRoute: !group
|
|
585
|
+
? {
|
|
586
|
+
sessionKey,
|
|
587
|
+
channel: CHANNEL_ID,
|
|
588
|
+
to: String(msg.sendID),
|
|
589
|
+
accountId: state.config.accountId,
|
|
590
|
+
}
|
|
591
|
+
: undefined,
|
|
592
|
+
onRecordError: (err) => ctx.logger.warn?.(`${logTag("inbound")} recordInboundSession: ${String(err)}`),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
runtime.channel.activity?.record?.({
|
|
596
|
+
channel: CHANNEL_ID,
|
|
597
|
+
accountId: state.config.accountId,
|
|
598
|
+
direction: "inbound",
|
|
599
|
+
});
|
|
600
|
+
try {
|
|
601
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
602
|
+
ctx: ctxPayload,
|
|
603
|
+
cfg,
|
|
604
|
+
dispatcherOptions: {
|
|
605
|
+
deliver: async (payload) => {
|
|
606
|
+
if (!payload.text)
|
|
607
|
+
return;
|
|
608
|
+
try {
|
|
609
|
+
await sendReplyFromInbound(state, msg, payload.text);
|
|
610
|
+
}
|
|
611
|
+
catch (e) {
|
|
612
|
+
ctx.logger.error?.(`${logTag("inbound")} deliver failed: ${formatSdkError(e)}`);
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
onError: (err, info) => {
|
|
616
|
+
ctx.logger.error?.(`${logTag("inbound")} ${info?.kind || "reply"} failed: ${String(err)}`);
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
replyOptions: {
|
|
620
|
+
disableBlockStreaming: true,
|
|
621
|
+
images: mediaResult.images,
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
ctx.logger.error?.(`${logTag("inbound")} dispatch failed: ${formatSdkError(err)}`);
|
|
627
|
+
try {
|
|
628
|
+
await sendReplyFromInbound(state, msg, `Processing failed: ${formatSdkError(err).slice(0, 80)}`);
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// ignore secondary send failures
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
export function _internal_clearInboundDedup() {
|
|
636
|
+
inboundDedupByAccount.clear();
|
|
637
|
+
inflightByAccount.clear();
|
|
638
|
+
dropAggByAccount.clear();
|
|
639
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* multi-openim-channel — OpenClaw plugin entry point.
|
|
3
|
+
*
|
|
4
|
+
* Wires up: SDK polyfills → register the channel descriptor → register
|
|
5
|
+
* lifecycle handlers on the channel object so OpenClaw's health-monitor
|
|
6
|
+
* can drive us → register MCP tools → register the setup CLI → register
|
|
7
|
+
* the long-running SDK service.
|
|
8
|
+
*/
|
|
9
|
+
import "./polyfills.js";
|
|
10
|
+
import type { PluginApi } from "./types.js";
|
|
11
|
+
export default function register(api: PluginApi): void;
|