@zeyiy/openclaw-channel 0.3.4
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 +18 -0
- package/README.md +129 -0
- package/README.zh-CN.md +128 -0
- package/dist/channel.d.ts +51 -0
- package/dist/channel.js +74 -0
- package/dist/clients.d.ts +6 -0
- package/dist/clients.js +103 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +168 -0
- package/dist/inbound.d.ts +3 -0
- package/dist/inbound.js +461 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +52 -0
- package/dist/media.d.ts +12 -0
- package/dist/media.js +206 -0
- package/dist/polyfills.d.ts +9 -0
- package/dist/polyfills.js +22 -0
- package/dist/portal.d.ts +11 -0
- package/dist/portal.js +531 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +67 -0
- package/dist/targets.d.ts +6 -0
- package/dist/targets.js +21 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +131 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +31 -0
- package/openclaw.plugin.json +116 -0
- package/package.json +74 -0
package/dist/inbound.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { SessionType } from "@openim/client-sdk";
|
|
2
|
+
import { appendFileSync } from "node:fs";
|
|
3
|
+
import { sendTextToTarget } from "./media";
|
|
4
|
+
import { formatSdkError } from "./utils";
|
|
5
|
+
/** 写入 /tmp/openim-debug.log,用于排查消息路由问题 */
|
|
6
|
+
function debugLog(msg) {
|
|
7
|
+
const ts = new Date().toISOString();
|
|
8
|
+
try {
|
|
9
|
+
appendFileSync("/tmp/openim-debug.log", `${ts} ${msg}\n`);
|
|
10
|
+
}
|
|
11
|
+
catch { }
|
|
12
|
+
}
|
|
13
|
+
const inboundDedup = new Map();
|
|
14
|
+
const INBOUND_DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
15
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
16
|
+
const IMAGE_FETCH_TIMEOUT_MS = 15000;
|
|
17
|
+
function normalizeImageMimeType(value) {
|
|
18
|
+
const mime = String(value ?? "").trim().toLowerCase();
|
|
19
|
+
return mime.startsWith("image/") ? mime : undefined;
|
|
20
|
+
}
|
|
21
|
+
function normalizeMimeType(value) {
|
|
22
|
+
const mime = String(value ?? "").trim().toLowerCase();
|
|
23
|
+
return mime.includes("/") ? mime : undefined;
|
|
24
|
+
}
|
|
25
|
+
function normalizeString(value) {
|
|
26
|
+
const text = String(value ?? "").trim();
|
|
27
|
+
return text || undefined;
|
|
28
|
+
}
|
|
29
|
+
function normalizeSize(value) {
|
|
30
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
31
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
32
|
+
}
|
|
33
|
+
function summarizeMedia(item) {
|
|
34
|
+
if (item.kind === "image") {
|
|
35
|
+
return item.url ? `[Image] ${item.url}` : "[Image message]";
|
|
36
|
+
}
|
|
37
|
+
if (item.kind === "video") {
|
|
38
|
+
const parts = ["[Video]"];
|
|
39
|
+
if (item.fileName)
|
|
40
|
+
parts.push(`name=${item.fileName}`);
|
|
41
|
+
if (item.url)
|
|
42
|
+
parts.push(`video=${item.url}`);
|
|
43
|
+
if (item.snapshotUrl)
|
|
44
|
+
parts.push(`snapshot=${item.snapshotUrl}`);
|
|
45
|
+
if (item.size)
|
|
46
|
+
parts.push(`size=${item.size}`);
|
|
47
|
+
return parts.join(" ");
|
|
48
|
+
}
|
|
49
|
+
const parts = ["[File]"];
|
|
50
|
+
if (item.fileName)
|
|
51
|
+
parts.push(`name=${item.fileName}`);
|
|
52
|
+
if (item.mimeType)
|
|
53
|
+
parts.push(`type=${item.mimeType}`);
|
|
54
|
+
if (item.url)
|
|
55
|
+
parts.push(`url=${item.url}`);
|
|
56
|
+
if (item.size)
|
|
57
|
+
parts.push(`size=${item.size}`);
|
|
58
|
+
return parts.join(" ");
|
|
59
|
+
}
|
|
60
|
+
function mergeInboundResults(parts) {
|
|
61
|
+
const valid = parts.filter(Boolean);
|
|
62
|
+
if (valid.length === 0)
|
|
63
|
+
return { body: "", kind: "unknown" };
|
|
64
|
+
const bodies = valid.map((item) => item.body).filter(Boolean);
|
|
65
|
+
const media = valid.flatMap((item) => item.media ?? []);
|
|
66
|
+
if (valid.length === 1) {
|
|
67
|
+
return {
|
|
68
|
+
body: bodies[0] || "",
|
|
69
|
+
kind: valid[0].kind,
|
|
70
|
+
media: media.length > 0 ? media : undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
body: bodies.join("\n"),
|
|
75
|
+
kind: "mixed",
|
|
76
|
+
media: media.length > 0 ? media : undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function fetchImageAsContentPart(url, hintedMimeType) {
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetch(url, { signal: controller.signal });
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (controller.signal.aborted) {
|
|
88
|
+
throw new Error(`image fetch timeout after ${IMAGE_FETCH_TIMEOUT_MS}ms`);
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`image fetch failed: ${response.status} ${response.statusText}`);
|
|
97
|
+
}
|
|
98
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
99
|
+
if (contentLength > MAX_IMAGE_BYTES) {
|
|
100
|
+
throw new Error(`image too large: ${contentLength} bytes`);
|
|
101
|
+
}
|
|
102
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
103
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
104
|
+
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
|
105
|
+
throw new Error(`image too large: ${buffer.byteLength} bytes`);
|
|
106
|
+
}
|
|
107
|
+
const mimeType = normalizeImageMimeType(response.headers.get("content-type")) ?? normalizeImageMimeType(hintedMimeType) ?? "image/jpeg";
|
|
108
|
+
return {
|
|
109
|
+
type: "image",
|
|
110
|
+
data: buffer.toString("base64"),
|
|
111
|
+
mimeType,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function buildTextEnvelope(runtime, cfg, fromLabel, senderId, timestamp, bodyText, chatType) {
|
|
115
|
+
const envelopeOptions = runtime.channel.reply?.resolveEnvelopeFormatOptions?.(cfg) ?? {};
|
|
116
|
+
const formatted = runtime.channel.reply?.formatInboundEnvelope?.({
|
|
117
|
+
channel: "OpenIM",
|
|
118
|
+
from: fromLabel,
|
|
119
|
+
timestamp,
|
|
120
|
+
body: bodyText,
|
|
121
|
+
chatType,
|
|
122
|
+
sender: { name: fromLabel, id: senderId },
|
|
123
|
+
envelope: envelopeOptions,
|
|
124
|
+
});
|
|
125
|
+
return typeof formatted === "string" ? formatted : bodyText;
|
|
126
|
+
}
|
|
127
|
+
async function materializeInboundMedia(media) {
|
|
128
|
+
if (!Array.isArray(media) || media.length === 0) {
|
|
129
|
+
return { images: [], warnings: [] };
|
|
130
|
+
}
|
|
131
|
+
const images = [];
|
|
132
|
+
const warnings = [];
|
|
133
|
+
for (const item of media) {
|
|
134
|
+
try {
|
|
135
|
+
if (item.kind === "image" && item.url) {
|
|
136
|
+
images.push(await fetchImageAsContentPart(item.url, item.mimeType));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (item.kind === "video" && item.snapshotUrl) {
|
|
140
|
+
images.push(await fetchImageAsContentPart(item.snapshotUrl));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
warnings.push(`${summarizeMedia(item)} => ${formatSdkError(err)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { images, warnings };
|
|
149
|
+
}
|
|
150
|
+
function extractPictureMedia(msg) {
|
|
151
|
+
const pic = msg.pictureElem;
|
|
152
|
+
if (!pic)
|
|
153
|
+
return [];
|
|
154
|
+
const source = pic.sourcePicture;
|
|
155
|
+
const big = pic.bigPicture;
|
|
156
|
+
const snapshot = pic.snapshotPicture;
|
|
157
|
+
const url = normalizeString(source?.url) || normalizeString(big?.url) || normalizeString(snapshot?.url);
|
|
158
|
+
const mimeType = normalizeImageMimeType(source?.type) || normalizeImageMimeType(big?.type) || normalizeImageMimeType(snapshot?.type);
|
|
159
|
+
return [{ kind: "image", url, mimeType }];
|
|
160
|
+
}
|
|
161
|
+
function extractVideoMedia(msg) {
|
|
162
|
+
const video = msg.videoElem;
|
|
163
|
+
if (!video)
|
|
164
|
+
return [];
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
kind: "video",
|
|
168
|
+
url: normalizeString(video.videoUrl),
|
|
169
|
+
snapshotUrl: normalizeString(video.snapshotUrl),
|
|
170
|
+
fileName: normalizeString(video.videoName ?? video.fileName ?? video.snapshotName),
|
|
171
|
+
size: normalizeSize(video.videoSize ?? video.duration),
|
|
172
|
+
mimeType: normalizeMimeType(video.videoType ?? video.type),
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
function extractFileMedia(msg) {
|
|
177
|
+
const file = msg.fileElem;
|
|
178
|
+
if (!file)
|
|
179
|
+
return [];
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
kind: "file",
|
|
183
|
+
url: normalizeString(file.sourceUrl),
|
|
184
|
+
fileName: normalizeString(file.fileName),
|
|
185
|
+
size: normalizeSize(file.fileSize),
|
|
186
|
+
mimeType: normalizeMimeType(file.fileType ?? file.type),
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
function extractInboundBody(msg, depth = 0) {
|
|
191
|
+
const text = String(msg.textElem?.content ?? msg.atTextElem?.text ?? "").trim();
|
|
192
|
+
const imageMedia = extractPictureMedia(msg);
|
|
193
|
+
const videoMedia = extractVideoMedia(msg);
|
|
194
|
+
const fileMedia = extractFileMedia(msg);
|
|
195
|
+
if (msg.quoteElem?.quoteMessage) {
|
|
196
|
+
const quotedMsg = msg.quoteElem.quoteMessage;
|
|
197
|
+
const quotedSender = String(quotedMsg.senderNickname || quotedMsg.sendID || "unknown");
|
|
198
|
+
const quoted = depth < 2 ? extractInboundBody(quotedMsg, depth + 1) : { body: "[quoted message]", kind: "mixed" };
|
|
199
|
+
const currentParts = [];
|
|
200
|
+
if (text)
|
|
201
|
+
currentParts.push(`Reply: ${text}`);
|
|
202
|
+
for (const item of [...imageMedia, ...videoMedia, ...fileMedia]) {
|
|
203
|
+
currentParts.push(`Reply attachment: ${summarizeMedia(item)}`);
|
|
204
|
+
}
|
|
205
|
+
const bodyLines = [`[Quote] ${quotedSender}: ${quoted.body || "[empty message]"}`];
|
|
206
|
+
if (currentParts.length > 0)
|
|
207
|
+
bodyLines.push(currentParts.join("\n"));
|
|
208
|
+
return {
|
|
209
|
+
body: bodyLines.join("\n"),
|
|
210
|
+
kind: currentParts.length > 0 ? "mixed" : quoted.kind,
|
|
211
|
+
media: [...imageMedia, ...videoMedia, ...fileMedia],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const parts = [];
|
|
215
|
+
if (text)
|
|
216
|
+
parts.push({ body: text, kind: "text" });
|
|
217
|
+
for (const item of imageMedia) {
|
|
218
|
+
parts.push({ body: summarizeMedia(item), kind: "image", media: [item] });
|
|
219
|
+
}
|
|
220
|
+
for (const item of videoMedia) {
|
|
221
|
+
parts.push({ body: summarizeMedia(item), kind: "video", media: [item] });
|
|
222
|
+
}
|
|
223
|
+
for (const item of fileMedia) {
|
|
224
|
+
parts.push({ body: summarizeMedia(item), kind: "file", media: [item] });
|
|
225
|
+
}
|
|
226
|
+
if (msg.customElem?.data || msg.customElem?.description || msg.customElem?.extension) {
|
|
227
|
+
const customText = msg.customElem.description || msg.customElem.data || msg.customElem.extension || "[Custom message]";
|
|
228
|
+
parts.push({ body: `[Custom message] ${customText}`, kind: "mixed" });
|
|
229
|
+
}
|
|
230
|
+
return mergeInboundResults(parts);
|
|
231
|
+
}
|
|
232
|
+
function shouldProcessInboundMessage(accountId, msg) {
|
|
233
|
+
const idPart = String(msg.clientMsgID || msg.serverMsgID || `${msg.sendID}-${msg.seq || msg.createTime || 0}`);
|
|
234
|
+
if (!idPart)
|
|
235
|
+
return true;
|
|
236
|
+
const key = `${accountId}:${idPart}`;
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const last = inboundDedup.get(key);
|
|
239
|
+
inboundDedup.set(key, now);
|
|
240
|
+
if (inboundDedup.size > 2000) {
|
|
241
|
+
for (const [k, ts] of inboundDedup.entries()) {
|
|
242
|
+
if (now - ts > INBOUND_DEDUP_TTL_MS)
|
|
243
|
+
inboundDedup.delete(k);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return !(last && now - last < INBOUND_DEDUP_TTL_MS);
|
|
247
|
+
}
|
|
248
|
+
function isGroupMessage(msg) {
|
|
249
|
+
return msg.sessionType === SessionType.Group && !!msg.groupID;
|
|
250
|
+
}
|
|
251
|
+
function isMentionedInGroup(msg, selfUserID) {
|
|
252
|
+
// Use SDK-provided isAtSelf flag (most reliable, computed server-side)
|
|
253
|
+
if (msg.atTextElem?.isAtSelf === true)
|
|
254
|
+
return true;
|
|
255
|
+
// Fallback: check atUserList manually
|
|
256
|
+
const list = msg.atTextElem?.atUserList;
|
|
257
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
258
|
+
return false;
|
|
259
|
+
const id = String(selfUserID);
|
|
260
|
+
return list.some((item) => String(item) === id);
|
|
261
|
+
}
|
|
262
|
+
function isWhitelistedSender(client, msg) {
|
|
263
|
+
const whitelist = client.config.inboundWhitelist;
|
|
264
|
+
if (!Array.isArray(whitelist) || whitelist.length === 0)
|
|
265
|
+
return true;
|
|
266
|
+
const senderId = String(msg.sendID || "").trim();
|
|
267
|
+
if (!senderId)
|
|
268
|
+
return false;
|
|
269
|
+
return whitelist.some((id) => id === senderId);
|
|
270
|
+
}
|
|
271
|
+
function shouldIgnoreSelfSentMessage(client, msg) {
|
|
272
|
+
const selfUserID = String(client.config.userID);
|
|
273
|
+
if (String(msg.sendID) !== selfUserID)
|
|
274
|
+
return false;
|
|
275
|
+
// Allow direct self-chat messages only when they come from another platform.
|
|
276
|
+
const isDirectSelfChat = msg.sessionType !== SessionType.Group && String(msg.recvID) === selfUserID;
|
|
277
|
+
if (!isDirectSelfChat)
|
|
278
|
+
return true;
|
|
279
|
+
const localPlatformID = Number(client.config.platformID);
|
|
280
|
+
const senderPlatformID = Number(msg.senderPlatformID);
|
|
281
|
+
if (!Number.isFinite(localPlatformID) || !Number.isFinite(senderPlatformID))
|
|
282
|
+
return true;
|
|
283
|
+
return localPlatformID === senderPlatformID;
|
|
284
|
+
}
|
|
285
|
+
async function sendReplyFromInbound(client, msg, text) {
|
|
286
|
+
const isGroup = isGroupMessage(msg);
|
|
287
|
+
const target = isGroup ? { kind: "group", id: String(msg.groupID) } : { kind: "user", id: String(msg.sendID) };
|
|
288
|
+
await sendTextToTarget(client, target, text);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* 标记私聊会话为已读。
|
|
292
|
+
* conversationID 格式:si_{自己的userID}_{对方的userID}
|
|
293
|
+
*/
|
|
294
|
+
async function markDirectMessageAsRead(client, msg) {
|
|
295
|
+
const selfID = client.config.userID;
|
|
296
|
+
const peerID = String(msg.sendID);
|
|
297
|
+
const conversationID = `si_${selfID}_${peerID}`;
|
|
298
|
+
try {
|
|
299
|
+
await client.sdk.markConversationMessageAsRead(conversationID);
|
|
300
|
+
debugLog(`[已读] 私聊已标记已读 conversation=${conversationID}`);
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
debugLog(`[已读] 标记已读失败 conversation=${conversationID} error=${e}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export async function processInboundMessage(api, client, msg) {
|
|
307
|
+
const runtime = api.runtime;
|
|
308
|
+
if (!runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
309
|
+
api.logger?.warn?.("[openim] runtime.channel.reply not available");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (shouldIgnoreSelfSentMessage(client, msg)) {
|
|
313
|
+
api.logger?.debug?.(`[openim] ignore self-sent message: clientMsgID=${msg.clientMsgID || "unknown"}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!shouldProcessInboundMessage(client.config.accountId, msg)) {
|
|
317
|
+
api.logger?.debug?.(`[openim] ignore duplicate message: clientMsgID=${msg.clientMsgID || "unknown"}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const group = isGroupMessage(msg);
|
|
321
|
+
api.logger?.debug?.(`[openim] inbound message: sessionType=${msg.sessionType}, contentType=${msg.contentType}, group=${group}, groupID=${msg.groupID || ""}, sendID=${msg.sendID}, clientMsgID=${msg.clientMsgID || "unknown"}`);
|
|
322
|
+
// 私聊消息:标记为已读,让对方看到已读回执
|
|
323
|
+
if (!group) {
|
|
324
|
+
markDirectMessageAsRead(client, msg).catch(() => { });
|
|
325
|
+
}
|
|
326
|
+
const inbound = extractInboundBody(msg);
|
|
327
|
+
if (!inbound.body) {
|
|
328
|
+
api.logger?.info?.(`[openim] ignore unsupported message: contentType=${msg.contentType}, clientMsgID=${msg.clientMsgID || "unknown"}`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const mentioned = group && isMentionedInGroup(msg, client.config.userID);
|
|
332
|
+
const hasWhitelist = client.config.inboundWhitelist.length > 0;
|
|
333
|
+
if (hasWhitelist) {
|
|
334
|
+
if (!isWhitelistedSender(client, msg)) {
|
|
335
|
+
api.logger?.debug?.(`[openim] ignore message: sender ${msg.sendID} not in whitelist`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (group && !mentioned) {
|
|
339
|
+
api.logger?.debug?.(`[openim] ignore group message: bot not mentioned (whitelist mode), groupID=${msg.groupID}`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else if (group && client.config.requireMention && !mentioned) {
|
|
344
|
+
api.logger?.debug?.(`[openim] ignore group message: requireMention=true but bot not mentioned, groupID=${msg.groupID}`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// 会话隔离:群聊按 groupID 分 session,私聊按发送者 ID 分 session
|
|
348
|
+
const baseSessionKey = group ? `openim:group:${msg.groupID}`.toLowerCase() : `openim:dm:${msg.sendID}`.toLowerCase();
|
|
349
|
+
const cfg = api.config;
|
|
350
|
+
const route = runtime.channel.routing?.resolveAgentRoute?.({
|
|
351
|
+
cfg,
|
|
352
|
+
sessionKey: baseSessionKey,
|
|
353
|
+
channel: "openim",
|
|
354
|
+
accountId: client.config.accountId,
|
|
355
|
+
}) ?? { agentId: "main", sessionKey: baseSessionKey };
|
|
356
|
+
// 将来源信息附加到路由 key 上,防止不同群/不同用户的会话被合并
|
|
357
|
+
const routeSessionKey = String(route?.sessionKey ?? "").trim();
|
|
358
|
+
const sessionKey = routeSessionKey ? `${routeSessionKey}:${baseSessionKey}` : baseSessionKey;
|
|
359
|
+
debugLog(`[route] ${group ? "群聊" : "私聊"} from=${msg.sendID} group=${msg.groupID || "-"} session=${sessionKey} agent=${route.agentId}`);
|
|
360
|
+
const storePath = runtime.channel.session?.resolveStorePath?.(cfg?.session?.store, {
|
|
361
|
+
agentId: route.agentId,
|
|
362
|
+
}) ?? "";
|
|
363
|
+
const chatType = group ? "group" : "direct";
|
|
364
|
+
const fromLabel = String(msg.senderNickname || msg.sendID);
|
|
365
|
+
const senderId = String(msg.sendID);
|
|
366
|
+
const timestamp = msg.sendTime || Date.now();
|
|
367
|
+
const mediaResult = await materializeInboundMedia(inbound.media);
|
|
368
|
+
const warningText = mediaResult.warnings.map((warning) => `[Media fetch failed] ${warning}`).join("\n");
|
|
369
|
+
const rawBody = warningText ? `${inbound.body}\n${warningText}` : inbound.body;
|
|
370
|
+
const body = buildTextEnvelope(runtime, cfg, fromLabel, senderId, timestamp, rawBody, chatType);
|
|
371
|
+
if (mediaResult.warnings.length > 0) {
|
|
372
|
+
for (const warning of mediaResult.warnings) {
|
|
373
|
+
api.logger?.warn?.(`[openim] inbound media fetch failed: ${warning}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const ctxPayload = {
|
|
377
|
+
Body: body,
|
|
378
|
+
RawBody: rawBody,
|
|
379
|
+
From: group ? `openim:group:${msg.groupID}` : `openim:${msg.sendID}`,
|
|
380
|
+
To: `openim:${client.config.userID}`,
|
|
381
|
+
SessionKey: sessionKey,
|
|
382
|
+
AccountId: client.config.accountId,
|
|
383
|
+
ChatType: chatType,
|
|
384
|
+
ConversationLabel: group ? `openim:g-${msg.groupID}` : `openim:${senderId}`, // 会话标签:群聊用群ID,私聊用用户ID
|
|
385
|
+
SenderName: fromLabel,
|
|
386
|
+
SenderId: senderId,
|
|
387
|
+
Provider: "openim",
|
|
388
|
+
Surface: "openim",
|
|
389
|
+
MessageSid: msg.clientMsgID || `openim-${Date.now()}`,
|
|
390
|
+
Timestamp: timestamp,
|
|
391
|
+
OriginatingChannel: "openim",
|
|
392
|
+
OriginatingTo: `openim:${client.config.userID}`,
|
|
393
|
+
CommandAuthorized: true,
|
|
394
|
+
_openim: {
|
|
395
|
+
accountId: client.config.accountId,
|
|
396
|
+
isGroup: group,
|
|
397
|
+
senderId,
|
|
398
|
+
groupId: String(msg.groupID || ""),
|
|
399
|
+
messageKind: inbound.kind,
|
|
400
|
+
mediaCount: inbound.media?.length ?? 0,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
if (runtime.channel.session?.recordInboundSession) {
|
|
404
|
+
await runtime.channel.session.recordInboundSession({
|
|
405
|
+
storePath,
|
|
406
|
+
sessionKey,
|
|
407
|
+
ctx: ctxPayload,
|
|
408
|
+
updateLastRoute: !group
|
|
409
|
+
? {
|
|
410
|
+
sessionKey,
|
|
411
|
+
channel: "openim",
|
|
412
|
+
to: String(msg.sendID),
|
|
413
|
+
accountId: client.config.accountId,
|
|
414
|
+
}
|
|
415
|
+
: undefined,
|
|
416
|
+
onRecordError: (err) => api.logger?.warn?.(`[openim] recordInboundSession: ${String(err)}`),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (runtime.channel.activity?.record) {
|
|
420
|
+
runtime.channel.activity.record({
|
|
421
|
+
channel: "openim",
|
|
422
|
+
accountId: client.config.accountId,
|
|
423
|
+
direction: "inbound",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
428
|
+
ctx: ctxPayload,
|
|
429
|
+
cfg,
|
|
430
|
+
dispatcherOptions: {
|
|
431
|
+
deliver: async (payload) => {
|
|
432
|
+
if (!payload.text)
|
|
433
|
+
return;
|
|
434
|
+
try {
|
|
435
|
+
await sendReplyFromInbound(client, msg, payload.text);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
api.logger?.error?.(`[openim] deliver failed: ${formatSdkError(e)}`);
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
onError: (err, info) => {
|
|
442
|
+
api.logger?.error?.(`[openim] ${info?.kind || "reply"} failed: ${String(err)}`);
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
replyOptions: {
|
|
446
|
+
disableBlockStreaming: true,
|
|
447
|
+
images: mediaResult.images,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
api.logger?.error?.(`[openim] dispatch failed: ${formatSdkError(err)}`);
|
|
453
|
+
try {
|
|
454
|
+
const errMsg = formatSdkError(err);
|
|
455
|
+
await sendReplyFromInbound(client, msg, `Processing failed: ${errMsg.slice(0, 80)}`);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// ignore secondary send errors
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw OpenIM Channel Plugin
|
|
3
|
+
*
|
|
4
|
+
* Integrates OpenIM into OpenClaw Gateway using @openim/client-sdk.
|
|
5
|
+
* Supports multi-account concurrency, direct/group text messaging, and mention-gated group triggering.
|
|
6
|
+
*/
|
|
7
|
+
import "./polyfills";
|
|
8
|
+
export default function register(api: any): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw OpenIM Channel Plugin
|
|
3
|
+
*
|
|
4
|
+
* Integrates OpenIM into OpenClaw Gateway using @openim/client-sdk.
|
|
5
|
+
* Supports multi-account concurrency, direct/group text messaging, and mention-gated group triggering.
|
|
6
|
+
*/
|
|
7
|
+
import "./polyfills";
|
|
8
|
+
import { OpenIMChannelPlugin } from "./channel";
|
|
9
|
+
import { connectedClientCount, startAccountClient, stopAllClients } from "./clients";
|
|
10
|
+
import { listEnabledAccountConfigs } from "./config";
|
|
11
|
+
import { registerOpenIMTools } from "./tools";
|
|
12
|
+
export default function register(api) {
|
|
13
|
+
globalThis.__openimApi = api;
|
|
14
|
+
globalThis.__openimGatewayConfig = api.config;
|
|
15
|
+
api.registerChannel({ plugin: OpenIMChannelPlugin });
|
|
16
|
+
if (typeof api.registerCli === "function") {
|
|
17
|
+
api.registerCli((ctx) => {
|
|
18
|
+
const prog = ctx.program;
|
|
19
|
+
if (prog && typeof prog.command === "function") {
|
|
20
|
+
const openim = prog.command("openim").description("OpenIM channel configuration");
|
|
21
|
+
openim.command("setup").description("Interactive setup for the OpenIM default account").action(async () => {
|
|
22
|
+
const { runOpenIMSetup } = await import("./setup");
|
|
23
|
+
await runOpenIMSetup();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}, { commands: ["openim"] });
|
|
27
|
+
}
|
|
28
|
+
registerOpenIMTools(api);
|
|
29
|
+
api.registerService({
|
|
30
|
+
id: "openim-sdk",
|
|
31
|
+
start: async () => {
|
|
32
|
+
if (connectedClientCount() > 0) {
|
|
33
|
+
api.logger?.info?.("[openim] service already started");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const accounts = listEnabledAccountConfigs(api);
|
|
37
|
+
if (accounts.length === 0) {
|
|
38
|
+
api.logger?.warn?.("[openim] no enabled account config found");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const account of accounts) {
|
|
42
|
+
await startAccountClient(api, account);
|
|
43
|
+
}
|
|
44
|
+
api.logger?.info?.(`[openim] service started with ${connectedClientCount()}/${accounts.length} connected accounts`);
|
|
45
|
+
},
|
|
46
|
+
stop: async () => {
|
|
47
|
+
await stopAllClients(api);
|
|
48
|
+
api.logger?.info?.("[openim] service stopped");
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
api.logger?.info?.("[openim] plugin loaded");
|
|
52
|
+
}
|
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OpenIMClientState, ParsedTarget } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* 发送文本消息到指定目标(用户或群组)。
|
|
4
|
+
*
|
|
5
|
+
* 群聊场景下,会自动识别文本中的 @提及(支持 <@ID> 和 @ID 两种格式),
|
|
6
|
+
* 将其转换为 OpenIM 的 at-text 消息(contentType=106),使接收方能正确
|
|
7
|
+
* 识别 @提及并触发 requireMention 回复机制。
|
|
8
|
+
*/
|
|
9
|
+
export declare function sendTextToTarget(client: OpenIMClientState, target: ParsedTarget, text: string): Promise<void>;
|
|
10
|
+
export declare function sendImageToTarget(client: OpenIMClientState, target: ParsedTarget, image: string): Promise<void>;
|
|
11
|
+
export declare function sendVideoToTarget(client: OpenIMClientState, target: ParsedTarget, video: string, name?: string): Promise<void>;
|
|
12
|
+
export declare function sendFileToTarget(client: OpenIMClientState, target: ParsedTarget, filePathOrUrl: string, name?: string): Promise<void>;
|