@core-workspace/infoflow-openclaw-plugin 2026.3.9 → 2026.3.32
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/CHANGELOG.md +21 -0
- package/README.md +142 -50
- package/docs/qa-feature-list.md +413 -0
- package/index.ts +25 -1
- package/openclaw.plugin.json +22 -1
- package/package.json +15 -4
- package/publish.sh +221 -0
- package/scripts/deploy.sh +1 -1
- package/scripts/npm-tools/README.md +70 -0
- package/scripts/npm-tools/cli.js +262 -0
- package/scripts/npm-tools/package.json +21 -0
- package/skills/infoflow-dev/references/api.md +1 -1
- package/src/adapter/inbound/ws-receiver.ts +71 -29
- package/src/adapter/outbound/reply-dispatcher.ts +12 -19
- package/src/channel/accounts.ts +26 -6
- package/src/channel/channel.ts +5 -4
- package/src/channel/media.ts +8 -0
- package/src/channel/outbound.ts +15 -7
- package/src/commands/changelog.ts +53 -0
- package/src/commands/doctor.ts +391 -0
- package/src/commands/logs.ts +212 -0
- package/src/handler/message-handler.ts +77 -82
- package/src/security/group-policy.ts +2 -0
- package/src/types.ts +20 -4
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /infoflow-doctor command
|
|
3
|
+
*
|
|
4
|
+
* 自检命令:逐项检查 Infoflow 插件的配置和连通性,
|
|
5
|
+
* 帮助用户快速定位"为什么群聊/私聊收不到回复"的问题。
|
|
6
|
+
*
|
|
7
|
+
* 用法:/infoflow-doctor
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { resolve, dirname } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
14
|
+
import { listInfoflowAccountIds, resolveInfoflowAccount } from "../channel/accounts.js";
|
|
15
|
+
import { sendInfoflowMessage } from "../channel/outbound.js";
|
|
16
|
+
import { sendInfoflowImageMessage } from "../channel/media.js";
|
|
17
|
+
import { getOrCreateAdapter } from "../utils/token-adapter.js";
|
|
18
|
+
|
|
19
|
+
// 从 package.json 读取插件版本号
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
let PLUGIN_VERSION = "unknown";
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, "../../package.json"), "utf-8"));
|
|
24
|
+
PLUGIN_VERSION = pkg.version ?? "unknown";
|
|
25
|
+
} catch { /* ignore */ }
|
|
26
|
+
|
|
27
|
+
// 48x48 绿色圆形+白色对勾,用于发图片自检(可视化验证)
|
|
28
|
+
const TEST_IMAGE_BASE64 =
|
|
29
|
+
"iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAA8klEQVR42u3ZSw6DMAwE0DkUN+kle0vaJRIkGtvx2KmKWAblASH+gLPZgZ8FHe9XJeg7PXOmg0hHRIZsipUFmYY0QalhTFBSGBZKNBPTJiCBZmRCoebRhFrN3aQDTaYYgmTvZTJAAWKWqQ5EfsUPoELNdTD0MasG5AgAiSBfOMoCuYOjFMRfCP5h8nfp3v2pJ2RdlRGNBzQaGU9j/KD74CVJlWFRz+dblXNGQfPy2f15wrRrpVaV5mgv0JjTD0HxD99+urxWCeXUeRp/kr+2NFhTl2VoNqlc/82GTftDHTtoTXuMHbuwHfvUTTv5ff915B0fHgSvRnMJWVsAAAAASUVORK5CYII=";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
type CheckResult = {
|
|
36
|
+
name: string;
|
|
37
|
+
ok: boolean;
|
|
38
|
+
detail: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Individual checks
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function checkAccountConfig(cfg: OpenClawConfig): CheckResult[] {
|
|
46
|
+
const results: CheckResult[] = [];
|
|
47
|
+
const accountIds = listInfoflowAccountIds(cfg);
|
|
48
|
+
|
|
49
|
+
for (const accountId of accountIds) {
|
|
50
|
+
const prefix = accountIds.length > 1 ? `[${accountId}] ` : "";
|
|
51
|
+
let account: ReturnType<typeof resolveInfoflowAccount>;
|
|
52
|
+
try {
|
|
53
|
+
account = resolveInfoflowAccount({ cfg, accountId });
|
|
54
|
+
} catch {
|
|
55
|
+
results.push({ name: `${prefix}账号解析`, ok: false, detail: "resolveInfoflowAccount 抛出异常" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const c = account.config;
|
|
60
|
+
|
|
61
|
+
results.push({
|
|
62
|
+
name: `${prefix}appKey`,
|
|
63
|
+
ok: !!c.appKey,
|
|
64
|
+
detail: c.appKey ? `已配置 (${c.appKey.slice(0, 8)}...)` : "未配置",
|
|
65
|
+
});
|
|
66
|
+
results.push({
|
|
67
|
+
name: `${prefix}appSecret`,
|
|
68
|
+
ok: !!c.appSecret,
|
|
69
|
+
detail: c.appSecret ? "已配置" : "未配置",
|
|
70
|
+
});
|
|
71
|
+
results.push({
|
|
72
|
+
name: `${prefix}apiHost`,
|
|
73
|
+
ok: !!c.apiHost,
|
|
74
|
+
detail: c.apiHost ?? "未配置(将使用默认值)",
|
|
75
|
+
});
|
|
76
|
+
results.push({
|
|
77
|
+
name: `${prefix}connectionMode`,
|
|
78
|
+
ok: true,
|
|
79
|
+
detail: c.connectionMode ?? "websocket(默认)",
|
|
80
|
+
});
|
|
81
|
+
if ((c.connectionMode ?? "websocket") === "websocket") {
|
|
82
|
+
results.push({
|
|
83
|
+
name: `${prefix}wsGateway`,
|
|
84
|
+
ok: true,
|
|
85
|
+
detail: c.wsGateway ?? "未配置(将使用默认值 infoflow-open-gateway.weiyun.baidu.com)",
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
results.push({
|
|
89
|
+
name: `${prefix}checkToken`,
|
|
90
|
+
ok: !!c.checkToken,
|
|
91
|
+
detail: c.checkToken ? "已配置" : "未配置(webhook 签名验证将失败)",
|
|
92
|
+
});
|
|
93
|
+
results.push({
|
|
94
|
+
name: `${prefix}encodingAESKey`,
|
|
95
|
+
ok: !!c.encodingAESKey,
|
|
96
|
+
detail: c.encodingAESKey ? "已配置" : "未配置(消息解密将失败)",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
results.push({
|
|
100
|
+
name: `${prefix}dmPolicy`,
|
|
101
|
+
ok: true,
|
|
102
|
+
detail: c.dmPolicy ?? "open(默认)",
|
|
103
|
+
});
|
|
104
|
+
results.push({
|
|
105
|
+
name: `${prefix}groupPolicy`,
|
|
106
|
+
ok: true,
|
|
107
|
+
detail: c.groupPolicy ?? "open(默认)",
|
|
108
|
+
});
|
|
109
|
+
if (c.groupPolicy === "disabled") {
|
|
110
|
+
results.push({
|
|
111
|
+
name: `${prefix}群聊提醒`,
|
|
112
|
+
ok: false,
|
|
113
|
+
detail: "groupPolicy=disabled,群聊消息不会被处理!如需群聊支持请设置为 open 或 allowlist",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function checkTokenFetch(cfg: OpenClawConfig): Promise<CheckResult[]> {
|
|
122
|
+
const results: CheckResult[] = [];
|
|
123
|
+
const accountIds = listInfoflowAccountIds(cfg);
|
|
124
|
+
|
|
125
|
+
for (const accountId of accountIds) {
|
|
126
|
+
const prefix = accountIds.length > 1 ? `[${accountId}] ` : "";
|
|
127
|
+
let account: ReturnType<typeof resolveInfoflowAccount>;
|
|
128
|
+
try {
|
|
129
|
+
account = resolveInfoflowAccount({ cfg, accountId });
|
|
130
|
+
} catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!account.config.appKey || !account.config.appSecret) {
|
|
134
|
+
results.push({ name: `${prefix}access_token 获取`, ok: false, detail: "appKey/appSecret 未配置,跳过" });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const adapter = getOrCreateAdapter({
|
|
140
|
+
appKey: account.config.appKey,
|
|
141
|
+
appSecret: account.config.appSecret,
|
|
142
|
+
apiHost: account.config.apiHost ?? "http://apiin.im.baidu.com",
|
|
143
|
+
});
|
|
144
|
+
const token = await adapter.getToken();
|
|
145
|
+
results.push({
|
|
146
|
+
name: `${prefix}access_token 获取`,
|
|
147
|
+
ok: !!token,
|
|
148
|
+
detail: token ? `成功 (${String(token).slice(0, 12)}...)` : "返回为空",
|
|
149
|
+
});
|
|
150
|
+
} catch (err: any) {
|
|
151
|
+
results.push({
|
|
152
|
+
name: `${prefix}access_token 获取`,
|
|
153
|
+
ok: false,
|
|
154
|
+
detail: `失败: ${err?.message ?? String(err)}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return results;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function checkSendMessage(cfg: OpenClawConfig, senderId: string): Promise<CheckResult[]> {
|
|
163
|
+
const results: CheckResult[] = [];
|
|
164
|
+
if (!senderId) {
|
|
165
|
+
results.push({ name: "发消息自检", ok: false, detail: "无法确定发送目标(senderId 为空)" });
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const accountIds = listInfoflowAccountIds(cfg);
|
|
170
|
+
for (const accountId of accountIds) {
|
|
171
|
+
const prefix = accountIds.length > 1 ? `[${accountId}] ` : "";
|
|
172
|
+
let account: ReturnType<typeof resolveInfoflowAccount>;
|
|
173
|
+
try {
|
|
174
|
+
account = resolveInfoflowAccount({ cfg, accountId });
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!account.config.appKey || !account.config.appSecret) continue;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const result = await sendInfoflowMessage({
|
|
182
|
+
cfg,
|
|
183
|
+
to: senderId,
|
|
184
|
+
contents: [{ type: "text", content: "✅ [infoflow-doctor] 发消息自检通过" }],
|
|
185
|
+
accountId: account.accountId,
|
|
186
|
+
});
|
|
187
|
+
results.push({
|
|
188
|
+
name: `${prefix}发消息到 ${senderId}`,
|
|
189
|
+
ok: result.ok,
|
|
190
|
+
detail: result.ok ? `成功 (messageId: ${result.messageId ?? "N/A"})` : `失败: ${result.error ?? "unknown"}`,
|
|
191
|
+
});
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
results.push({
|
|
194
|
+
name: `${prefix}发消息到 ${senderId}`,
|
|
195
|
+
ok: false,
|
|
196
|
+
detail: `异常: ${err?.message ?? String(err)}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return results;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function checkSendImage(cfg: OpenClawConfig, to: string): Promise<CheckResult[]> {
|
|
205
|
+
const results: CheckResult[] = [];
|
|
206
|
+
const accountIds = listInfoflowAccountIds(cfg);
|
|
207
|
+
|
|
208
|
+
for (const accountId of accountIds) {
|
|
209
|
+
const prefix = accountIds.length > 1 ? `[${accountId}] ` : "";
|
|
210
|
+
let account: ReturnType<typeof resolveInfoflowAccount>;
|
|
211
|
+
try {
|
|
212
|
+
account = resolveInfoflowAccount({ cfg, accountId });
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!account.config.appKey || !account.config.appSecret) continue;
|
|
217
|
+
|
|
218
|
+
const isGroup = to.includes(":group:");
|
|
219
|
+
const target = to.replace(/^.*?:group:/, "group:");
|
|
220
|
+
const label = isGroup ? `群 ${target}` : to;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const result = await sendInfoflowImageMessage({
|
|
224
|
+
cfg,
|
|
225
|
+
to: target,
|
|
226
|
+
base64Image: TEST_IMAGE_BASE64,
|
|
227
|
+
accountId: account.accountId,
|
|
228
|
+
});
|
|
229
|
+
results.push({
|
|
230
|
+
name: `${prefix}发图片到 ${label}`,
|
|
231
|
+
ok: result.ok,
|
|
232
|
+
detail: result.ok
|
|
233
|
+
? `成功 (messageId: ${result.messageId ?? "N/A"})`
|
|
234
|
+
: `失败: ${result.error ?? "unknown"}`,
|
|
235
|
+
});
|
|
236
|
+
} catch (err: any) {
|
|
237
|
+
results.push({
|
|
238
|
+
name: `${prefix}发图片到 ${label}`,
|
|
239
|
+
ok: false,
|
|
240
|
+
detail: `异常: ${err?.message ?? String(err)}`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return results;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Format output
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
// 将技术字段名映射为用户友好的中文描述
|
|
253
|
+
const FIELD_LABELS: Record<string, string> = {
|
|
254
|
+
appKey: "应用 Key",
|
|
255
|
+
appSecret: "应用密钥",
|
|
256
|
+
apiHost: "API 地址",
|
|
257
|
+
connectionMode: "连接方式",
|
|
258
|
+
wsGateway: "WebSocket 网关地址",
|
|
259
|
+
checkToken: "Webhook 验签 Token",
|
|
260
|
+
encodingAESKey: "消息加密密钥",
|
|
261
|
+
dmPolicy: "私聊权限策略",
|
|
262
|
+
groupPolicy: "群聊权限策略",
|
|
263
|
+
"群聊提醒": "群聊权限警告",
|
|
264
|
+
"账号解析": "账号配置读取",
|
|
265
|
+
"access_token 获取": "API 登录验证",
|
|
266
|
+
"发消息自检": "发消息测试",
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
function friendlyName(name: string): string {
|
|
270
|
+
// 处理带账号前缀的名字,如 "[main] appKey"
|
|
271
|
+
const match = name.match(/^(\[.+?\] )(.+)$/);
|
|
272
|
+
if (match) {
|
|
273
|
+
const prefix = match[1];
|
|
274
|
+
const field = match[2];
|
|
275
|
+
return `${prefix}${FIELD_LABELS[field] ?? field}`;
|
|
276
|
+
}
|
|
277
|
+
// 处理 "发消息到 xxx" / "发图片到 xxx"
|
|
278
|
+
if (name.startsWith("发消息到 ") || name.startsWith("发图片到 ")) return name;
|
|
279
|
+
return FIELD_LABELS[name] ?? name;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function friendlyDetail(name: string, detail: string): string {
|
|
283
|
+
// 连接方式特殊处理
|
|
284
|
+
if (name.endsWith("connectionMode")) {
|
|
285
|
+
if (detail.includes("websocket")) return "WebSocket 长连接(无需公网域名)";
|
|
286
|
+
return "Webhook 回调(需配置公网地址)";
|
|
287
|
+
}
|
|
288
|
+
// dmPolicy
|
|
289
|
+
if (name.endsWith("dmPolicy")) {
|
|
290
|
+
if (detail.includes("open")) return "开放(任何人可私聊)";
|
|
291
|
+
if (detail.includes("allowlist")) return "白名单(仅允许名单内用户)";
|
|
292
|
+
if (detail.includes("pairing")) return "配对授权";
|
|
293
|
+
return detail;
|
|
294
|
+
}
|
|
295
|
+
// groupPolicy
|
|
296
|
+
if (name.endsWith("groupPolicy")) {
|
|
297
|
+
if (detail.includes("disabled")) return "已关闭群聊(所有群消息不处理)";
|
|
298
|
+
if (detail.includes("allowlist")) return "白名单(仅允许名单内的群)";
|
|
299
|
+
if (detail.includes("open")) return "开放(所有群可触发)";
|
|
300
|
+
return detail;
|
|
301
|
+
}
|
|
302
|
+
// wsGateway 默认值
|
|
303
|
+
if (detail.includes("infoflow-open-gateway.weiyun.baidu.com")) {
|
|
304
|
+
return detail.includes("未配置") ? "使用默认网关" : detail;
|
|
305
|
+
}
|
|
306
|
+
// apiHost 默认值
|
|
307
|
+
if (detail.includes("将使用默认值")) return "使用默认地址";
|
|
308
|
+
// groupPolicy=disabled 警告
|
|
309
|
+
if (detail.includes("groupPolicy=disabled")) {
|
|
310
|
+
return "群聊功能已关闭,机器人不会响应任何群消息。如需开启,请将 groupPolicy 设置为 open";
|
|
311
|
+
}
|
|
312
|
+
return detail;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function formatResults(results: CheckResult[]): string {
|
|
316
|
+
const lines: string[] = [`**如流机器人自检报告** v${PLUGIN_VERSION}`, ""];
|
|
317
|
+
|
|
318
|
+
const failed = results.filter((r) => !r.ok);
|
|
319
|
+
const passed = results.filter((r) => r.ok);
|
|
320
|
+
|
|
321
|
+
for (const r of results) {
|
|
322
|
+
const icon = r.ok ? "✅" : "❌";
|
|
323
|
+
const label = friendlyName(r.name);
|
|
324
|
+
const detail = friendlyDetail(r.name, r.detail);
|
|
325
|
+
lines.push(`${icon} **${label}**:${detail}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
lines.push("");
|
|
329
|
+
lines.push("---");
|
|
330
|
+
|
|
331
|
+
if (failed.length === 0) {
|
|
332
|
+
lines.push(`✅ 自检全部通过(共 ${results.length} 项),机器人运行正常!`);
|
|
333
|
+
} else {
|
|
334
|
+
lines.push(`⚠️ 发现 ${failed.length} 个问题,请根据上方提示修复后重试`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
lines.push("");
|
|
338
|
+
lines.push("📋 **排查工具**");
|
|
339
|
+
lines.push("- `/infoflow-logs` — 查看最近 50 条收发消息日志");
|
|
340
|
+
lines.push("- `/infoflow-logs 100` — 查看最近 100 条");
|
|
341
|
+
lines.push("- `/infoflow-changelog` — 查看插件更新记录");
|
|
342
|
+
|
|
343
|
+
return lines.join("\n");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Command handler
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
export async function runDoctorCommand(ctx: {
|
|
351
|
+
config: OpenClawConfig;
|
|
352
|
+
senderId?: string;
|
|
353
|
+
/** 消息目标:群聊时为 "group:<groupId>",私聊时与 senderId 相同 */
|
|
354
|
+
to?: string;
|
|
355
|
+
}): Promise<{ text: string }> {
|
|
356
|
+
const { config, senderId, to } = ctx;
|
|
357
|
+
const results: CheckResult[] = [];
|
|
358
|
+
|
|
359
|
+
// 1. 配置检查(同步)
|
|
360
|
+
results.push(...checkAccountConfig(config));
|
|
361
|
+
|
|
362
|
+
// 2. Token 获取(异步)
|
|
363
|
+
results.push(...(await checkTokenFetch(config)));
|
|
364
|
+
|
|
365
|
+
// 网页 UI 触发时 senderId 为系统账号,无法发消息,跳过发送自检
|
|
366
|
+
const isWebUI = !senderId || senderId === "openclaw-control-ui" || senderId.startsWith("openclaw-");
|
|
367
|
+
const realSenderId = isWebUI ? undefined : senderId;
|
|
368
|
+
|
|
369
|
+
if (isWebUI) {
|
|
370
|
+
results.push({ name: "发消息自检", ok: true, detail: "网页 UI 触发,跳过发送自检(请从如流客户端触发以完整测试)" });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 3. 发私聊消息自检(发给触发命令的用户)
|
|
374
|
+
if (realSenderId) {
|
|
375
|
+
results.push(...(await checkSendMessage(config, realSenderId)));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 4. 发群消息自检(仅群聊中执行命令时)
|
|
379
|
+
const groupTo = to?.includes(":group:") ? to.replace(/^.*?:group:/, "group:") : undefined;
|
|
380
|
+
if (groupTo) {
|
|
381
|
+
results.push(...(await checkSendMessage(config, groupTo)));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 5. 发图片自检(群聊发到群,私聊发给用户)
|
|
385
|
+
const imageTarget = groupTo ?? realSenderId;
|
|
386
|
+
if (imageTarget) {
|
|
387
|
+
results.push(...(await checkSendImage(config, imageTarget)));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { text: formatResults(results) };
|
|
391
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /infoflow-logs command
|
|
3
|
+
*
|
|
4
|
+
* 读取 OpenClaw 网关的结构化日志文件,筛选 Infoflow 相关条目并格式化输出。
|
|
5
|
+
* 帮助用户快速了解"收到了哪些消息、回复了什么、有没有报错"。
|
|
6
|
+
*
|
|
7
|
+
* 日志文件位置:/tmp/openclaw/openclaw-YYYY-MM-DD.log(jsonl 格式)
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* /infoflow-logs — 展示最近 50 条
|
|
11
|
+
* /infoflow-logs 100 — 展示最近 100 条(最大 200)
|
|
12
|
+
* /infoflow-logs 0-50 — 最近第 0~50 条
|
|
13
|
+
* /infoflow-logs 50-100 — 最近第 50~100 条(往前翻页)
|
|
14
|
+
*
|
|
15
|
+
* 长输出由框架的 textChunkLimit(默认 1800 字符/条)自动分片发送,
|
|
16
|
+
* 可通过 channels.infoflow.textChunkLimit 配置调整每片大小。
|
|
17
|
+
* 总输出上限由 LOGS_TOTAL_CHAR_LIMIT 控制(默认 20000 字符)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const LOG_DIR = "/tmp/openclaw";
|
|
28
|
+
const DEFAULT_LINES = 50;
|
|
29
|
+
const MAX_LINES = 200;
|
|
30
|
+
/** 总输出字符上限,防止输出过多刷屏 */
|
|
31
|
+
const LOGS_TOTAL_CHAR_LIMIT = 20_000;
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Infoflow log filter
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function isInfoflowRelevant(msg: string): boolean {
|
|
38
|
+
// Exclude SDK internal heartbeat noise
|
|
39
|
+
if (msg.includes("[dispatch] 分发事件: heartbeat")) return false;
|
|
40
|
+
if (msg.includes("[dispatch] 分发事件: connected")) return false;
|
|
41
|
+
if (msg.includes("[dispatch] 分发事件: disconnected")) return false;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
msg.includes("[inbound:") ||
|
|
45
|
+
msg.includes("[outbound") ||
|
|
46
|
+
msg.includes("[dispatch") ||
|
|
47
|
+
msg.includes("[ws:") ||
|
|
48
|
+
msg.includes("[infoflow:") ||
|
|
49
|
+
msg.includes("[infoflow]") ||
|
|
50
|
+
(msg.includes("infoflow") && msg.includes("error"))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatLogEntry(raw: string): string | null {
|
|
55
|
+
const trimmed = raw.trim();
|
|
56
|
+
if (!trimmed) return null;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const obj = JSON.parse(trimmed) as Record<string, unknown>;
|
|
60
|
+
// jsonl: { "0": binding-json, "1": message } or { "0": message }
|
|
61
|
+
let msg = "";
|
|
62
|
+
const field0 = String(obj["0"] ?? "");
|
|
63
|
+
const field1 = String(obj["1"] ?? "");
|
|
64
|
+
if (field1) {
|
|
65
|
+
msg = field1;
|
|
66
|
+
} else if (field0 && !field0.startsWith("{")) {
|
|
67
|
+
msg = field0;
|
|
68
|
+
}
|
|
69
|
+
if (!msg || !isInfoflowRelevant(msg)) return null;
|
|
70
|
+
|
|
71
|
+
const time = obj["time"] as string | undefined;
|
|
72
|
+
let timeStr = "";
|
|
73
|
+
if (time) {
|
|
74
|
+
try {
|
|
75
|
+
timeStr = `${new Date(time).toLocaleTimeString("en-US", { hour12: false })} `;
|
|
76
|
+
} catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const meta = obj["_meta"] as Record<string, unknown> | undefined;
|
|
80
|
+
const levelName = String(meta?.["logLevelName"] ?? "");
|
|
81
|
+
const level = Number(obj["level"] ?? (levelName === "ERROR" ? 50 : levelName === "WARN" ? 40 : 30));
|
|
82
|
+
const levelStr = level >= 50 ? "❌ " : level >= 40 ? "⚠️ " : "";
|
|
83
|
+
|
|
84
|
+
return `${timeStr}${levelStr}${msg}`;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Log file discovery
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function getTodayLogPath(): string {
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const yyyy = now.getFullYear();
|
|
97
|
+
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
98
|
+
const dd = String(now.getDate()).padStart(2, "0");
|
|
99
|
+
return join(LOG_DIR, `openclaw-${yyyy}-${mm}-${dd}.log`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getYesterdayLogPath(): string {
|
|
103
|
+
const yesterday = new Date(Date.now() - 86_400_000);
|
|
104
|
+
const yyyy = yesterday.getFullYear();
|
|
105
|
+
const mm = String(yesterday.getMonth() + 1).padStart(2, "0");
|
|
106
|
+
const dd = String(yesterday.getDate()).padStart(2, "0");
|
|
107
|
+
return join(LOG_DIR, `openclaw-${yyyy}-${mm}-${dd}.log`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Command handler
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export async function runLogsCommand(ctx: {
|
|
115
|
+
config?: unknown;
|
|
116
|
+
senderId?: string;
|
|
117
|
+
args?: string;
|
|
118
|
+
}): Promise<{ text: string }> {
|
|
119
|
+
// Parse optional argument:
|
|
120
|
+
// /infoflow-logs → 最近 50 条
|
|
121
|
+
// /infoflow-logs 100 → 最近 100 条
|
|
122
|
+
// /infoflow-logs 50-100 → 第 50~100 条(从末尾倒数)
|
|
123
|
+
const arg = ctx.args?.trim() ?? "";
|
|
124
|
+
let startOffset = 0;
|
|
125
|
+
let limit = DEFAULT_LINES;
|
|
126
|
+
|
|
127
|
+
if (arg) {
|
|
128
|
+
const rangeMatch = arg.match(/^(\d+)-(\d+)$/);
|
|
129
|
+
if (rangeMatch) {
|
|
130
|
+
const a = parseInt(rangeMatch[1], 10);
|
|
131
|
+
const b = parseInt(rangeMatch[2], 10);
|
|
132
|
+
const lo = Math.min(a, b);
|
|
133
|
+
const hi = Math.max(a, b);
|
|
134
|
+
startOffset = lo;
|
|
135
|
+
limit = Math.min(hi - lo, MAX_LINES);
|
|
136
|
+
} else {
|
|
137
|
+
const n = parseInt(arg, 10);
|
|
138
|
+
limit = Number.isFinite(n) && n > 0 ? Math.min(n, MAX_LINES) : DEFAULT_LINES;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const todayPath = getTodayLogPath();
|
|
143
|
+
const yesterdayPath = getYesterdayLogPath();
|
|
144
|
+
const logPath = existsSync(todayPath) ? todayPath
|
|
145
|
+
: existsSync(yesterdayPath) ? yesterdayPath
|
|
146
|
+
: null;
|
|
147
|
+
|
|
148
|
+
if (!logPath) {
|
|
149
|
+
return {
|
|
150
|
+
text: [
|
|
151
|
+
"**Infoflow 日志**",
|
|
152
|
+
"",
|
|
153
|
+
"未找到日志文件。",
|
|
154
|
+
`期望位置:${todayPath}`,
|
|
155
|
+
"",
|
|
156
|
+
"可能的原因:",
|
|
157
|
+
"- Gateway 尚未启动过",
|
|
158
|
+
"- 日志路径与预期不符",
|
|
159
|
+
].join("\n"),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let lines: string[];
|
|
164
|
+
try {
|
|
165
|
+
const content = readFileSync(logPath, "utf-8");
|
|
166
|
+
lines = content.split("\n");
|
|
167
|
+
} catch (err: unknown) {
|
|
168
|
+
return {
|
|
169
|
+
text: `**Infoflow 日志**\n\n读取日志文件失败:${err instanceof Error ? err.message : String(err)}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Parse and filter
|
|
174
|
+
const entries: string[] = [];
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const formatted = formatLogEntry(line);
|
|
177
|
+
if (formatted) entries.push(formatted);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Slice window (from end)
|
|
181
|
+
const end = entries.length - startOffset;
|
|
182
|
+
const start = Math.max(0, end - limit);
|
|
183
|
+
let recent = entries.slice(start, end > 0 ? end : undefined);
|
|
184
|
+
|
|
185
|
+
const rangeDesc = startOffset > 0
|
|
186
|
+
? `第 ${startOffset}~${startOffset + recent.length} 条`
|
|
187
|
+
: `最近 ${recent.length} 条`;
|
|
188
|
+
|
|
189
|
+
if (recent.length === 0) {
|
|
190
|
+
return {
|
|
191
|
+
text: `**Infoflow 日志** (${rangeDesc},来自 ${logPath.split("/").pop()})\n\n暂无相关日志。`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Apply total char limit: drop oldest entries until within budget
|
|
196
|
+
let totalChars = recent.reduce((sum, e) => sum + e.length + 1, 0);
|
|
197
|
+
let dropped = 0;
|
|
198
|
+
while (recent.length > 0 && totalChars > LOGS_TOTAL_CHAR_LIMIT) {
|
|
199
|
+
totalChars -= recent[0].length + 1;
|
|
200
|
+
recent = recent.slice(1);
|
|
201
|
+
dropped++;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const title = `**Infoflow 日志** (${rangeDesc},来自 ${logPath.split("/").pop()})`;
|
|
205
|
+
const body = "```\n" + recent.join("\n") + "\n```";
|
|
206
|
+
const limitNote = dropped > 0
|
|
207
|
+
? `\n\n⚠️ 超出 ${LOGS_TOTAL_CHAR_LIMIT} 字符上限,已跳过最早 ${dropped} 条。`
|
|
208
|
+
: "";
|
|
209
|
+
|
|
210
|
+
// Long output is automatically chunked by the framework (textChunkLimit per message).
|
|
211
|
+
return { text: `${title}\n\n${body}${limitNote}` };
|
|
212
|
+
}
|