@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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jerryliang122/openclaw-qqbot",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "QQ Bot channel plugin for OpenClaw (independently maintained fork)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -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
  });
@@ -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(() => null);
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')) { res.statusCode = 400; res.end(JSON.stringify({ error: 'unsupported content type' })); return; }
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) { res.statusCode = 413; res.end(JSON.stringify({ error: 'too large' })); return; }
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
  }
@@ -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;
@@ -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
- const result = await bot.sendTextWithKeyboard(
206
- replyTarget,
207
- pendingPayload.text,
208
- pendingPayload.keyboard as never,
209
- );
210
- const messageId = (result as { id?: string } | undefined)?.id;
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
- void _store?.append?.(historyGroupKey(accountId, groupOpenid), historyEntry, limit);
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]) listener();
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
- try { await acknowledgeInteraction(event.id); } catch { /* ignore */ }
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 { await ack(event.id); } catch { /* ignore */ }
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 { /* ignore */ }
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 { /* ignore */ }
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.debug(`[question] resolved questionId=${parsed.questionId} optionIndex=${parsed.optionIndex} status=${result.status}`);
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
 
@@ -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) storeEntry(msg, '', params.target ?? { scope: '' }, params.mediaKind);
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(() => {/* ignore */});
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}`);