@jerryliang122/openclaw-qqbot 1.0.0 → 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.
Files changed (39) hide show
  1. package/README.md +2 -13
  2. package/README.zh.md +2 -13
  3. package/dist/index.cjs +9371 -9414
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +2 -5
  6. package/index.ts +1 -1
  7. package/openclaw.plugin.json +1 -1
  8. package/package.json +1 -1
  9. package/src/adapter/pairing.ts +14 -1
  10. package/src/adapter/webhook.ts +16 -3
  11. package/src/commands/bot-approve.ts +2 -2
  12. package/src/commands/bot-group-always.ts +1 -1
  13. package/src/commands/bot-help.ts +1 -1
  14. package/src/commands/bot-streaming.ts +1 -1
  15. package/src/commands/bot-version.ts +1 -12
  16. package/src/commands/config-util.ts +8 -0
  17. package/src/commands/index.ts +49 -14
  18. package/src/dispatch/dispatch.ts +1 -1
  19. package/src/features/approval-capability.ts +19 -6
  20. package/src/features/credential-backup.ts +8 -3
  21. package/src/features/history-store.ts +8 -1
  22. package/src/features/quota-manager.ts +5 -2
  23. package/src/features/typing-refresh.ts +9 -1
  24. package/src/gateway/event-handlers.ts +36 -12
  25. package/src/gateway/lifecycle.ts +10 -6
  26. package/src/gateway/qqbot-gateway.ts +24 -1
  27. package/src/middleware/access-control.ts +3 -1
  28. package/src/middleware/secret-capture.ts +5 -3
  29. package/src/outbound/debounce.ts +19 -2
  30. package/src/outbound-adapter.ts +10 -1
  31. package/src/setup/finalize.ts +3 -2
  32. package/src/tools/platform.ts +8 -0
  33. package/src/tools/secret-input.ts +8 -0
  34. package/src/types-plugin.ts +1 -0
  35. package/src/types.ts +0 -5
  36. package/src/typing-lifecycle.ts +9 -4
  37. package/skills/qqbot-upgrade/SKILL.md +0 -56
  38. package/src/commands/bot-upgrade.ts +0 -56
  39. package/src/features/update-checker.ts +0 -166
@@ -22,7 +22,6 @@ import { createPluginLogger } from '../utils/plugin-logger.js';
22
22
  import type { PluginLogger } from '../utils/plugin-logger.js';
23
23
  import { registerGateway, unregisterGateway, getGateway } from '../outbound/outbound-service.js';
24
24
  import { saveCredentialBackup, loadCredentialBackup } from '../features/credential-backup.js';
25
- import { triggerUpdateCheck } from '../features/update-checker.js';
26
25
  import { syncCommandPanels } from '../features/command-panel.js';
27
26
  import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from '../features/approval-capability.js';
28
27
  import { cleanupTypingByAccount } from '../typing-lifecycle.js';
@@ -74,6 +73,14 @@ export async function startAccountWithCredentialRecovery(ctx: StartAccountContex
74
73
  }
75
74
  }
76
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
+
77
84
  // 创建 gateway 实例并注册
78
85
  const gw = new QQBotGateway(account, runtime, log);
79
86
  registerGateway(account.accountId, gw);
@@ -111,16 +118,13 @@ async function initFeatures(
111
118
  ctx: StartAccountContext,
112
119
  log: PluginLogger,
113
120
  ): Promise<void> {
114
- // 1. 版本更新检测(后台预热,fire-and-forget)
115
- triggerUpdateCheck(log);
116
-
117
- // 2. 注册 "approval.native" 运行时上下文。框架的 approval bootstrap 监听此上下文,
121
+ // 1. 注册 "approval.native" 运行时上下文。框架的 approval bootstrap 监听此上下文,
118
122
  // 据此为该账户自动 spawn native 审批 handler(消费 exec/plugin approval 事件,
119
123
  // 回调 qqbotPlugin.approvalCapability.nativeRuntime 的 availability/presentation/transport)。
120
124
  // 账户停止时 abortSignal 触发,上下文自动注销。
121
125
  registerApprovalNativeContext(account.accountId, ctx);
122
126
 
123
- // 3. 指令面板同步:把 openclaw essential 原生指令注册到 QQ Bot 指令面板(/v2/panels)。
127
+ // 2. 指令面板同步:把 openclaw essential 原生指令注册到 QQ Bot 指令面板(/v2/panels)。
124
128
  // 每进程每账号仅一次;失败只记日志并由 once-guard 释放等待重连重试,不影响消息收发。
125
129
  syncCommandPanels(account, ctx.cfg, log).catch((e) => {
126
130
  log?.error(`[qqbot:${account.accountId}] command panel sync error: ${e}`);
@@ -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}`);
@@ -49,14 +49,16 @@ async function replyToUser(
49
49
  try {
50
50
  await ctx.bot.sendText(ctx.replyTarget, text);
51
51
  return;
52
- } catch {
52
+ } catch (err) {
53
53
  rollback();
54
- // 落到主动降级
54
+ ctx.log?.warn?.(`[secret] passive reply failed, falling back to proactive: ${err instanceof Error ? err.message : String(err)}`);
55
55
  }
56
56
  }
57
57
  }
58
58
  const proactiveTarget = { scope: 'c2c' as const, targetId: ctx.message.senderId as string };
59
- await ctx.bot.sendText(proactiveTarget, text).catch(() => {/* 尽力而为 */});
59
+ await ctx.bot.sendText(proactiveTarget, text).catch((err: unknown) => {
60
+ ctx.log?.warn?.(`[secret] proactive reply failed: ${err instanceof Error ? err.message : String(err)}`);
61
+ });
60
62
  }
61
63
 
62
64
  function formatSetFailure(name: string, result: SecretsStoreSetResult): string {
@@ -13,6 +13,9 @@ const DEFAULT_WINDOW_MS = 1500;
13
13
  const DEFAULT_MAX_WAIT_MS = 8000;
14
14
  const DEFAULT_SEPARATOR = '\n\n---\n\n';
15
15
 
16
+ // 最小结构类型:兼容 PluginLogger 与 SDK Logger(与 config-util 同款)
17
+ type WarnSink = { warn?: (msg: string, meta?: Record<string, unknown>) => void };
18
+
16
19
  interface PendingDeliver {
17
20
  texts: string[];
18
21
  firstAt: number;
@@ -26,15 +29,18 @@ export class DeliverDebouncer {
26
29
  private readonly maxWaitMs: number;
27
30
  private readonly separator: string;
28
31
  private readonly flush: (targetId: string, mergedText: string) => Promise<void>;
32
+ private readonly log?: WarnSink;
29
33
 
30
34
  constructor(
31
35
  config: DeliverDebounceConfig | undefined,
32
36
  flush: (targetId: string, mergedText: string) => Promise<void>,
37
+ log?: WarnSink,
33
38
  ) {
34
39
  this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
35
40
  this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
36
41
  this.separator = config?.separator ?? DEFAULT_SEPARATOR;
37
42
  this.flush = flush;
43
+ this.log = log;
38
44
  }
39
45
 
40
46
  get enabled(): boolean {
@@ -60,7 +66,7 @@ export class DeliverDebouncer {
60
66
  if (Date.now() - existing.firstAt >= this.maxWaitMs) {
61
67
  await this.doFlush(targetId);
62
68
  } else {
63
- existing.timer = setTimeout(() => this.doFlush(targetId), this.windowMs);
69
+ existing.timer = setTimeout(() => this.scheduledFlush(targetId), this.windowMs);
64
70
  }
65
71
  return;
66
72
  }
@@ -70,13 +76,24 @@ export class DeliverDebouncer {
70
76
  const pending: PendingDeliver = {
71
77
  texts: [text],
72
78
  firstAt: Date.now(),
73
- timer: setTimeout(() => this.doFlush(targetId), this.windowMs),
79
+ timer: setTimeout(() => this.scheduledFlush(targetId), this.windowMs),
74
80
  resolve,
75
81
  };
76
82
  this.pending.set(targetId, pending);
77
83
  });
78
84
  }
79
85
 
86
+ /**
87
+ * 定时器触发的 flush:promise 被 setTimeout 回调丢弃,失败若不就地
88
+ * 捕获会成为 unhandledRejection——记 WARN 封口(pending.resolve 在
89
+ * doFlush 的 finally 中仍会执行,入队方不会被悬挂)。
90
+ */
91
+ private scheduledFlush(targetId: string): void {
92
+ void this.doFlush(targetId).catch((err: unknown) => {
93
+ this.log?.warn?.(`[debounce] timer flush failed target=${targetId}: ${err instanceof Error ? err.message : String(err)}`);
94
+ });
95
+ }
96
+
80
97
  private async doFlush(targetId: string): Promise<void> {
81
98
  const pending = this.pending.get(targetId);
82
99
  if (!pending) return;
@@ -28,6 +28,11 @@ import { isApprovalPayload } from './features/approval-utils.js';
28
28
  import { TEXT_CHUNK_LIMIT } from './constants.js';
29
29
  import { tryGetQQBotRuntime } from './runtime.js';
30
30
  import { getAdapters } from './adapter/resolve.js';
31
+ import { createPluginLogger } from './utils/plugin-logger.js';
32
+
33
+ // 契约入口(框架直发 / message 工具 / cron announce)无调用方 logger 可传,
34
+ // 用模块级 logger 兜底——frameworkSink 每次调用时 resolve,register 早期自动降级 console
35
+ const alog = createPluginLogger({ prefix: '[outbound]' });
31
36
 
32
37
  export type SendTextFn = (params: {
33
38
  to: string;
@@ -178,6 +183,8 @@ export function createQQBotOutboundAdapter(params: QQBotOutboundAdapterParams):
178
183
  if (canReply && replyToId && result.error) {
179
184
  rollback();
180
185
  log?.debug?.(`[${resolvedAccountId}] rollback quota: send failed`);
186
+ } else if (!canReply) {
187
+ log?.info?.(`[${resolvedAccountId}] [quota] fallback to proactive media send: quota exhausted or no msgId`);
181
188
  }
182
189
 
183
190
  return result;
@@ -287,6 +294,7 @@ export function createQQBotChannelOutbound(params: QQBotOutboundAdapterParams =
287
294
  accountId: ctx.accountId ?? undefined,
288
295
  replyToId: resolvePassiveFirstReplyToId(ctx.to, ctx.replyToId),
289
296
  account,
297
+ log: alog,
290
298
  });
291
299
  if (result.error) throw new Error(result.error);
292
300
  return { channel: 'qqbot', messageId: result.messageId ?? '' };
@@ -302,7 +310,8 @@ export function createQQBotChannelOutbound(params: QQBotOutboundAdapterParams =
302
310
  accountId: resolvedAccountId,
303
311
  replyToId: resolvePassiveFirstReplyToId(ctx.to, ctx.replyToId),
304
312
  account,
305
- agentId: resolveMCPAgentId(ctx.to, resolvedAccountId, ctx.cfg),
313
+ agentId: resolveMCPAgentId(ctx.to, resolvedAccountId, ctx.cfg, alog),
314
+ log: alog,
306
315
  });
307
316
  if (result.error) throw new Error(result.error);
308
317
  return { channel: 'qqbot', messageId: result.messageId ?? '' };
@@ -49,13 +49,14 @@ async function linkViaQrCode(cfg: OpenClawConfig, _accountId: string, prompter:
49
49
  }
50
50
  }
51
51
 
52
- async function linkViaManual(cfg: OpenClawConfig, _accountId: string, prompter: Prompter): Promise<OpenClawConfig> {
52
+ async function linkViaManual(cfg: OpenClawConfig, _accountId: string, prompter: Prompter, rt: Runtime): Promise<OpenClawConfig> {
53
53
  const appIdInput = await prompter.text({ message: '请输入 QQ Bot AppID', validate: (v) => v.trim() ? undefined : 'AppID 不能为空' });
54
54
  const secret = await prompter.text({ message: '请输入 QQ Bot AppSecret', validate: (v) => v.trim() ? undefined : 'AppSecret 不能为空' });
55
55
  const appId = appIdInput.trim();
56
56
  const key = resolveAccountKey(cfg, appId);
57
57
  let next = applyQQBotAccountConfig(cfg, key, { appId, clientSecret: secret.trim() });
58
58
  next = applyAccountDefaults(next, key);
59
+ rt.log(`手动绑定完成!账户: ${key} (AppID: ${appId})`);
59
60
  await prompter.note('✔ QQ Bot 配置完成!', 'QQ Bot');
60
61
  return next;
61
62
  }
@@ -82,7 +83,7 @@ export async function finalizeQQBotSetup(params: {
82
83
  if (mode === 'qr') {
83
84
  next = await linkViaQrCode(next, accountId, params.prompter, params.runtime);
84
85
  } else if (mode === 'manual') {
85
- next = await linkViaManual(next, accountId, params.prompter);
86
+ next = await linkViaManual(next, accountId, params.prompter, params.runtime);
86
87
  } else if (!configured) {
87
88
  await params.prompter.note('您可以稍后运行以下命令重新配置:\n openclaw channels add', 'QQ Bot');
88
89
  }
@@ -2,6 +2,11 @@ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
2
  import { listQQBotAccountIds } from '../config.js';
3
3
  import { getBotForAccount } from '../bot-instance.js';
4
4
  import { getRequestAccountId } from '../request-context.js';
5
+ import { createPluginLogger } from '../utils/plugin-logger.js';
6
+
7
+ // 工具 execute 无 logger 参数;request-context 活跃期间 enrichMeta
8
+ // 自动带上 accountId/openId/messageId
9
+ const toolLog = createPluginLogger({ prefix: '[tool:platform]' });
5
10
 
6
11
  // ========== JSON Schema ==========
7
12
 
@@ -111,6 +116,7 @@ export function registerPlatformTool(api: OpenClawPluginApi): void {
111
116
  try {
112
117
  const bot = getBotForAccount(accountId);
113
118
  const apiGateway = bot.api;
119
+ const startedAt = Date.now();
114
120
 
115
121
  let data: unknown;
116
122
  switch (method) {
@@ -131,10 +137,12 @@ export function registerPlatformTool(api: OpenClawPluginApi): void {
131
137
  break;
132
138
  }
133
139
 
140
+ toolLog.info(`${method} ${p.path} ok (${Date.now() - startedAt}ms)`, { accountId });
134
141
  return json(data);
135
142
  } catch (err: unknown) {
136
143
  const errMsg = err instanceof Error ? err.message : String(err);
137
144
  const apiErr = err as { httpStatus?: number; bizCode?: number; path?: string };
145
+ toolLog.warn(`${method} ${p.path} failed status=${apiErr.httpStatus ?? '?'} code=${apiErr.bizCode ?? '?'}: ${errMsg}`, { accountId });
138
146
  return json({
139
147
  error: errMsg,
140
148
  status: apiErr.httpStatus,
@@ -22,8 +22,13 @@ import {
22
22
  } from '../features/secret-input-store.js';
23
23
  import { getRequestAccountId, getRequestTarget } from '../request-context.js';
24
24
  import { parseTarget } from '../outbound/target.js';
25
+ import { createPluginLogger } from '../utils/plugin-logger.js';
25
26
  import type { InlineKeyboard, KeyboardButton } from '../types.js';
26
27
 
28
+ // 工具 execute 无 logger 参数可传;request-context 在工具执行期间处于活跃,
29
+ // 模块级 logger 的 enrichMeta 会自动带上 accountId/openId/messageId
30
+ const toolLog = createPluginLogger({ prefix: '[tool:secret-input]' });
31
+
27
32
  // ========== JSON Schema ==========
28
33
 
29
34
  const SecretInputSchema = {
@@ -162,11 +167,14 @@ export function registerSecretInputTool(api: OpenClawPluginApi): void {
162
167
  );
163
168
  } catch (err) {
164
169
  cancelPendingSecretInput(accountId, parsed.targetId);
170
+ toolLog.error(`card send failed: ${err instanceof Error ? err.message : String(err)}`, { name, accountId });
165
171
  return json({
166
172
  error: `密钥输入卡片发送失败:${err instanceof Error ? err.message : String(err)}`,
167
173
  });
168
174
  }
169
175
 
176
+ toolLog.info(`card sent, pending registered name=${name} ttlMinutes=${DEFAULT_SECRET_INPUT_TTL_MS / 60_000}`);
177
+
170
178
  return json({
171
179
  ok: true,
172
180
  name,
@@ -69,6 +69,7 @@ export interface TypingParams {
69
69
  replyToId: string;
70
70
  log?: {
71
71
  debug?: (message: string) => void;
72
+ warn?: (message: string) => void;
72
73
  };
73
74
  }
74
75
 
package/src/types.ts CHANGED
@@ -163,11 +163,6 @@ export interface QQBotAccountConfig {
163
163
  * 禁用时:公网 URL 始终由插件先下载到本地,再以 Base64 上传(适用于 QQ 平台无法访问目标 URL 的场景)
164
164
  */
165
165
  urlDirectUpload?: boolean;
166
- /**
167
- * /bot-upgrade 指令返回的升级指引网址
168
- * 默认: 本仓库 CHANGELOG(含各版本升级说明)
169
- */
170
- upgradeUrl?: string;
171
166
  /**
172
167
  * 群消息是否默认需要 @机器人才响应(默认 true)
173
168
  * 优先级低于 groups.{groupId}.requireMention 和 groups."*".requireMention
@@ -59,14 +59,17 @@ export async function startTypingWithRenewal(
59
59
 
60
60
  log?.debug?.(`[${accountId}] typing started: ${sessionKey}`);
61
61
 
62
- const timer = setTimeout(async () => {
63
- await handleTypingRenewal({
62
+ const timer = setTimeout(() => {
63
+ // promise 被 setTimeout 回调丢弃,失败不就地捕获会成为 unhandledRejection
64
+ void handleTypingRenewal({
64
65
  sessionKey,
65
66
  accountId,
66
67
  to,
67
68
  replyToId,
68
69
  log,
69
70
  sendTyping,
71
+ }).catch((err: unknown) => {
72
+ log?.warn?.(`[${accountId}] typing renewal error: ${err instanceof Error ? err.message : String(err)}`);
70
73
  });
71
74
  }, TYPING_RENEWAL_MS);
72
75
 
@@ -120,8 +123,10 @@ async function handleTypingRenewal(
120
123
  state.renewalCount += 1;
121
124
  log?.debug?.(`[${accountId}] typing renewed #${state.renewalCount}: ${sessionKey}`);
122
125
 
123
- state.timer = setTimeout(async () => {
124
- await handleTypingRenewal(params);
126
+ state.timer = setTimeout(() => {
127
+ void handleTypingRenewal(params).catch((err: unknown) => {
128
+ log?.warn?.(`[${accountId}] typing renewal error: ${err instanceof Error ? err.message : String(err)}`);
129
+ });
125
130
  }, TYPING_RENEWAL_MS);
126
131
 
127
132
  activeTypingSessions.set(sessionKey, state);
@@ -1,56 +0,0 @@
1
- ---
2
- name: qqbot-upgrade
3
- description: 将 openclaw-qqbot 插件(@jerryliang122/openclaw-qqbot)升级到最新版本。当用户要求更新 QQ 机器人插件、升级 qqbot 扩展或同步最新版时使用。
4
- metadata: {"openclaw":{"emoji":"⬆️","requires":{"config":["channels.qqbot"]}}}
5
- ---
6
-
7
- # QQBot 插件升级
8
-
9
- ## 何时使用
10
-
11
- 用户表达以下意图时,应在**本机终端**执行升级命令(不要只口头说明「去官网升级」):
12
-
13
- - 更新 / 升级 `openclaw-qqbot` / QQBot 插件 / QQ 机器人插件
14
- - 同步本仓库(jerryliang122/qqbot-openclaw)最新版
15
-
16
- ---
17
-
18
- ## 标准命令
19
-
20
- npm 包(推荐):
21
-
22
- ```bash
23
- openclaw plugins install @jerryliang122/openclaw-qqbot
24
- ```
25
-
26
- 指定版本:
27
-
28
- ```bash
29
- openclaw plugins install @jerryliang122/openclaw-qqbot@<version>
30
- ```
31
-
32
- 或从 GitHub(无法访问 npm 时):
33
-
34
- ```bash
35
- openclaw plugins install git+https://github.com/jerryliang122/qqbot-openclaw.git
36
- ```
37
-
38
- 升级完成后重启网关使其生效:
39
-
40
- ```bash
41
- openclaw gateway restart
42
- ```
43
-
44
- ---
45
-
46
- ## 前置条件
47
-
48
- - OpenClaw >= 2026.9.2(低版本框架无法加载本插件)
49
- - 需 **Node.js / npm** 环境可访问 npm registry(或 GitHub)
50
- - 从 2.x 旧版本升级:先阅读仓库 CHANGELOG 的 Breaking Changes 与配置迁移表
51
-
52
- ---
53
-
54
- ## 执行后
55
-
56
- 根据命令退出码与终端输出向用户简要汇报:成功则提示新版本号并确认网关已重启;失败则摘录关键错误并提示检查网络、npm 权限与 OpenClaw 版本。
@@ -1,56 +0,0 @@
1
- import type { SlashCommand } from '@tencent-connect/qqbot-nodejs';
2
- import type { ResolvedQQBotAccount } from '../types.js';
3
- import { getPackageVersion } from '../utils/pkg-version.js';
4
- import { getUpdateInfo } from '../features/update-checker.js';
5
- import { checkCommandAuth } from './config-util.js';
6
-
7
- const PLUGIN_VERSION = getPackageVersion();
8
- const DEFAULT_UPGRADE_URL = 'https://github.com/jerryliang122/qqbot-openclaw/blob/main/CHANGELOG.md';
9
- const GITHUB_URL = 'https://github.com/jerryliang122/qqbot-openclaw';
10
-
11
- /** /bot-upgrade — 检查更新并查看升级指引 */
12
- export function botUpgrade(account: ResolvedQQBotAccount): SlashCommand {
13
- return {
14
- name: 'bot-upgrade',
15
- description: '检查更新并查看升级指引',
16
- scope: 'c2c',
17
- authorized: checkCommandAuth,
18
- usage: [
19
- '/bot-upgrade 检查是否有新版本',
20
- ].join('\n'),
21
- handler: async () => {
22
- const url = account.config.upgradeUrl ?? DEFAULT_UPGRADE_URL;
23
- const info = await getUpdateInfo();
24
-
25
- if (info.checkedAt === 0) {
26
- return '⏳ 版本检查中,请稍后再试';
27
- }
28
-
29
- if (info.error) {
30
- return [
31
- '❌ 主机网络访问异常,无法检查更新',
32
- '',
33
- `查看升级指引:[点击查看](${url})`,
34
- ].join('\n');
35
- }
36
-
37
- if (!info.hasUpdate) {
38
- return [
39
- `✅ 当前已是最新版本 v${PLUGIN_VERSION}`,
40
- '',
41
- `项目地址:[GitHub](${GITHUB_URL})`,
42
- ].join('\n');
43
- }
44
-
45
- return [
46
- '🆕 发现新版本',
47
- '',
48
- `当前版本:**v${PLUGIN_VERSION}**`,
49
- `最新版本:**v${info.latest}**`,
50
- '',
51
- `📖 升级指引:[点击查看](${url})`,
52
- `🌟 GitHub 仓库:[点击前往](${GITHUB_URL})`,
53
- ].join('\n');
54
- },
55
- };
56
- }
@@ -1,166 +0,0 @@
1
- /**
2
- * 版本检查器
3
- *
4
- * - triggerUpdateCheck(): gateway 启动时调用,后台预热缓存
5
- * - getUpdateInfo(): 每次实时查询 npm registry,返回最新结果
6
- *
7
- * 使用 HTTPS 直接请求 npm registry API(不依赖 npm CLI),
8
- * 支持多 registry fallback:npmjs.org → npmmirror.com,解决国内网络问题。
9
- */
10
-
11
- import https from "node:https";
12
- import { getPackageVersion } from "../utils/pkg-version.js";
13
- import type { PluginLogger } from '../utils/plugin-logger.js';
14
-
15
- const PKG_NAME = "@jerryliang122/openclaw-qqbot";
16
- const ENCODED_PKG = encodeURIComponent(PKG_NAME);
17
-
18
- const REGISTRIES = [
19
- `https://registry.npmjs.org/${ENCODED_PKG}`,
20
- `https://registry.npmmirror.com/${ENCODED_PKG}`,
21
- ];
22
-
23
- let CURRENT_VERSION = getPackageVersion();
24
-
25
- export interface UpdateInfo {
26
- current: string;
27
- latest: string | null;
28
- stable: string | null;
29
- alpha: string | null;
30
- hasUpdate: boolean;
31
- checkedAt: number;
32
- error?: string;
33
- }
34
-
35
- function fetchJson(url: string, timeoutMs: number): Promise<any> {
36
- return new Promise((resolve, reject) => {
37
- const req = https.get(url, { timeout: timeoutMs, headers: { Accept: "application/json" } }, (res) => {
38
- if (res.statusCode !== 200) {
39
- res.resume();
40
- reject(new Error(`HTTP ${res.statusCode} from ${url}`));
41
- return;
42
- }
43
- let data = "";
44
- res.on("data", (chunk: string) => { data += chunk; });
45
- res.on("end", () => {
46
- try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
47
- });
48
- });
49
- req.on("error", reject);
50
- req.on("timeout", () => { req.destroy(); reject(new Error(`timeout fetching ${url}`)); });
51
- });
52
- }
53
-
54
- async function fetchDistTags(log?: PluginLogger): Promise<Record<string, string>> {
55
- for (const url of REGISTRIES) {
56
- try {
57
- const json = await fetchJson(url, 10_000);
58
- const tags = json["dist-tags"];
59
- if (tags && typeof tags === "object") return tags;
60
- } catch (e: any) {
61
- log?.debug(`[update-checker] ${url} failed: ${e.message}`);
62
- }
63
- }
64
- throw new Error("all registries failed");
65
- }
66
-
67
- function buildUpdateInfo(tags: Record<string, string>): UpdateInfo {
68
- const currentIsPrerelease = CURRENT_VERSION.includes("-");
69
- const stableTag = tags.latest || null;
70
- const alphaTag = tags.alpha || null;
71
-
72
- const compareTarget = currentIsPrerelease ? alphaTag : stableTag;
73
-
74
- const hasUpdate = typeof compareTarget === "string"
75
- && compareTarget !== CURRENT_VERSION
76
- && compareVersions(compareTarget, CURRENT_VERSION) > 0;
77
-
78
- return {
79
- current: CURRENT_VERSION,
80
- latest: compareTarget,
81
- stable: stableTag,
82
- alpha: alphaTag,
83
- hasUpdate,
84
- checkedAt: Date.now(),
85
- };
86
- }
87
-
88
- /**
89
- * Gateway 启动时调用,后台预热版本缓存。
90
- * log 通过闭包传递,避免模块级可变状态。
91
- */
92
- export function triggerUpdateCheck(log: PluginLogger): void {
93
- getUpdateInfo(log).then((info) => {
94
- if (info.hasUpdate) {
95
- log.info(`[update-checker] new version available: ${info.latest} (current: ${CURRENT_VERSION})`);
96
- }
97
- }).catch(() => {});
98
- }
99
-
100
- /** 每次实时查询 npm registry */
101
- export async function getUpdateInfo(log?: PluginLogger): Promise<UpdateInfo> {
102
- try {
103
- const tags = await fetchDistTags(log);
104
- return buildUpdateInfo(tags);
105
- } catch (err: any) {
106
- log?.debug(`[update-checker] check failed: ${err.message}`);
107
- return { current: CURRENT_VERSION, latest: null, stable: null, alpha: null, hasUpdate: false, checkedAt: Date.now(), error: err.message };
108
- }
109
- }
110
-
111
- /**
112
- * 检查指定版本是否存在于 npm registry
113
- */
114
- export async function checkVersionExists(version: string, pkgName?: string): Promise<boolean> {
115
- const registries = pkgName ? buildRegistries(pkgName) : REGISTRIES;
116
- for (const baseUrl of registries) {
117
- try {
118
- const url = `${baseUrl}/${version}`;
119
- const json = await fetchJson(url, 10_000);
120
- if (json && json.version === version) return true;
121
- } catch {
122
- // try next registry
123
- }
124
- }
125
- return false;
126
- }
127
-
128
- function buildRegistries(pkgName: string): string[] {
129
- const encoded = encodeURIComponent(pkgName);
130
- return [
131
- `https://registry.npmjs.org/${encoded}`,
132
- `https://registry.npmmirror.com/${encoded}`,
133
- ];
134
- }
135
-
136
- function compareVersions(a: string, b: string): number {
137
- const parse = (v: string) => {
138
- const clean = v.replace(/^v/, "");
139
- const [main, pre] = clean.split("-", 2);
140
- return { parts: main.split(".").map(Number), pre: pre || null };
141
- };
142
- const pa = parse(a);
143
- const pb = parse(b);
144
- for (let i = 0; i < 3; i++) {
145
- const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0);
146
- if (diff !== 0) return diff;
147
- }
148
- if (!pa.pre && pb.pre) return 1;
149
- if (pa.pre && !pb.pre) return -1;
150
- if (!pa.pre && !pb.pre) return 0;
151
- const aParts = pa.pre!.split(".");
152
- const bParts = pb.pre!.split(".");
153
- for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
154
- const aP = aParts[i] ?? "";
155
- const bP = bParts[i] ?? "";
156
- const aNum = Number(aP);
157
- const bNum = Number(bP);
158
- if (!isNaN(aNum) && !isNaN(bNum)) {
159
- if (aNum !== bNum) return aNum - bNum;
160
- } else {
161
- if (aP < bP) return -1;
162
- if (aP > bP) return 1;
163
- }
164
- }
165
- return 0;
166
- }