claw_messenger 1.0.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +577 -0
  3. package/bin/auto-init.js +104 -0
  4. package/bin/cli.js +5 -0
  5. package/bin/diagnose-plugin.js +174 -0
  6. package/bin/dm-bridge.cjs +12 -0
  7. package/bin/install.js +452 -0
  8. package/bin/postinstall.js +23 -0
  9. package/bin/qr-crypto-node.js +186 -0
  10. package/bin/setup.js +262 -0
  11. package/dist/auto-register.d.ts +49 -0
  12. package/dist/auto-register.js +328 -0
  13. package/dist/bridge-runner.d.ts +1 -0
  14. package/dist/bridge-runner.js +107 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +164 -0
  17. package/dist/device-status.d.ts +30 -0
  18. package/dist/device-status.js +109 -0
  19. package/dist/env-polyfill.d.ts +3 -0
  20. package/dist/env-polyfill.js +166 -0
  21. package/dist/group-config-manager.d.ts +22 -0
  22. package/dist/group-config-manager.js +130 -0
  23. package/dist/index.d.ts +17 -0
  24. package/dist/index.js +36 -0
  25. package/dist/logger.d.ts +14 -0
  26. package/dist/logger.js +103 -0
  27. package/dist/mac-address.d.ts +1 -0
  28. package/dist/mac-address.js +46 -0
  29. package/dist/openclaw-client.d.ts +41 -0
  30. package/dist/openclaw-client.js +530 -0
  31. package/dist/openclaw-config.d.ts +41 -0
  32. package/dist/openclaw-config.js +359 -0
  33. package/dist/openclaw.plugin.json +40 -0
  34. package/dist/package.json +112 -0
  35. package/dist/plugin-entry.d.ts +54 -0
  36. package/dist/plugin-entry.js +772 -0
  37. package/dist/postinstall.js +23 -0
  38. package/dist/rongcloud-client.d.ts +16 -0
  39. package/dist/rongcloud-client.js +274 -0
  40. package/dist/rongcloud-server-api.d.ts +53 -0
  41. package/dist/rongcloud-server-api.js +221 -0
  42. package/dist/utils.d.ts +9 -0
  43. package/dist/utils.js +97 -0
  44. package/openclaw.plugin.json +40 -0
  45. package/package.json +112 -0
@@ -0,0 +1,772 @@
1
+ /**
2
+ * OpenClaw Plugin Entry Point
3
+ * RongCloud IM Channel Plugin with Group Conversation Round Limit
4
+ * Compatible with OpenClaw 2026.4.21+ channel plugin API
5
+ */
6
+ import { getOrRegisterToken, getAppKey, getAppSecret } from './auto-register.js';
7
+ import { OpenClawClient } from './openclaw-client.js';
8
+ import { RongCloudServerAPI } from './rongcloud-server-api.js';
9
+ import { createLogger } from './logger.js';
10
+ import { groupConfigManager } from './group-config-manager.js';
11
+ import { spawnOpenClaw } from './utils.js';
12
+ import axios from 'axios';
13
+ import { getDeviceRunningStatus, isDeviceStatusRequest, parseDeviceStatusRequest } from './device-status.js';
14
+ const log = createLogger('plugin-entry');
15
+ const SERVER_URL = process.env.DM_SERVER_URL || 'https://newsradar.dreamdt.cn/im';
16
+ // 网页标题缓存,避免同一轮对话重复抓取
17
+ const urlTitleCache = new Map();
18
+ async function fetchUrlTitle(url) {
19
+ const cached = urlTitleCache.get(url);
20
+ if (cached)
21
+ return cached;
22
+ try {
23
+ const response = await axios.get(url, {
24
+ timeout: 8000,
25
+ maxRedirects: 3,
26
+ headers: {
27
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
28
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
29
+ },
30
+ responseType: 'text',
31
+ });
32
+ const html = response.data;
33
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
34
+ let title = titleMatch ? titleMatch[1].trim() : url;
35
+ // 解码 HTML 实体
36
+ title = title.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
37
+ if (!title)
38
+ title = url;
39
+ urlTitleCache.set(url, title);
40
+ return title;
41
+ }
42
+ catch (err) {
43
+ log.warn(`[WebSearch] 获取网页标题失败: ${url}, ${err.message}`);
44
+ urlTitleCache.set(url, url);
45
+ return url;
46
+ }
47
+ }
48
+ // 延迟加载 RongCloudClient,避免在 `openclaw agent` CLI 模式下加载重型 SDK
49
+ let RongCloudClientCtor = null;
50
+ async function getRongCloudClient() {
51
+ if (!RongCloudClientCtor) {
52
+ const mod = await import('./rongcloud-client.js');
53
+ RongCloudClientCtor = mod.RongCloudClient;
54
+ }
55
+ return RongCloudClientCtor;
56
+ }
57
+ // 判断当前是否运行在 `openclaw agent` 短生命周期 CLI 中
58
+ function isAgentCLI() {
59
+ return process.argv.some(arg => arg === 'agent');
60
+ }
61
+ /**
62
+ * 等待 abortSignal 触发
63
+ */
64
+ function waitForAbort(abortSignal) {
65
+ return new Promise((resolve) => {
66
+ if (abortSignal.aborted) {
67
+ resolve();
68
+ return;
69
+ }
70
+ abortSignal.addEventListener('abort', () => resolve(), { once: true });
71
+ });
72
+ }
73
+ class RongCloudChannelImpl {
74
+ constructor() {
75
+ this.client = null;
76
+ this.accountId = null;
77
+ this.openclaw = null;
78
+ this.serverAPI = null;
79
+ this.appSecret = undefined;
80
+ this.streamQueue = Promise.resolve();
81
+ this.streamMessageUIDs = new Map();
82
+ }
83
+ /**
84
+ * 启动 channel account(OpenClaw 2026.4.21+ 新 API)
85
+ */
86
+ async startAccount(ctx) {
87
+ var _a, _b, _c, _d, _e, _f;
88
+ // agent CLI 模式下不连接融云,避免阻塞进程退出
89
+ if (isAgentCLI()) {
90
+ return;
91
+ }
92
+ const nodeName = (_c = (_b = (_a = ctx.cfg) === null || _a === void 0 ? void 0 : _a.channels) === null || _b === void 0 ? void 0 : _b.claw_messenger) === null || _c === void 0 ? void 0 : _c.nodeName;
93
+ const token = await getOrRegisterToken(nodeName);
94
+ if (!token) {
95
+ const errorMsg = '未获取到有效融云 token,虾说 IM 功能未启用';
96
+ log.error('[RongCloud] ' + errorMsg);
97
+ ctx.setStatus(Object.assign(Object.assign({}, ctx.getStatus()), { running: false, connected: false, lastError: errorMsg }));
98
+ return;
99
+ }
100
+ const appKey = await getAppKey(SERVER_URL);
101
+ this.appSecret = await getAppSecret(SERVER_URL, token, this.accountId || undefined);
102
+ if (!this.appSecret) {
103
+ log.warn('[RongCloud] 未获取到 AppSecret,流式消息将不可用,降级为文本回复');
104
+ }
105
+ else {
106
+ this.serverAPI = new RongCloudServerAPI(appKey, this.appSecret, {
107
+ info: (msg) => log.info(msg),
108
+ warn: (msg) => log.warn(msg),
109
+ error: (msg) => log.error(msg),
110
+ });
111
+ }
112
+ this.openclaw = new OpenClawClient(undefined, {
113
+ info: (msg) => log.info(msg),
114
+ warn: (msg) => log.warn(msg),
115
+ error: (msg) => log.error(msg),
116
+ });
117
+ const RongCloudClientClass = await getRongCloudClient();
118
+ this.client = new RongCloudClientClass({
119
+ appKey,
120
+ token,
121
+ onMessage: async (msg) => {
122
+ await this.handleMessage(msg, ctx);
123
+ },
124
+ onConnectSuccess: (userId) => {
125
+ var _a;
126
+ this.accountId = userId;
127
+ ctx.setStatus(Object.assign(Object.assign({}, ctx.getStatus()), { running: true, connected: true, lastConnectedAt: Date.now(), lastError: null }));
128
+ if ((_a = ctx.log) === null || _a === void 0 ? void 0 : _a.info) {
129
+ ctx.log.info(`[RongCloud] 已连接,节点 userId: ${userId}`);
130
+ }
131
+ },
132
+ onError: (code) => {
133
+ const errorMsg = `[RongCloud] 连接失败,code: ${code}`;
134
+ log.error(errorMsg);
135
+ ctx.setStatus(Object.assign(Object.assign({}, ctx.getStatus()), { connected: false, lastError: errorMsg }));
136
+ },
137
+ });
138
+ if ((_d = ctx.log) === null || _d === void 0 ? void 0 : _d.info) {
139
+ ctx.log.info(`[RongCloud] 启动融云连接...`);
140
+ }
141
+ this.client.connect().catch((err) => {
142
+ log.error('[RongCloud] 后台连接失败:', (err === null || err === void 0 ? void 0 : err.message) || err);
143
+ ctx.setStatus(Object.assign(Object.assign({}, ctx.getStatus()), { connected: false, lastError: String((err === null || err === void 0 ? void 0 : err.message) || err) }));
144
+ });
145
+ // 保持长期运行,直到 abortSignal 触发
146
+ await waitForAbort(ctx.abortSignal);
147
+ // 清理
148
+ if ((_e = ctx.log) === null || _e === void 0 ? void 0 : _e.info) {
149
+ ctx.log.info('[RongCloud] 断开融云连接...');
150
+ }
151
+ if ((_f = this.client) === null || _f === void 0 ? void 0 : _f.disconnect) {
152
+ await this.client.disconnect();
153
+ }
154
+ this.accountId = null;
155
+ }
156
+ async sendMessage(to, content, metadata) {
157
+ if (!this.client) {
158
+ throw new Error('RongCloud client not initialized');
159
+ }
160
+ const conversationType = (metadata === null || metadata === void 0 ? void 0 : metadata.conversationType) || 1;
161
+ await this.client.sendMessage(to, content, conversationType);
162
+ }
163
+ async sendMediaMessage(targetId, media, conversationType) {
164
+ if (!this.serverAPI || !this.accountId) {
165
+ log.warn('[RongCloud] 未配置融云服务端密钥,媒体消息降级为文本提示');
166
+ await this.sendMessage(targetId, '当前未配置融云服务端密钥,暂不支持发送媒体消息。', { conversationType });
167
+ return;
168
+ }
169
+ const { mediaUrl, mimeType = '', fileName = '', fileSize = 0 } = media;
170
+ const isImage = mimeType.startsWith('image/');
171
+ const objectName = isImage ? 'RC:ImgMsg' : 'RC:FileMsg';
172
+ const content = isImage
173
+ ? { content: '', imageUri: mediaUrl, extra: { name: fileName } }
174
+ : { name: fileName, fileUrl: mediaUrl, size: fileSize, fileType: mimeType };
175
+ if (conversationType === 3) {
176
+ await this.serverAPI.sendGroupMessage({
177
+ fromUserId: this.accountId,
178
+ toGroupId: targetId,
179
+ objectName,
180
+ content,
181
+ });
182
+ }
183
+ else {
184
+ await this.serverAPI.sendPrivateMessage({
185
+ fromUserId: this.accountId,
186
+ toUserId: targetId,
187
+ objectName,
188
+ content,
189
+ });
190
+ }
191
+ }
192
+ extractMentions(content) {
193
+ const mentions = [];
194
+ const regex = /@(claw_[a-zA-Z0-9]+)/g;
195
+ let match;
196
+ while ((match = regex.exec(content)) !== null) {
197
+ mentions.push(match[1]);
198
+ }
199
+ return mentions;
200
+ }
201
+ async handleMessage(msg, ctx) {
202
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
203
+ if (msg.isOffLineMessage) {
204
+ return;
205
+ }
206
+ if (!this.accountId || msg.senderUserId === this.accountId) {
207
+ return;
208
+ }
209
+ // 处理设备状态请求(P2P 远程管理)
210
+ if (isDeviceStatusRequest(msg)) {
211
+ await this._handleDeviceStatusRequest(msg);
212
+ return;
213
+ }
214
+ const fromUser = msg.senderUserId;
215
+ const targetId = msg.targetId;
216
+ const convType = msg.conversationType;
217
+ // 群聊消息统一做 @mention 过滤(无论 claw 还是 RC:TxtMsg)
218
+ if (convType === 3) {
219
+ let mentions = [];
220
+ if (((_b = (_a = msg.content) === null || _a === void 0 ? void 0 : _a.mentionedInfo) === null || _b === void 0 ? void 0 : _b.userIdList) && Array.isArray(msg.content.mentionedInfo.userIdList)) {
221
+ mentions = msg.content.mentionedInfo.userIdList;
222
+ if (mentions.length > 0) {
223
+ log.info(`[RongCloud] 融云 mentionedInfo: ${mentions.join(', ')},本节点: ${this.accountId}`);
224
+ }
225
+ }
226
+ // 兜底:从文本内容中正则匹配 @claw_xxx
227
+ if (mentions.length === 0) {
228
+ const text = typeof msg.content === 'string' ? msg.content : (((_c = msg.content) === null || _c === void 0 ? void 0 : _c.content) || '');
229
+ mentions = this.extractMentions(text);
230
+ }
231
+ if (mentions.length > 0 && !mentions.includes(this.accountId)) {
232
+ log.info(`[RongCloud] 群聊消息 @${mentions.join(', ')},非本节点(${this.accountId}),忽略`);
233
+ return;
234
+ }
235
+ if (mentions.length === 0) {
236
+ log.info(`[RongCloud] 群聊消息未 @ 本节点(${this.accountId}),忽略`);
237
+ return;
238
+ }
239
+ }
240
+ let content = '';
241
+ if (msg.messageType === 'RC:TxtMsg') {
242
+ content = ((_d = msg.content) === null || _d === void 0 ? void 0 : _d.content) || '';
243
+ }
244
+ else if (msg.messageType === 'RC:ImgMsg') {
245
+ content = `[图片] ${((_e = msg.content) === null || _e === void 0 ? void 0 : _e.imageUri) || ''}`;
246
+ }
247
+ else if (msg.messageType === 'RC:FileMsg') {
248
+ const fileName = ((_f = msg.content) === null || _f === void 0 ? void 0 : _f.name) || '';
249
+ const fileUrl = ((_g = msg.content) === null || _g === void 0 ? void 0 : _g.fileUrl) || '';
250
+ content = `[文件] ${fileName} ${fileUrl}`.trim();
251
+ }
252
+ else {
253
+ return;
254
+ }
255
+ // 发送已读回执(fire-and-forget,不阻塞消息处理)
256
+ if ((_h = this.client) === null || _h === void 0 ? void 0 : _h.sendReadReceipt) {
257
+ this.client.sendReadReceipt(msg).catch((err) => {
258
+ log.warn('[RongCloud] 发送已读回执失败:', (err === null || err === void 0 ? void 0 : err.message) || err);
259
+ });
260
+ }
261
+ // 发送 typing 状态(fire-and-forget,提示对方正在输入)
262
+ if (this.serverAPI && this.accountId) {
263
+ this.serverAPI.sendTypingStatus({
264
+ fromUserId: this.accountId,
265
+ toUserId: fromUser,
266
+ conversationType: convType,
267
+ }).catch((err) => {
268
+ log.warn('[RongCloud] 发送 typing 状态失败:', (err === null || err === void 0 ? void 0 : err.message) || err);
269
+ });
270
+ }
271
+ if (convType === 3) {
272
+ // 只有文本消息才处理群管理命令
273
+ if (msg.messageType === 'RC:TxtMsg') {
274
+ const commandReply = await groupConfigManager.handleCommand(targetId, fromUser, content);
275
+ if (commandReply) {
276
+ await ((_j = this.client) === null || _j === void 0 ? void 0 : _j.sendMessage(targetId, commandReply, convType));
277
+ return;
278
+ }
279
+ }
280
+ const config = groupConfigManager.getConfig(targetId);
281
+ if (config.isStopped) {
282
+ log.info(`[RongCloud] 群 ${targetId} 的 OpenClaw 对话已停止,跳过处理`);
283
+ return;
284
+ }
285
+ if (config.currentRounds >= config.maxRounds) {
286
+ await ((_k = this.client) === null || _k === void 0 ? void 0 : _k.sendMessage(targetId, `⛔ OpenClaw 对话轮数已达上限(${config.maxRounds}轮),如需继续请联系群主调整设置。`, convType));
287
+ return;
288
+ }
289
+ }
290
+ try {
291
+ if (this.openclaw && this.serverAPI && this.appSecret) {
292
+ await this.handleStreamChat(content, fromUser, targetId, convType);
293
+ }
294
+ else {
295
+ // 降级:使用 CLI 调用
296
+ const reply = await this.callOpenClaw(content, fromUser);
297
+ log.info(`[RongCloud] AI 回复:${reply}`);
298
+ await ((_l = this.client) === null || _l === void 0 ? void 0 : _l.sendMessage(convType === 3 ? targetId : fromUser, reply, convType));
299
+ }
300
+ if (convType === 3) {
301
+ groupConfigManager.incrementRounds(targetId);
302
+ }
303
+ }
304
+ catch (err) {
305
+ log.error('[RongCloud] 处理失败:', err.message);
306
+ }
307
+ }
308
+ async handleStreamChat(content, fromUser, targetId, convType) {
309
+ var _a;
310
+ if (!this.openclaw || !this.serverAPI || !this.accountId)
311
+ return;
312
+ // 每轮新对话清空网页标题缓存,避免旧会话数据污染
313
+ urlTitleCache.clear();
314
+ const streamId = `stream-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
315
+ let seq = 0;
316
+ let hasSentChunk = false;
317
+ let buffer = '';
318
+ let fullText = '';
319
+ let streamError = null;
320
+ let streamDone = false;
321
+ // 网页搜索状态
322
+ let webSearchRawBuffer = '';
323
+ let isWebSearchSearching = false;
324
+ const webSearchUrls = [];
325
+ let webSearchDone = false;
326
+ let webSearchStatusSent = false;
327
+ const fromUserId = this.accountId;
328
+ const toId = convType === 3 ? targetId : fromUser;
329
+ const flushBuffer = async (isLastChunk = false) => {
330
+ // 非尾包且 buffer 为空时不发送
331
+ if (!isLastChunk && buffer.length === 0)
332
+ return;
333
+ seq += 1;
334
+ const chunkToSend = buffer;
335
+ buffer = '';
336
+ const isFirstChunk = seq === 1;
337
+ await this._sendStreamChunk(fromUserId, toId, chunkToSend, streamId, isFirstChunk, isLastChunk, seq, convType);
338
+ hasSentChunk = true;
339
+ };
340
+ const appendWebSearchSources = async () => {
341
+ if (webSearchUrls.length === 0)
342
+ return;
343
+ try {
344
+ const titles = await Promise.all(webSearchUrls.map(url => fetchUrlTitle(url)));
345
+ const sources = webSearchUrls.map((url, idx) => ({ url, title: titles[idx] }));
346
+ const sourcesJson = JSON.stringify(sources);
347
+ const sourcesMarkdown = `\n\n<!--WEB_SEARCH_SOURCES:${sourcesJson}-->\n\n---\n**参考来源:**\n${sources.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join('\n')}`;
348
+ buffer += sourcesMarkdown;
349
+ fullText += sourcesMarkdown;
350
+ await flushBuffer(false);
351
+ }
352
+ catch (err) {
353
+ log.warn('[WebSearch] 构建参考来源失败:', err.message);
354
+ }
355
+ };
356
+ const processWebSearchMarkers = async (delta) => {
357
+ webSearchRawBuffer += delta;
358
+ let changed = true;
359
+ while (changed) {
360
+ changed = false;
361
+ // 检测搜索开始
362
+ const searchingIdx = webSearchRawBuffer.indexOf('[web_search:searching]');
363
+ if (searchingIdx !== -1) {
364
+ if (!isWebSearchSearching) {
365
+ isWebSearchSearching = true;
366
+ log.info('[WebSearch] 检测到开始搜索');
367
+ if (!webSearchStatusSent) {
368
+ await this._sendWebSearchStatus(toId, convType, 'searching', []);
369
+ webSearchStatusSent = true;
370
+ }
371
+ }
372
+ webSearchRawBuffer = webSearchRawBuffer.slice(0, searchingIdx) + webSearchRawBuffer.slice(searchingIdx + '[web_search:searching]'.length);
373
+ changed = true;
374
+ continue;
375
+ }
376
+ // 检测 URL
377
+ const urlMatch = webSearchRawBuffer.match(/\[web_search:url:([^\]]+)\]/);
378
+ if (urlMatch && urlMatch.index !== undefined) {
379
+ const url = urlMatch[1].trim();
380
+ if (url && !webSearchUrls.includes(url)) {
381
+ webSearchUrls.push(url);
382
+ log.info(`[WebSearch] 检测到 URL: ${url}`);
383
+ }
384
+ webSearchRawBuffer = webSearchRawBuffer.slice(0, urlMatch.index) + webSearchRawBuffer.slice(urlMatch.index + urlMatch[0].length);
385
+ changed = true;
386
+ continue;
387
+ }
388
+ // 检测搜索结束
389
+ const doneIdx = webSearchRawBuffer.indexOf('[web_search:done]');
390
+ if (doneIdx !== -1) {
391
+ isWebSearchSearching = false;
392
+ webSearchDone = true;
393
+ log.info('[WebSearch] 检测到搜索结束');
394
+ webSearchRawBuffer = webSearchRawBuffer.slice(0, doneIdx) + webSearchRawBuffer.slice(doneIdx + '[web_search:done]'.length);
395
+ changed = true;
396
+ continue;
397
+ }
398
+ }
399
+ // 保留可能的不完整标记前缀
400
+ const markerStart = '[web_search:';
401
+ const markerIdx = webSearchRawBuffer.lastIndexOf(markerStart);
402
+ if (markerIdx !== -1 && webSearchRawBuffer.length - markerIdx < 500) {
403
+ const safeText = webSearchRawBuffer.slice(0, markerIdx);
404
+ webSearchRawBuffer = webSearchRawBuffer.slice(markerIdx);
405
+ return safeText;
406
+ }
407
+ const safeText = webSearchRawBuffer;
408
+ webSearchRawBuffer = '';
409
+ return safeText;
410
+ };
411
+ try {
412
+ await this.openclaw.chatStream(content, fromUser, async (delta) => {
413
+ const displayDelta = await processWebSearchMarkers(delta);
414
+ fullText += displayDelta;
415
+ buffer += displayDelta;
416
+ // 首包立即发送,让用户尽快看到"正在回复";后续按阈值 20 字符发送
417
+ if (!hasSentChunk || buffer.length >= 20) {
418
+ await flushBuffer(false);
419
+ }
420
+ }, async () => {
421
+ // 流正常结束:处理剩余标记缓冲、追加参考来源、发送结束标记与历史消息
422
+ streamDone = true;
423
+ await processWebSearchMarkers('');
424
+ if (webSearchDone && webSearchUrls.length > 0) {
425
+ await appendWebSearchSources();
426
+ }
427
+ if (webSearchStatusSent) {
428
+ await this._sendWebSearchStatus(toId, convType, 'done', webSearchUrls);
429
+ }
430
+ // 先把剩余真实内容作为非尾包发出,再发空结束包,最后补发持久化历史消息
431
+ if (buffer.length > 0) {
432
+ await flushBuffer(false);
433
+ }
434
+ await flushBuffer(true);
435
+ await this._sendStreamHistory(toId, streamId, fullText, convType);
436
+ this.streamMessageUIDs.delete(streamId);
437
+ }, (err) => {
438
+ streamError = err;
439
+ log.error('[RongCloud] OpenClaw 流式调用失败: ' + err.message);
440
+ });
441
+ if (streamError) {
442
+ throw streamError;
443
+ }
444
+ // 兜底:若 onDone 未被触发,手动发送结束标记与历史消息
445
+ if (!streamDone) {
446
+ await processWebSearchMarkers('');
447
+ if (webSearchDone && webSearchUrls.length > 0) {
448
+ await appendWebSearchSources();
449
+ }
450
+ if (webSearchStatusSent) {
451
+ await this._sendWebSearchStatus(toId, convType, 'done', webSearchUrls);
452
+ }
453
+ if (buffer.length > 0) {
454
+ await flushBuffer(false);
455
+ }
456
+ await flushBuffer(true);
457
+ await this._sendStreamHistory(toId, streamId, fullText, convType);
458
+ }
459
+ // 清理 messageUID
460
+ this.streamMessageUIDs.delete(streamId);
461
+ }
462
+ catch (err) {
463
+ if (hasSentChunk) {
464
+ // 已发送过流式片段:尽量优雅结束,补发历史记录,再告知用户错误
465
+ try {
466
+ if (buffer.length > 0) {
467
+ await flushBuffer(false);
468
+ }
469
+ await flushBuffer(true);
470
+ await this._sendStreamHistory(toId, streamId, fullText, convType);
471
+ }
472
+ catch (finishErr) {
473
+ log.warn('[RongCloud] 流式收尾失败:', finishErr.message);
474
+ }
475
+ }
476
+ this.streamMessageUIDs.delete(streamId);
477
+ // 未发送过任何流式片段,或已收尾后,降级为普通文本错误提示
478
+ await ((_a = this.client) === null || _a === void 0 ? void 0 : _a.sendMessage(toId, `OpenClaw 调用失败: ${err.message}`, convType));
479
+ throw err;
480
+ }
481
+ }
482
+ async _handleDeviceStatusRequest(msg) {
483
+ const parsed = parseDeviceStatusRequest(msg);
484
+ if (!parsed)
485
+ return;
486
+ const { requestId, sourceId } = parsed;
487
+ if (!sourceId || !requestId) {
488
+ log.warn('[DeviceStatus] 状态请求缺少 sourceId 或 requestId');
489
+ return;
490
+ }
491
+ try {
492
+ const status = await getDeviceRunningStatus();
493
+ await this._sendDeviceStatusReport(sourceId, requestId, status);
494
+ }
495
+ catch (err) {
496
+ log.error('[DeviceStatus] 获取设备运行状态失败:', err.message);
497
+ await this._sendDeviceStatusReport(sourceId, requestId, null, err.message);
498
+ }
499
+ }
500
+ async _sendDeviceStatusReport(toId, requestId, status, error) {
501
+ if (!this.serverAPI || !this.accountId)
502
+ return;
503
+ const content = {
504
+ status: error ? 'error' : 'success',
505
+ message: error || '状态报告',
506
+ data: status,
507
+ };
508
+ try {
509
+ await this.serverAPI.sendPrivateMessage({
510
+ fromUserId: this.accountId,
511
+ toUserId: toId,
512
+ objectName: 'command',
513
+ content: {
514
+ msg_type: 'device_status_report',
515
+ source_im_id: this.accountId,
516
+ destination_im_id: toId,
517
+ request_id: requestId,
518
+ content: JSON.stringify(content),
519
+ timestamp: Math.floor(Date.now() / 1000),
520
+ },
521
+ });
522
+ log.info(`[DeviceStatus] 已发送状态报告 -> ${toId}, openclaw=${status === null || status === void 0 ? void 0 : status.openclaw_status}, opencode=${status === null || status === void 0 ? void 0 : status.opencode_status}`);
523
+ }
524
+ catch (err) {
525
+ log.warn('[DeviceStatus] 发送状态报告失败:', err.message);
526
+ }
527
+ }
528
+ async _sendWebSearchStatus(toId, convType, status, urls = []) {
529
+ if (!this.serverAPI || !this.accountId)
530
+ return;
531
+ try {
532
+ const content = { status, urls, timestamp: Date.now() };
533
+ if (convType === 3) {
534
+ await this.serverAPI.sendGroupMessage({
535
+ fromUserId: this.accountId,
536
+ toGroupId: toId,
537
+ objectName: 'web_search',
538
+ content,
539
+ });
540
+ }
541
+ else {
542
+ await this.serverAPI.sendPrivateMessage({
543
+ fromUserId: this.accountId,
544
+ toUserId: toId,
545
+ objectName: 'web_search',
546
+ content,
547
+ });
548
+ }
549
+ log.info(`[WebSearch] 已发送 ${status} 状态, urls=${urls.length}`);
550
+ }
551
+ catch (err) {
552
+ log.warn('[WebSearch] 发送搜索状态失败:', err.message);
553
+ }
554
+ }
555
+ async _sendStreamChunk(fromUserId, targetId, content, streamId, isFirstChunk, isLastChunk, seq, conversationType) {
556
+ if (!this.serverAPI)
557
+ return;
558
+ // 使用队列确保流式消息片段串行发送
559
+ this.streamQueue = this.streamQueue.then(async () => {
560
+ try {
561
+ const messageUID = this.streamMessageUIDs.get(streamId) || null;
562
+ if (conversationType === 3) {
563
+ const result = await this.serverAPI.sendStreamGroup({
564
+ fromUserId,
565
+ toGroupId: targetId,
566
+ content,
567
+ streamId,
568
+ isFirstChunk,
569
+ isLastChunk,
570
+ seq,
571
+ messageUID,
572
+ });
573
+ if (isFirstChunk && (result === null || result === void 0 ? void 0 : result.messageUID)) {
574
+ this.streamMessageUIDs.set(streamId, result.messageUID);
575
+ }
576
+ }
577
+ else {
578
+ const result = await this.serverAPI.sendStreamPrivate({
579
+ fromUserId,
580
+ toUserId: targetId,
581
+ content,
582
+ streamId,
583
+ isFirstChunk,
584
+ isLastChunk,
585
+ seq,
586
+ messageUID,
587
+ });
588
+ if (isFirstChunk && (result === null || result === void 0 ? void 0 : result.messageUID)) {
589
+ this.streamMessageUIDs.set(streamId, result.messageUID);
590
+ }
591
+ }
592
+ }
593
+ catch (err) {
594
+ log.warn(`[RongCloud] 发送流式消息失败 seq=${seq}:`, err.message);
595
+ }
596
+ });
597
+ await this.streamQueue;
598
+ }
599
+ async _sendStreamHistory(targetId, streamId, fullText, conversationType) {
600
+ if (!this.client || !fullText.trim())
601
+ return;
602
+ try {
603
+ const historyContent = JSON.stringify({
604
+ __stream_history__: true,
605
+ streamId,
606
+ text: fullText,
607
+ sentTime: Date.now(),
608
+ });
609
+ await this.client.sendMessage(targetId, historyContent, conversationType);
610
+ log.info(`[RongCloud] 流式历史消息已发送, length=${fullText.length}`);
611
+ }
612
+ catch (err) {
613
+ log.warn('[RongCloud] 发送流式历史消息失败:', err.message);
614
+ }
615
+ }
616
+ async callOpenClaw(message, fromUser) {
617
+ const sessionId = `claw-messenger-${fromUser}-${Date.now()}`;
618
+ return spawnOpenClaw(['agent', '-m', message, '--session-id', sessionId]);
619
+ }
620
+ }
621
+ const channelImpl = new RongCloudChannelImpl();
622
+ export { RongCloudChannelImpl, channelImpl };
623
+ const clawMessengerPlugin = {
624
+ id: 'claw_messenger',
625
+ meta: {
626
+ id: 'claw_messenger',
627
+ label: 'ClawMessenger',
628
+ selectionLabel: '虾说 IM',
629
+ docsPath: '/channels/claw_messenger',
630
+ docsLabel: 'claw_messenger',
631
+ blurb: '虾说 IM 直连通道,支持私聊、群聊、流式消息',
632
+ order: 80,
633
+ },
634
+ capabilities: {
635
+ chatTypes: ['direct', 'group'],
636
+ media: true,
637
+ reactions: false,
638
+ threads: false,
639
+ edit: false,
640
+ reply: false,
641
+ polls: false,
642
+ },
643
+ configSchema: {
644
+ schema: {
645
+ type: 'object',
646
+ additionalProperties: false,
647
+ properties: {
648
+ nodeName: { type: 'string', label: '节点昵称' },
649
+ },
650
+ },
651
+ },
652
+ config: {
653
+ listAccountIds: (cfg) => {
654
+ var _a;
655
+ const channelConfig = (_a = cfg === null || cfg === void 0 ? void 0 : cfg.channels) === null || _a === void 0 ? void 0 : _a.claw_messenger;
656
+ if ((channelConfig === null || channelConfig === void 0 ? void 0 : channelConfig.enabled) === false)
657
+ return [];
658
+ return ['default'];
659
+ },
660
+ resolveAccount: (cfg, accountId) => {
661
+ var _a;
662
+ if (accountId !== 'default')
663
+ return null;
664
+ const channelConfig = (_a = cfg === null || cfg === void 0 ? void 0 : cfg.channels) === null || _a === void 0 ? void 0 : _a.claw_messenger;
665
+ return {
666
+ accountId: 'default',
667
+ enabled: (channelConfig === null || channelConfig === void 0 ? void 0 : channelConfig.enabled) !== false,
668
+ name: (channelConfig === null || channelConfig === void 0 ? void 0 : channelConfig.nodeName) || 'default',
669
+ };
670
+ },
671
+ isConfigured: (account) => {
672
+ return (account === null || account === void 0 ? void 0 : account.enabled) !== false;
673
+ },
674
+ isEnabled: (account, cfg) => {
675
+ var _a, _b;
676
+ return (account === null || account === void 0 ? void 0 : account.enabled) !== false && ((_b = (_a = cfg === null || cfg === void 0 ? void 0 : cfg.channels) === null || _a === void 0 ? void 0 : _a.claw_messenger) === null || _b === void 0 ? void 0 : _b.enabled) !== false;
677
+ },
678
+ describeAccount: (account) => ({
679
+ accountId: account === null || account === void 0 ? void 0 : account.accountId,
680
+ enabled: account === null || account === void 0 ? void 0 : account.enabled,
681
+ name: account === null || account === void 0 ? void 0 : account.name,
682
+ }),
683
+ },
684
+ messaging: {
685
+ normalizeTarget: (target) => {
686
+ // 统一为 claw_messenger:<user|group>:<id> 格式
687
+ if (typeof target !== 'string')
688
+ return target;
689
+ if (target.startsWith('claw_messenger:'))
690
+ return target;
691
+ if (target.startsWith('user:') || target.startsWith('group:')) {
692
+ return `claw_messenger:${target}`;
693
+ }
694
+ // 默认按 user 处理
695
+ return `claw_messenger:user:${target}`;
696
+ },
697
+ targetResolver: {
698
+ looksLikeId: (id) => {
699
+ if (typeof id !== 'string')
700
+ return false;
701
+ return /^claw_messenger:(user|group):/.test(id) || /^[a-zA-Z0-9_]+$/.test(id);
702
+ },
703
+ hint: '虾说 IM target format: claw_messenger:user:<userId> or claw_messenger:group:<groupId>',
704
+ },
705
+ },
706
+ outbound: {
707
+ deliveryMode: 'direct',
708
+ chunker: null,
709
+ textChunkLimit: 5000,
710
+ sendText: async ({ to, text, metadata }) => {
711
+ // 解析 target
712
+ let targetId = to;
713
+ let conversationType = (metadata === null || metadata === void 0 ? void 0 : metadata.conversationType) || 1;
714
+ if (to.startsWith('claw_messenger:user:')) {
715
+ targetId = to.slice('claw_messenger:user:'.length);
716
+ conversationType = 1;
717
+ }
718
+ else if (to.startsWith('claw_messenger:group:')) {
719
+ targetId = to.slice('claw_messenger:group:'.length);
720
+ conversationType = 3;
721
+ }
722
+ await channelImpl.sendMessage(targetId, text, { conversationType });
723
+ return {
724
+ channel: 'claw_messenger',
725
+ messageId: '',
726
+ meta: {},
727
+ };
728
+ },
729
+ sendMedia: async ({ to, mediaUrl, mimeType, fileName, fileSize, metadata, }) => {
730
+ let targetId = to;
731
+ let conversationType = (metadata === null || metadata === void 0 ? void 0 : metadata.conversationType) || 1;
732
+ if (to.startsWith('claw_messenger:user:')) {
733
+ targetId = to.slice('claw_messenger:user:'.length);
734
+ conversationType = 1;
735
+ }
736
+ else if (to.startsWith('claw_messenger:group:')) {
737
+ targetId = to.slice('claw_messenger:group:'.length);
738
+ conversationType = 3;
739
+ }
740
+ await channelImpl.sendMediaMessage(targetId, { mediaUrl, mimeType, fileName, fileSize }, conversationType);
741
+ return {
742
+ channel: 'claw_messenger',
743
+ messageId: '',
744
+ meta: {},
745
+ };
746
+ },
747
+ },
748
+ gateway: {
749
+ startAccount: async (ctx) => {
750
+ log.info(`[claw_messenger:${ctx.accountId}] 启动 channel account`);
751
+ ctx.setStatus(Object.assign(Object.assign({}, ctx.getStatus()), { running: true, connected: false, lastError: null }));
752
+ try {
753
+ await channelImpl.startAccount(ctx);
754
+ }
755
+ catch (err) {
756
+ const message = (err === null || err === void 0 ? void 0 : err.message) || String(err);
757
+ log.error(`[claw_messenger:${ctx.accountId}] channel 退出:`, message);
758
+ ctx.setStatus(Object.assign(Object.assign({}, ctx.getStatus()), { running: false, connected: false, lastError: message }));
759
+ throw err;
760
+ }
761
+ },
762
+ },
763
+ };
764
+ export default {
765
+ id: 'claw_messenger',
766
+ name: 'Claw Messenger',
767
+ description: '虾说 IM 直连通道插件,支持私聊、群聊、流式消息',
768
+ register(api) {
769
+ log.info('虾说 IM 插件已注册');
770
+ void api.registerChannel({ plugin: clawMessengerPlugin });
771
+ },
772
+ };