@jerryliang122/openclaw-qqbot 1.0.1 → 1.0.2
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/dist/index.cjs +7343 -7210
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/package.json +1 -1
- package/src/adapter/pairing.ts +14 -1
- package/src/adapter/webhook.ts +16 -3
- package/src/commands/bot-approve.ts +2 -2
- package/src/commands/bot-group-always.ts +1 -1
- package/src/commands/bot-streaming.ts +1 -1
- package/src/commands/config-util.ts +8 -0
- package/src/commands/index.ts +49 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/features/approval-capability.ts +19 -6
- package/src/features/credential-backup.ts +8 -3
- package/src/features/history-store.ts +8 -1
- package/src/features/quota-manager.ts +5 -2
- package/src/features/typing-refresh.ts +9 -1
- package/src/gateway/event-handlers.ts +36 -12
- package/src/gateway/lifecycle.ts +8 -0
- package/src/gateway/qqbot-gateway.ts +24 -1
- package/src/middleware/access-control.ts +3 -1
- package/src/middleware/secret-capture.ts +5 -3
- package/src/outbound/debounce.ts +19 -2
- package/src/outbound-adapter.ts +10 -1
- package/src/setup/finalize.ts +3 -2
- package/src/tools/platform.ts +8 -0
- package/src/tools/secret-input.ts +8 -0
- package/src/types-plugin.ts +1 -0
- package/src/typing-lifecycle.ts +9 -4
package/dist/index.d.cts
CHANGED
|
@@ -1203,6 +1203,8 @@ type Listener = () => void;
|
|
|
1203
1203
|
declare function subscribeOutboundMessage(accountId: string, scope: string, targetId: string, listener: Listener): () => void;
|
|
1204
1204
|
/**
|
|
1205
1205
|
* 消息发送成功后由出站层调用:通知该会话活跃的 typing 会话补发续期。
|
|
1206
|
+
* 单个监听器抛错只跳过该监听器——此处位于网关发送成功路径上,
|
|
1207
|
+
* 不能让一个坏的订阅方炸掉整条 send 链。
|
|
1206
1208
|
*/
|
|
1207
1209
|
declare function notifyOutboundMessageSent(accountId: string, scope: string, targetId: string): void;
|
|
1208
1210
|
|
package/package.json
CHANGED
package/src/adapter/pairing.ts
CHANGED
|
@@ -12,6 +12,9 @@ import { spawn } from 'node:child_process';
|
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import { resolveOpenClawCli } from '../features/secret-store-cli.js';
|
|
15
|
+
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
16
|
+
|
|
17
|
+
const pairingLog = createPluginLogger({ prefix: '[pairing]' });
|
|
15
18
|
|
|
16
19
|
export interface PairingApi {
|
|
17
20
|
readAllowFromStore: (params: { channel: string; accountId: string }) => Promise<string[]>;
|
|
@@ -64,6 +67,7 @@ export function getPairingApi(): PairingApi {
|
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
const APPROVE_TIMEOUT_MS = 30_000;
|
|
70
|
+
const STDERR_TAIL_LIMIT = 500;
|
|
67
71
|
|
|
68
72
|
async function approveViaCli(params: {
|
|
69
73
|
channel: string;
|
|
@@ -82,13 +86,22 @@ async function approveViaCli(params: {
|
|
|
82
86
|
];
|
|
83
87
|
return new Promise((resolve) => {
|
|
84
88
|
const child = spawn(cli.cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
89
|
+
// 收集 stderr 尾部:spawn 失败/非零退出时区分「CLI 坏了」与「码无效」
|
|
90
|
+
let stderrTail = '';
|
|
91
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
92
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_LIMIT);
|
|
93
|
+
});
|
|
85
94
|
const timer = setTimeout(() => child.kill('SIGKILL'), APPROVE_TIMEOUT_MS);
|
|
86
|
-
child.on('error', () => {
|
|
95
|
+
child.on('error', (err) => {
|
|
87
96
|
clearTimeout(timer);
|
|
97
|
+
pairingLog.warn(`approve CLI spawn failed: ${err.message}`, { cmd: cli.cmd });
|
|
88
98
|
resolve({ approved: false });
|
|
89
99
|
});
|
|
90
100
|
child.on('close', (codeNum) => {
|
|
91
101
|
clearTimeout(timer);
|
|
102
|
+
if (codeNum !== 0) {
|
|
103
|
+
pairingLog.warn(`approve CLI exited ${codeNum}`, { channel: params.channel, accountId: params.accountId, stderr: stderrTail || undefined });
|
|
104
|
+
}
|
|
92
105
|
resolve({ approved: codeNum === 0 });
|
|
93
106
|
});
|
|
94
107
|
});
|
package/src/adapter/webhook.ts
CHANGED
|
@@ -97,7 +97,12 @@ export function createPluginWebhookAdapter(params: {
|
|
|
97
97
|
function createSharedHandler(path: string, log: PluginLogger) {
|
|
98
98
|
return async (req: any, res: any) => {
|
|
99
99
|
try {
|
|
100
|
-
const ingress = await import('openclaw/plugin-sdk/webhook-ingress').catch(() =>
|
|
100
|
+
const ingress = await import('openclaw/plugin-sdk/webhook-ingress').catch((err) => {
|
|
101
|
+
// 管线缺失会让所有请求降级到 handleSimple(无签名匹配多账号路由),
|
|
102
|
+
// 此前完全无痕迹——至少留一条 WARN 说明降级发生及原因
|
|
103
|
+
log.warn?.(`[webhook] ingress pipeline unavailable, downgrading to simple handler: ${err instanceof Error ? err.message : String(err)}`);
|
|
104
|
+
return null;
|
|
105
|
+
});
|
|
101
106
|
|
|
102
107
|
if (!ingress?.withResolvedWebhookRequestPipeline) {
|
|
103
108
|
await handleSimple(req, res, path, log);
|
|
@@ -208,14 +213,20 @@ async function delegateToHandler(handler: WebhookRequestHandler, req: any, res:
|
|
|
208
213
|
async function handleSimple(req: any, res: any, path: string, log: PluginLogger) {
|
|
209
214
|
try {
|
|
210
215
|
const ct = String(req.headers['content-type'] ?? '');
|
|
211
|
-
if (!ct.includes('application/json')) {
|
|
216
|
+
if (!ct.includes('application/json')) {
|
|
217
|
+
log.debug?.(`[webhook] simple ${req.method ?? '?'} ${path} -> 400 (unsupported content type)`);
|
|
218
|
+
res.statusCode = 400; res.end(JSON.stringify({ error: 'unsupported content type' })); return;
|
|
219
|
+
}
|
|
212
220
|
|
|
213
221
|
const chunks: Buffer[] = [];
|
|
214
222
|
let total = 0;
|
|
215
223
|
for await (const chunk of req) {
|
|
216
224
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
217
225
|
total += buf.length;
|
|
218
|
-
if (total > 1_048_576) {
|
|
226
|
+
if (total > 1_048_576) {
|
|
227
|
+
log.debug?.(`[webhook] simple ${req.method ?? '?'} ${path} -> 413 (too large)`);
|
|
228
|
+
res.statusCode = 413; res.end(JSON.stringify({ error: 'too large' })); return;
|
|
229
|
+
}
|
|
219
230
|
chunks.push(buf);
|
|
220
231
|
}
|
|
221
232
|
const rawBody = Buffer.concat(chunks);
|
|
@@ -223,12 +234,14 @@ async function handleSimple(req: any, res: any, path: string, log: PluginLogger)
|
|
|
223
234
|
for (const entry of entries) {
|
|
224
235
|
const resp = await entry.handler({ body: rawBody, headers: mapHeaders(req) });
|
|
225
236
|
if (resp.status < 400) {
|
|
237
|
+
log.debug?.(`[webhook] simple ${req.method ?? '?'} ${path} -> ${resp.status} (account=${entry.accountId})`);
|
|
226
238
|
res.statusCode = resp.status;
|
|
227
239
|
if (resp.headers) for (const [k, v] of Object.entries(resp.headers)) res.setHeader(k, v as string);
|
|
228
240
|
res.end(resp.body);
|
|
229
241
|
return;
|
|
230
242
|
}
|
|
231
243
|
}
|
|
244
|
+
log.warn?.(`[webhook] simple ${req.method ?? '?'} ${path} -> 401 (no target matched signature)`);
|
|
232
245
|
res.statusCode = 401; res.end(JSON.stringify({ error: 'invalid signature' }));
|
|
233
246
|
} catch (err) {
|
|
234
247
|
log.error(`Webhook simple handler error: ${(err as Error).message}`);
|
|
@@ -105,7 +105,7 @@ export function botApprove(getRuntime: () => PluginRuntime): SlashCommand {
|
|
|
105
105
|
cfg.tools.exec ??= {};
|
|
106
106
|
cfg.tools.exec.security = preset.security;
|
|
107
107
|
cfg.tools.exec.ask = preset.ask;
|
|
108
|
-
});
|
|
108
|
+
}, ctx.log);
|
|
109
109
|
if (error) return error;
|
|
110
110
|
|
|
111
111
|
if (arg === 'on') {
|
|
@@ -127,7 +127,7 @@ export function botApprove(getRuntime: () => PluginRuntime): SlashCommand {
|
|
|
127
127
|
if (Object.keys(exec).length === 0) delete cfg.tools.exec;
|
|
128
128
|
if (cfg.tools && Object.keys(cfg.tools).length === 0) delete cfg.tools;
|
|
129
129
|
}
|
|
130
|
-
});
|
|
130
|
+
}, ctx.log);
|
|
131
131
|
if (error) return error;
|
|
132
132
|
|
|
133
133
|
return ['✅ 审批配置已重置', '', '已移除 tools.exec.security 和 tools.exec.ask', '框架将使用默认值(security=deny, ask=on-miss)', '', '如需开启命令执行,请使用 /bot-approve on'].join('\n');
|
|
@@ -44,7 +44,7 @@ export function botGroupAlways(account: ResolvedQQBotAccount, getRuntime: () =>
|
|
|
44
44
|
|
|
45
45
|
const error = await updateAccountConfig(account, getRuntime, (acfg) => {
|
|
46
46
|
(acfg as any).defaultRequireMention = newRequireMention;
|
|
47
|
-
});
|
|
47
|
+
}, ctx.log);
|
|
48
48
|
if (error) return error;
|
|
49
49
|
|
|
50
50
|
// 更新内存中的配置
|
|
@@ -43,7 +43,7 @@ export function botStreaming(account: ResolvedQQBotAccount, getRuntime: () => Pl
|
|
|
43
43
|
|
|
44
44
|
const error = await updateAccountConfig(account, getRuntime, (acfg) => {
|
|
45
45
|
(acfg as any).streaming = { mode: targetEnabled ? 'partial' : 'off' };
|
|
46
|
-
});
|
|
46
|
+
}, ctx.log);
|
|
47
47
|
if (error) return error;
|
|
48
48
|
|
|
49
49
|
account.config.streaming = { mode: targetEnabled ? 'partial' : 'off' };
|
|
@@ -3,6 +3,10 @@ import type { SlashCommandHandlerContext } from '@tencent-connect/qqbot-nodejs';
|
|
|
3
3
|
import type { PluginRuntime } from 'openclaw/plugin-sdk';
|
|
4
4
|
import { getAdapters } from '../adapter/resolve.js';
|
|
5
5
|
|
|
6
|
+
// 最小结构类型:同时兼容 PluginLogger 与 SDK MiddlewareContext 的 Logger
|
|
7
|
+
// (后者的 warn/debug 是可选方法且无 child)——与 quota-manager 的 log 参数同款
|
|
8
|
+
type WarnSink = { warn?: (msg: string, meta?: Record<string, unknown>) => void };
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
11
|
* 命令授权检查(用于 SlashCommand.authorized 回调)。
|
|
8
12
|
*
|
|
@@ -50,6 +54,7 @@ export async function updateAccountConfig(
|
|
|
50
54
|
account: ResolvedQQBotAccount,
|
|
51
55
|
getRuntime: () => PluginRuntime,
|
|
52
56
|
updater: (accountConfig: Record<string, unknown>) => void,
|
|
57
|
+
log?: WarnSink,
|
|
53
58
|
): Promise<string | null> {
|
|
54
59
|
const [err, result] = await resolvePersistFn(getRuntime);
|
|
55
60
|
if (err || !result) return err;
|
|
@@ -72,6 +77,7 @@ export async function updateAccountConfig(
|
|
|
72
77
|
});
|
|
73
78
|
return null;
|
|
74
79
|
} catch (e) {
|
|
80
|
+
log?.warn?.(`[cmd] account config persist failed: ${e instanceof Error ? e.message : String(e)}`, { accountId: account.accountId });
|
|
75
81
|
return `⚠️ 配置修改失败:${e instanceof Error ? e.message : String(e)}`;
|
|
76
82
|
}
|
|
77
83
|
}
|
|
@@ -83,6 +89,7 @@ export async function updateAccountConfig(
|
|
|
83
89
|
export async function updateGlobalConfig(
|
|
84
90
|
getRuntime: () => PluginRuntime,
|
|
85
91
|
updater: (cfg: any) => void,
|
|
92
|
+
log?: WarnSink,
|
|
86
93
|
): Promise<string | null> {
|
|
87
94
|
const [err, result] = await resolvePersistFn(getRuntime);
|
|
88
95
|
if (err || !result) return err;
|
|
@@ -91,6 +98,7 @@ export async function updateGlobalConfig(
|
|
|
91
98
|
await result.persist(updater);
|
|
92
99
|
return null;
|
|
93
100
|
} catch (e) {
|
|
101
|
+
log?.warn?.(`[cmd] global config persist failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
94
102
|
return `⚠️ 配置修改失败:${e instanceof Error ? e.message : String(e)}`;
|
|
95
103
|
}
|
|
96
104
|
}
|
package/src/commands/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 通过 SDK 的 slashCommand 中间件统一注册所有内置命令。
|
|
5
5
|
* 每个命令拆分为独立文件,此处仅编排。
|
|
6
6
|
*/
|
|
7
|
-
import type { SlashCommand } from '@tencent-connect/qqbot-nodejs';
|
|
7
|
+
import type { SlashCommand, SlashCommandHandlerContext } from '@tencent-connect/qqbot-nodejs';
|
|
8
8
|
import type { PluginRuntime } from 'openclaw/plugin-sdk';
|
|
9
9
|
import type { ResolvedQQBotAccount } from '../types.js';
|
|
10
10
|
import { botHelp } from './bot-help.js';
|
|
@@ -23,6 +23,43 @@ export interface CommandBuildOptions {
|
|
|
23
23
|
getRuntime: () => PluginRuntime;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// ========== 命令执行审计 ==========
|
|
27
|
+
|
|
28
|
+
const CMD_AUDIT_RAW_LIMIT = 80;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 审计包装:每个 /bot-* 调用留一条 [cmd] INFO(命令、执行者、scope、
|
|
32
|
+
* 原始输入截断),授权拒绝留 [cmd] WARN。SDK 的 slashCommand 中间件只在
|
|
33
|
+
* handler 抛错时输出 ERROR——成功执行与越权尝试此前零痕迹,含
|
|
34
|
+
* /bot-clear-storage(删存储)、/bot-approve(安全相关)这类操作。
|
|
35
|
+
*
|
|
36
|
+
* ctx.log 即网关注入 SDK 的 PluginLogger(带账号前缀与 request-context
|
|
37
|
+
* 元数据);warn/debug 在 SDK Logger 接口上是可选方法,须可选链调用。
|
|
38
|
+
*/
|
|
39
|
+
export function withAudit(cmd: SlashCommand): SlashCommand {
|
|
40
|
+
const name = Array.isArray(cmd.name) ? cmd.name.join('|') : cmd.name;
|
|
41
|
+
const origHandler = cmd.handler;
|
|
42
|
+
const origAuthorized = cmd.authorized;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
...cmd,
|
|
46
|
+
authorized: origAuthorized
|
|
47
|
+
? (ctx: SlashCommandHandlerContext) => {
|
|
48
|
+
const verdict = origAuthorized(ctx);
|
|
49
|
+
if (verdict !== true) {
|
|
50
|
+
ctx.log?.warn?.(`[cmd] unauthorized ${name} sender=${ctx.message?.senderId ?? '?'} scope=${ctx.message?.kind ?? '?'}`);
|
|
51
|
+
}
|
|
52
|
+
return verdict;
|
|
53
|
+
}
|
|
54
|
+
: undefined,
|
|
55
|
+
handler: async (ctx: SlashCommandHandlerContext) => {
|
|
56
|
+
const raw = (ctx.command?.raw ?? '').slice(0, CMD_AUDIT_RAW_LIMIT);
|
|
57
|
+
ctx.log?.info?.(`[cmd] ${name} sender=${ctx.message?.senderId ?? '?'} scope=${ctx.message?.kind ?? '?'} raw="${raw}"`);
|
|
58
|
+
return origHandler(ctx);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
/**
|
|
27
64
|
* 构建标准命令列表(匹配后直接回复,不进入 AI)
|
|
28
65
|
*/
|
|
@@ -32,17 +69,17 @@ export function buildCommandList(account: ResolvedQQBotAccount, opts: CommandBui
|
|
|
32
69
|
// help 需要访问完整命令列表,延迟绑定
|
|
33
70
|
const help = botHelp(account, () => commands);
|
|
34
71
|
commands.push(
|
|
35
|
-
help,
|
|
36
|
-
botPing(),
|
|
37
|
-
botVersion(account),
|
|
38
|
-
botMe(),
|
|
39
|
-
botLogs(opts.getRuntime()),
|
|
40
|
-
botStreaming(account, opts.getRuntime),
|
|
41
|
-
botClearStorage(account),
|
|
42
|
-
botApprove(opts.getRuntime),
|
|
43
|
-
botGroupAlways(account, opts.getRuntime),
|
|
44
|
-
botGroupInfo(account),
|
|
45
|
-
botPairing(opts.getRuntime),
|
|
72
|
+
withAudit(help),
|
|
73
|
+
withAudit(botPing()),
|
|
74
|
+
withAudit(botVersion(account)),
|
|
75
|
+
withAudit(botMe()),
|
|
76
|
+
withAudit(botLogs(opts.getRuntime())),
|
|
77
|
+
withAudit(botStreaming(account, opts.getRuntime)),
|
|
78
|
+
withAudit(botClearStorage(account)),
|
|
79
|
+
withAudit(botApprove(opts.getRuntime)),
|
|
80
|
+
withAudit(botGroupAlways(account, opts.getRuntime)),
|
|
81
|
+
withAudit(botGroupInfo(account)),
|
|
82
|
+
withAudit(botPairing(opts.getRuntime)),
|
|
46
83
|
);
|
|
47
84
|
|
|
48
85
|
return commands;
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -206,7 +206,7 @@ export async function dispatchToOpenClaw(
|
|
|
206
206
|
? new DeliverDebouncer(debounceConfig, async (targetId, mergedText) => {
|
|
207
207
|
const result = await sendText({ to: targetId, text: mergedText, accountId: account.accountId, replyToId: envelope.messageId, account });
|
|
208
208
|
trackOutbound(result, 'debounce');
|
|
209
|
-
})
|
|
209
|
+
}, dlog)
|
|
210
210
|
: undefined;
|
|
211
211
|
|
|
212
212
|
const deliverCtx: DeliverContext = {
|
|
@@ -33,6 +33,11 @@ import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount } from "../config.js";
|
|
|
33
33
|
import { tryGetBotForAccount } from "../bot-instance.js";
|
|
34
34
|
import { getQQBotRuntime } from "../runtime.js";
|
|
35
35
|
import { getAdapters } from "../adapter/resolve.js";
|
|
36
|
+
import { createPluginLogger } from "../utils/plugin-logger.js";
|
|
37
|
+
|
|
38
|
+
// deliverPending 由框架 approval-delivery-runtime 回调,无 logger 参数可传;
|
|
39
|
+
// 审批卡投递的成功/失败此前只在失败时抛给框架,成功零痕迹
|
|
40
|
+
const approvalLog = createPluginLogger({ prefix: "[approval]" });
|
|
36
41
|
import type { InlineKeyboard, ResolvedQQBotAccount } from "../types.js";
|
|
37
42
|
import {
|
|
38
43
|
buildApprovalKeyboard,
|
|
@@ -193,6 +198,7 @@ const qqbotApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
|
|
193
198
|
accountId && accountId !== DEFAULT_ACCOUNT_ID ? accountId : DEFAULT_ACCOUNT_ID;
|
|
194
199
|
const bot = tryGetBotForAccount(resolvedAccountId);
|
|
195
200
|
if (!bot) {
|
|
201
|
+
approvalLog.error(`card deliver failed: bot unavailable account=${resolvedAccountId} target=${preparedTarget.type}:${preparedTarget.id}`);
|
|
196
202
|
throw new Error(
|
|
197
203
|
`QQ Bot gateway not running for account "${resolvedAccountId}" — cannot deliver approval`,
|
|
198
204
|
);
|
|
@@ -202,12 +208,19 @@ const qqbotApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
|
|
202
208
|
scope: preparedTarget.type as "c2c" | "group",
|
|
203
209
|
targetId: preparedTarget.id,
|
|
204
210
|
};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
let result: { id?: string } | undefined;
|
|
212
|
+
try {
|
|
213
|
+
result = await bot.sendTextWithKeyboard(
|
|
214
|
+
replyTarget,
|
|
215
|
+
pendingPayload.text,
|
|
216
|
+
pendingPayload.keyboard as never,
|
|
217
|
+
);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
approvalLog.error(`card deliver failed: ${err instanceof Error ? err.message : String(err)}`, { account: resolvedAccountId, target: `${preparedTarget.type}:${preparedTarget.id}` });
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
const messageId = result?.id;
|
|
223
|
+
approvalLog.info(`card delivered account=${resolvedAccountId} target=${preparedTarget.type}:${preparedTarget.id} msgId=${messageId ?? '-'}`);
|
|
211
224
|
return {
|
|
212
225
|
messageId,
|
|
213
226
|
targetType: preparedTarget.type,
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { getQQBotDataDir } from '../utils/platform.js';
|
|
12
|
+
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
13
|
+
|
|
14
|
+
const backupLog = createPluginLogger({ prefix: '[credential-backup]' });
|
|
12
15
|
|
|
13
16
|
const BACKUP_DIR = 'credential-backup';
|
|
14
17
|
const BACKUP_FILENAME = 'current.json';
|
|
@@ -44,8 +47,9 @@ export function saveCredentialBackup(accountId: string, appId: string, clientSec
|
|
|
44
47
|
const tmpPath = backupPath + '.tmp';
|
|
45
48
|
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
46
49
|
fs.renameSync(tmpPath, backupPath);
|
|
47
|
-
} catch {
|
|
48
|
-
//
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// 非关键操作,不中断启动;DEBUG 留痕(恢复失败路径在 lifecycle 已有 ERROR)
|
|
52
|
+
backupLog.debug(`save failed: ${err instanceof Error ? err.message : String(err)}`, { accountId });
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -60,7 +64,8 @@ export function loadCredentialBackup(accountId?: string): CredentialBackup | nul
|
|
|
60
64
|
return readBackupFile(backupPath, accountId);
|
|
61
65
|
}
|
|
62
66
|
return null;
|
|
63
|
-
} catch {
|
|
67
|
+
} catch (err) {
|
|
68
|
+
backupLog.debug(`load failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
69
|
return null;
|
|
65
70
|
}
|
|
66
71
|
}
|
|
@@ -40,7 +40,14 @@ export function recordOutboundToGroupHistory(
|
|
|
40
40
|
timestamp: Date.now(),
|
|
41
41
|
isBot: true,
|
|
42
42
|
};
|
|
43
|
-
|
|
43
|
+
// append 可能返回 Promise(异步后端);当前 MemoryHistoryStore 为同步,
|
|
44
|
+
// 但 rejected promise 若不捕获会成为 unhandledRejection——纯防御
|
|
45
|
+
const appended = _store?.append?.(historyGroupKey(accountId, groupOpenid), historyEntry, limit) as
|
|
46
|
+
| void
|
|
47
|
+
| Promise<void>;
|
|
48
|
+
if (appended && typeof (appended as Promise<void>).catch === 'function') {
|
|
49
|
+
(appended as Promise<void>).catch(() => {/* 历史缓冲是尽力而为,失败不影响主流程 */});
|
|
50
|
+
}
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
@@ -85,6 +85,7 @@ export function checkAndConsumePassiveReplyQuota(
|
|
|
85
85
|
const { accountId, msgId, scope, log } = params;
|
|
86
86
|
|
|
87
87
|
if (!msgId) {
|
|
88
|
+
log?.debug?.(`[${accountId}] passive quota check without msgId (proactive path) scope=${scope}`);
|
|
88
89
|
return { canReply: false, rollback: () => {} };
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -93,17 +94,19 @@ export function checkAndConsumePassiveReplyQuota(
|
|
|
93
94
|
const ttl = QUOTA_LIMITS[scope].ttlMs;
|
|
94
95
|
|
|
95
96
|
let cached = quotaCache.get(key);
|
|
96
|
-
|
|
97
|
+
|
|
97
98
|
// 检查过期:msg_id 过期后不能用于被动回复
|
|
98
99
|
// 这是 QQ Bot 平台限制,API 会返回错误 40034128
|
|
99
100
|
if (cached && now > cached.expiresAt) {
|
|
100
|
-
// msg_id
|
|
101
|
+
// msg_id 已过期,不能用于被动回复(降级决策的日志在 adapter/gateway 层)
|
|
102
|
+
log?.debug?.(`[${accountId}] passive quota denied (expired): ${key}`);
|
|
101
103
|
return { canReply: false, rollback: () => {} };
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
// 检查配额
|
|
105
107
|
const limit = QUOTA_LIMITS[scope].count;
|
|
106
108
|
if (cached && cached.count >= limit) {
|
|
109
|
+
log?.debug?.(`[${accountId}] passive quota denied (exhausted): ${key} count=${cached.count}/${limit}`);
|
|
107
110
|
return { canReply: false, rollback: () => {} };
|
|
108
111
|
}
|
|
109
112
|
|
|
@@ -43,9 +43,17 @@ export function subscribeOutboundMessage(
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* 消息发送成功后由出站层调用:通知该会话活跃的 typing 会话补发续期。
|
|
46
|
+
* 单个监听器抛错只跳过该监听器——此处位于网关发送成功路径上,
|
|
47
|
+
* 不能让一个坏的订阅方炸掉整条 send 链。
|
|
46
48
|
*/
|
|
47
49
|
export function notifyOutboundMessageSent(accountId: string, scope: string, targetId: string): void {
|
|
48
50
|
const set = listeners.get(key(accountId, scope, targetId));
|
|
49
51
|
if (!set) return;
|
|
50
|
-
for (const listener of [...set])
|
|
52
|
+
for (const listener of [...set]) {
|
|
53
|
+
try {
|
|
54
|
+
listener();
|
|
55
|
+
} catch {
|
|
56
|
+
// 监听器自身的问题不应影响发送方与其他监听器
|
|
57
|
+
}
|
|
58
|
+
}
|
|
51
59
|
}
|
|
@@ -122,8 +122,13 @@ export async function handleInteraction(
|
|
|
122
122
|
const requireMention = updatedCfg?.requireMention ?? true;
|
|
123
123
|
const clawCfg = buildClawCfg(requireMention, [], resolveGroupPolicy(cfg, account.accountId));
|
|
124
124
|
await acknowledgeInteraction(event.id, 0, { claw_cfg: clawCfg });
|
|
125
|
-
} catch {
|
|
126
|
-
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log.warn(`interaction update ack failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
try {
|
|
128
|
+
await acknowledgeInteraction(event.id);
|
|
129
|
+
} catch (retryErr) {
|
|
130
|
+
log.debug(`interaction update ack retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
131
|
+
}
|
|
127
132
|
}
|
|
128
133
|
return;
|
|
129
134
|
}
|
|
@@ -162,7 +167,11 @@ async function handleConfigQuery(
|
|
|
162
167
|
await ack(event.id, 0, { claw_cfg: clawCfg });
|
|
163
168
|
} catch (err) {
|
|
164
169
|
log.warn(`interaction query failed: ${(err as Error)?.message ?? err}, ack without data`);
|
|
165
|
-
try {
|
|
170
|
+
try {
|
|
171
|
+
await ack(event.id);
|
|
172
|
+
} catch (retryErr) {
|
|
173
|
+
log.debug(`interaction query ack retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
174
|
+
}
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
177
|
|
|
@@ -193,7 +202,9 @@ async function handleApproval(
|
|
|
193
202
|
log: PluginLogger,
|
|
194
203
|
ack: (id: string) => Promise<void>,
|
|
195
204
|
): Promise<void> {
|
|
196
|
-
try { await ack(event.id); } catch {
|
|
205
|
+
try { await ack(event.id); } catch (err) {
|
|
206
|
+
log.debug(`[approval] initial ack failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
207
|
+
}
|
|
197
208
|
|
|
198
209
|
const buttonData = event.data?.resolved?.button_data;
|
|
199
210
|
if (!buttonData?.startsWith('approve:')) return;
|
|
@@ -218,6 +229,10 @@ async function handleApproval(
|
|
|
218
229
|
return;
|
|
219
230
|
}
|
|
220
231
|
|
|
232
|
+
log.info(
|
|
233
|
+
`[approval] tap operator=${operatorId ?? 'unknown'} approvalId=${parsed.approvalId} kind=${parsed.approvalKind} decision=${parsed.decision} account=${account.accountId}`,
|
|
234
|
+
);
|
|
235
|
+
|
|
221
236
|
try {
|
|
222
237
|
const { resolveApprovalOverGateway } = await import('openclaw/plugin-sdk/approval-gateway-runtime');
|
|
223
238
|
await resolveApprovalOverGateway({
|
|
@@ -228,6 +243,9 @@ async function handleApproval(
|
|
|
228
243
|
senderId: operatorId,
|
|
229
244
|
clientDisplayName: 'QQBot Approval Handler',
|
|
230
245
|
});
|
|
246
|
+
log.info(
|
|
247
|
+
`[approval] resolved approvalId=${parsed.approvalId} decision=${parsed.decision} operator=${operatorId ?? 'unknown'}`,
|
|
248
|
+
);
|
|
231
249
|
} catch (err) {
|
|
232
250
|
log.error(`interaction approve error: ${err instanceof Error ? err.message : String(err)}`);
|
|
233
251
|
}
|
|
@@ -242,7 +260,9 @@ async function handleQuestion(
|
|
|
242
260
|
log: PluginLogger,
|
|
243
261
|
ack: (id: string) => Promise<void>,
|
|
244
262
|
): Promise<void> {
|
|
245
|
-
try { await ack(event.id); } catch {
|
|
263
|
+
try { await ack(event.id); } catch (err) {
|
|
264
|
+
log.debug(`[question] initial ack failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
265
|
+
}
|
|
246
266
|
|
|
247
267
|
const buttonData = event.data?.resolved?.button_data;
|
|
248
268
|
if (!buttonData) return;
|
|
@@ -270,7 +290,7 @@ async function handleQuestion(
|
|
|
270
290
|
senderId: operatorId,
|
|
271
291
|
clientDisplayName: 'QQBot question',
|
|
272
292
|
});
|
|
273
|
-
log.
|
|
293
|
+
log.info(`[question] resolved questionId=${parsed.questionId} optionIndex=${parsed.optionIndex} status=${result.status} operator=${operatorId ?? 'unknown'}`);
|
|
274
294
|
} catch (err) {
|
|
275
295
|
log.error(`[question] resolve error: ${err instanceof Error ? err.message : String(err)}`);
|
|
276
296
|
}
|
|
@@ -308,7 +328,7 @@ async function handleMultiQuestionTap(
|
|
|
308
328
|
|
|
309
329
|
if (tap.status === 'unknown' || tap.status === 'terminal') {
|
|
310
330
|
log.info(`[question] multi tap ignored id=${parsed.questionId} status=${tap.status}`);
|
|
311
|
-
await sendMultiQuestionFeedback(event, account, '该问题已提交或已过期');
|
|
331
|
+
await sendMultiQuestionFeedback(event, account, '该问题已提交或已过期', log);
|
|
312
332
|
return;
|
|
313
333
|
}
|
|
314
334
|
if (tap.status === 'resolving') {
|
|
@@ -322,6 +342,7 @@ async function handleMultiQuestionTap(
|
|
|
322
342
|
event,
|
|
323
343
|
account,
|
|
324
344
|
`✅ 已记录 ${tap.answeredCount}/${tap.total}\n还剩:${pendingTitles.join('、')}`,
|
|
345
|
+
log,
|
|
325
346
|
);
|
|
326
347
|
return;
|
|
327
348
|
}
|
|
@@ -363,7 +384,7 @@ async function sendMultiQuestionConfirmCard(
|
|
|
363
384
|
if (!bot) {
|
|
364
385
|
markMultiQuestionResolveFailed(questionId);
|
|
365
386
|
log.error(`[question] confirm card send failed: bot unavailable id=${questionId}`);
|
|
366
|
-
await sendMultiQuestionFeedbackText(scope, peerId, account, '⚠️ 提交卡发送失败,请直接文字回复答案');
|
|
387
|
+
await sendMultiQuestionFeedbackText(scope, peerId, account, '⚠️ 提交卡发送失败,请直接文字回复答案', log);
|
|
367
388
|
return;
|
|
368
389
|
}
|
|
369
390
|
try {
|
|
@@ -373,7 +394,7 @@ async function sendMultiQuestionConfirmCard(
|
|
|
373
394
|
} catch (err) {
|
|
374
395
|
markMultiQuestionResolveFailed(questionId);
|
|
375
396
|
log.error(`[question] confirm card send error id=${questionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
376
|
-
await sendMultiQuestionFeedbackText(scope, peerId, account, '⚠️ 提交卡发送失败,请直接文字回复答案');
|
|
397
|
+
await sendMultiQuestionFeedbackText(scope, peerId, account, '⚠️ 提交卡发送失败,请直接文字回复答案', log);
|
|
377
398
|
}
|
|
378
399
|
}
|
|
379
400
|
|
|
@@ -382,11 +403,12 @@ async function sendMultiQuestionFeedback(
|
|
|
382
403
|
event: InteractionEvent,
|
|
383
404
|
account: ResolvedQQBotAccount,
|
|
384
405
|
text: string,
|
|
406
|
+
log?: PluginLogger,
|
|
385
407
|
): Promise<void> {
|
|
386
408
|
const scope = event.group_openid ? 'group' as const : 'c2c' as const;
|
|
387
409
|
const targetId = event.group_openid ?? event.user_openid;
|
|
388
410
|
if (!targetId) return;
|
|
389
|
-
await sendMultiQuestionFeedbackText(scope, targetId, account, text);
|
|
411
|
+
await sendMultiQuestionFeedbackText(scope, targetId, account, text, log);
|
|
390
412
|
}
|
|
391
413
|
|
|
392
414
|
async function sendMultiQuestionFeedbackText(
|
|
@@ -394,13 +416,15 @@ async function sendMultiQuestionFeedbackText(
|
|
|
394
416
|
targetId: string,
|
|
395
417
|
account: ResolvedQQBotAccount,
|
|
396
418
|
text: string,
|
|
419
|
+
log?: PluginLogger,
|
|
397
420
|
): Promise<void> {
|
|
398
421
|
const bot = tryGetBotForAccount(account.accountId);
|
|
399
422
|
if (!bot) return;
|
|
400
423
|
try {
|
|
401
424
|
await bot.sendText({ scope, targetId }, text);
|
|
402
|
-
} catch {
|
|
403
|
-
//
|
|
425
|
+
} catch (err) {
|
|
426
|
+
// 主动消息可能触发平台限频;回执是锦上添花,吞掉即可(仅 DEBUG 留痕)
|
|
427
|
+
log?.debug(`[question] feedback send failed scope=${scope}: ${err instanceof Error ? err.message : String(err)}`);
|
|
404
428
|
}
|
|
405
429
|
}
|
|
406
430
|
|
package/src/gateway/lifecycle.ts
CHANGED
|
@@ -73,6 +73,14 @@ export async function startAccountWithCredentialRecovery(ctx: StartAccountContex
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// 账号启动留痕:哪个账号、以什么凭证来源、什么传输模式启动了
|
|
77
|
+
// (secret 只打来源不打值)——多账号排障时定位配置实际生效形态
|
|
78
|
+
log.info(
|
|
79
|
+
`account starting accountId=${account.accountId} appId=${account.appId || '(none)'}`
|
|
80
|
+
+ ` enabled=${account.enabled ?? true} secretSource=${account.secretSource ?? '?'}`
|
|
81
|
+
+ ` transport=${account.config.transport}${account.config.transport === 'webhook' ? ` path=${account.config.webhook?.path ?? '(default)'}` : ''}`,
|
|
82
|
+
);
|
|
83
|
+
|
|
76
84
|
// 创建 gateway 实例并注册
|
|
77
85
|
const gw = new QQBotGateway(account, runtime, log);
|
|
78
86
|
registerGateway(account.accountId, gw);
|
|
@@ -306,6 +306,25 @@ export class QQBotGateway {
|
|
|
306
306
|
const { accountId, appId } = this.account;
|
|
307
307
|
const senderName = this.account.config.name ?? appId;
|
|
308
308
|
|
|
309
|
+
// 出站成功的统一 INFO 痕迹:gateway 的 sendText/sendMedia/sendVoice/sendVideo/
|
|
310
|
+
// sendFile 与中间件对 bot.sendText 的直调全部落到这两个被包装的方法上。
|
|
311
|
+
// 此前成功发送在插件层零日志,只能靠 SDK 的 `<<< Status` 状态行倒推。
|
|
312
|
+
const MEDIA_KIND_BY_TYPE = new Map<number, string>([
|
|
313
|
+
[1, 'image'], [2, 'video'], [3, 'voice'], [4, 'file'],
|
|
314
|
+
]);
|
|
315
|
+
const logTx = (
|
|
316
|
+
kind: string,
|
|
317
|
+
target: { scope?: string; targetId?: string; msgId?: string },
|
|
318
|
+
msg: { id?: string },
|
|
319
|
+
contentLen?: number,
|
|
320
|
+
): void => {
|
|
321
|
+
this.log.info(
|
|
322
|
+
`[tx] sent kind=${kind} scope=${target.scope ?? '?'} target=${target.targetId ?? '?'}`
|
|
323
|
+
+ ` msgId=${msg.id || '-'} passive=${target.msgId ? 'true' : 'false'}`
|
|
324
|
+
+ (contentLen !== undefined ? ` contentLen=${contentLen}` : ''),
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
309
328
|
const storeEntry = (msg: MessageResponse, content: string, target: { scope: string; targetId?: string }, mediaKind?: string): void => {
|
|
310
329
|
// 出站 id 先登记回声表(不依赖 ext_info.ref_idx 是否返回),
|
|
311
330
|
// 供 inboundGuard 识别平台回投的 bot 自身消息
|
|
@@ -352,6 +371,7 @@ export class QQBotGateway {
|
|
|
352
371
|
const origSendText = this.bot.sendText.bind(this.bot);
|
|
353
372
|
this.bot.sendText = async (target, text, ...rest) => {
|
|
354
373
|
const result = await origSendText(target, text, ...rest);
|
|
374
|
+
logTx('text', target, result, text.length);
|
|
355
375
|
storeEntry(result, text, target);
|
|
356
376
|
return result;
|
|
357
377
|
};
|
|
@@ -361,7 +381,10 @@ export class QQBotGateway {
|
|
|
361
381
|
this.bot.sendMedia = async (params: any) => {
|
|
362
382
|
const result = await origSendMedia(params);
|
|
363
383
|
const msg = (result as any).message as MessageResponse | undefined;
|
|
364
|
-
if (msg)
|
|
384
|
+
if (msg) {
|
|
385
|
+
logTx(MEDIA_KIND_BY_TYPE.get(params.fileType) ?? 'media', params.target ?? {}, msg);
|
|
386
|
+
storeEntry(msg, '', params.target ?? { scope: '' }, params.mediaKind);
|
|
387
|
+
}
|
|
365
388
|
return result;
|
|
366
389
|
};
|
|
367
390
|
|
|
@@ -116,7 +116,9 @@ async function checkPairingMode(
|
|
|
116
116
|
].join('\n');
|
|
117
117
|
|
|
118
118
|
ctx.log?.info?.(`[access] pairing required for ${opts.senderId}`);
|
|
119
|
-
await ctx.bot.sendText(ctx.replyTarget, reply).catch(() => {
|
|
119
|
+
await ctx.bot.sendText(ctx.replyTarget, reply).catch((err: unknown) => {
|
|
120
|
+
ctx.log?.warn?.(`[access] pairing challenge send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
121
|
+
});
|
|
120
122
|
ctx.stop('access:pairing_required');
|
|
121
123
|
} catch (err) {
|
|
122
124
|
ctx.log?.error?.(`[access] pairing error: ${(err as Error).message}`);
|