@jerryliang122/openclaw-qqbot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +967 -0
  3. package/README.zh.md +790 -0
  4. package/dist/index.cjs +18072 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +1222 -0
  7. package/index.ts +92 -0
  8. package/openclaw.plugin.json +38 -0
  9. package/package.json +69 -0
  10. package/preload.cjs +19 -0
  11. package/scripts/link-sdk-core.cjs +268 -0
  12. package/scripts/proactive-api-server.ts +369 -0
  13. package/scripts/send-proactive.ts +293 -0
  14. package/scripts/test-sendmedia.ts +116 -0
  15. package/skills/qqbot-channel/SKILL.md +285 -0
  16. package/skills/qqbot-channel/references/api_references.md +521 -0
  17. package/skills/qqbot-remind/SKILL.md +159 -0
  18. package/skills/qqbot-upgrade/SKILL.md +56 -0
  19. package/src/adapter/contract.ts +63 -0
  20. package/src/adapter/lint.ts +144 -0
  21. package/src/adapter/media.ts +40 -0
  22. package/src/adapter/pairing.ts +95 -0
  23. package/src/adapter/resolve.ts +255 -0
  24. package/src/adapter/setup.ts +13 -0
  25. package/src/adapter/webhook.ts +248 -0
  26. package/src/adapter/workspace.ts +21 -0
  27. package/src/agent-prompt-adapter.ts +26 -0
  28. package/src/bot-instance.ts +60 -0
  29. package/src/channel.ts +230 -0
  30. package/src/commands/bot-approve.ts +143 -0
  31. package/src/commands/bot-clear-storage.ts +114 -0
  32. package/src/commands/bot-group-always.ts +62 -0
  33. package/src/commands/bot-group-info.ts +48 -0
  34. package/src/commands/bot-help.ts +40 -0
  35. package/src/commands/bot-logs.ts +248 -0
  36. package/src/commands/bot-me.ts +18 -0
  37. package/src/commands/bot-pairing.ts +50 -0
  38. package/src/commands/bot-ping.ts +33 -0
  39. package/src/commands/bot-streaming.ts +55 -0
  40. package/src/commands/bot-upgrade.ts +56 -0
  41. package/src/commands/bot-version.ts +41 -0
  42. package/src/commands/config-util.ts +96 -0
  43. package/src/commands/index.ts +51 -0
  44. package/src/config.ts +403 -0
  45. package/src/constants.ts +6 -0
  46. package/src/dispatch/body-assembler.ts +308 -0
  47. package/src/dispatch/ctx-builder.ts +127 -0
  48. package/src/dispatch/dispatch.ts +667 -0
  49. package/src/dispatch/envelope-builder.ts +112 -0
  50. package/src/dispatch/index.ts +2 -0
  51. package/src/features/approval-capability.ts +302 -0
  52. package/src/features/approval-helpers.ts +271 -0
  53. package/src/features/approval-utils.ts +21 -0
  54. package/src/features/command-panel.ts +301 -0
  55. package/src/features/credential-backup.ts +74 -0
  56. package/src/features/group-mode-store.ts +79 -0
  57. package/src/features/history-store.ts +75 -0
  58. package/src/features/msgid-cache.ts +55 -0
  59. package/src/features/outbound-echo-store.ts +46 -0
  60. package/src/features/proactive-budget.ts +57 -0
  61. package/src/features/proactive.ts +549 -0
  62. package/src/features/question-helpers.ts +771 -0
  63. package/src/features/quota-manager.ts +173 -0
  64. package/src/features/ref-index-store.ts +289 -0
  65. package/src/features/secret-input-store.ts +118 -0
  66. package/src/features/secret-store-cli.ts +324 -0
  67. package/src/features/typing-refresh.ts +51 -0
  68. package/src/features/update-checker.ts +166 -0
  69. package/src/gateway/event-handlers.ts +456 -0
  70. package/src/gateway/index.ts +3 -0
  71. package/src/gateway/lifecycle.ts +236 -0
  72. package/src/gateway/middleware-setup.ts +173 -0
  73. package/src/gateway/qqbot-gateway.ts +458 -0
  74. package/src/gateway-adapter.ts +44 -0
  75. package/src/heartbeat-adapter.ts +57 -0
  76. package/src/message-adapter.ts +40 -0
  77. package/src/messaging-adapter.ts +78 -0
  78. package/src/middleware/access-control.ts +125 -0
  79. package/src/middleware/attachment.ts +373 -0
  80. package/src/middleware/inbound-guard.ts +102 -0
  81. package/src/middleware/policy-injector.ts +71 -0
  82. package/src/middleware/secret-capture.ts +161 -0
  83. package/src/middleware/typing.ts +110 -0
  84. package/src/openclaw-plugin-sdk.d.ts +543 -0
  85. package/src/outbound/chunker.ts +80 -0
  86. package/src/outbound/debounce.ts +102 -0
  87. package/src/outbound/deliver-pipeline.ts +235 -0
  88. package/src/outbound/index.ts +3 -0
  89. package/src/outbound/local-file-router.ts +145 -0
  90. package/src/outbound/media-send.ts +408 -0
  91. package/src/outbound/outbound-service.ts +298 -0
  92. package/src/outbound/reply-limiter.ts +139 -0
  93. package/src/outbound/sanitize.ts +32 -0
  94. package/src/outbound/streaming-controller.ts +332 -0
  95. package/src/outbound/target.ts +109 -0
  96. package/src/outbound-adapter.ts +323 -0
  97. package/src/plugin-base.ts +42 -0
  98. package/src/request-context.ts +50 -0
  99. package/src/runtime.ts +42 -0
  100. package/src/setup/account-key.ts +41 -0
  101. package/src/setup/finalize.ts +110 -0
  102. package/src/setup/login.ts +197 -0
  103. package/src/setup/surface.ts +40 -0
  104. package/src/status-adapter.ts +56 -0
  105. package/src/tools/platform.ts +149 -0
  106. package/src/tools/remind.ts +308 -0
  107. package/src/tools/secret-input.ts +185 -0
  108. package/src/types-augment.d.ts +54 -0
  109. package/src/types-plugin.ts +82 -0
  110. package/src/types.ts +620 -0
  111. package/src/typing-lifecycle.ts +182 -0
  112. package/src/utils/mention.ts +52 -0
  113. package/src/utils/pkg-version.ts +23 -0
  114. package/src/utils/platform.ts +459 -0
  115. package/src/utils/plugin-logger.ts +104 -0
  116. package/src/utils/ssrf-guard.ts +132 -0
  117. package/src/utils/stt.ts +150 -0
  118. package/src/utils/voice-text.ts +61 -0
  119. package/tsconfig.json +17 -0
  120. package/tsup.config.ts +64 -0
@@ -0,0 +1,324 @@
1
+ /**
2
+ * openclaw secrets store CLI 执行器
3
+ *
4
+ * 把用户在聊天中发送的密钥值通过 `openclaw secrets store set` 写入
5
+ * OpenClaw 密钥库(CLI 直写本地共享状态,绕过 Gateway)。
6
+ *
7
+ * kind 规则(openclaw 2026.9+ 两级模型):
8
+ * - env:代理可读环境变量,`--value` 传参,事后可 `secrets store get` 读回
9
+ * - secret:「受保护的机密」,write-only 代理不可读,CLI 拒绝 `--value`,
10
+ * 必须走 `--value-file -`(stdin)写入
11
+ *
12
+ * 聊天卡片流程(qqbot_secret_input → secretCapture)**恒用 env**:
13
+ * 卡片收来的值本就留在 QQ 聊天记录里,且 AI 随后要读取使用,存成
14
+ * write-only 的 secret 反而用不了(2026-08-30 用户反馈)。执行器保留
15
+ * secret/stdin 分支仅作通用能力,当前没有调用方产生该 kind。
16
+ *
17
+ * 安全约束:
18
+ * - spawn 恒为 args 数组 + shell 缺省(不经过 shell),值不参与任何
19
+ * 字符串拼接 → 无注入面;name 另有白名单正则双保险
20
+ * - 所有输出/错误经 scrubSecret() 清洗后才会返回或落日志,值绝不外泄
21
+ *
22
+ * 注:本文件直接 import node:child_process。openclaw 插件安装扫描器
23
+ * (dangerous-exec 规则)会记一条 critical 告警,但本地安装不会被拦截
24
+ * (硬拦截仅依赖包名黑名单)——见 AGENTS.md 已知项。
25
+ */
26
+ import { spawn } from 'node:child_process';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { createRequire } from 'node:module';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ // CJS 兼容:__filename 在 CJS 中可用,ESM 中使用 import.meta.url
33
+ const __file = typeof __filename !== 'undefined'
34
+ ? __filename
35
+ : fileURLToPath(import.meta.url);
36
+ const require_ = createRequire(__file);
37
+
38
+ export type SecretStoreKind = 'env' | 'secret';
39
+
40
+ /** CLI 同款命名规则:^[A-Z][A-Z0-9_]{0,127}$ */
41
+ const SECRET_NAME_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
42
+
43
+ /** CLI 单值上限 64 KiB(超出 CLI 直接 exit 2,插件侧提前拦截) */
44
+ export const SECRET_VALUE_MAX_BYTES = 65_536;
45
+
46
+ const SET_TIMEOUT_MS = 30_000;
47
+ const RELOAD_TIMEOUT_MS = 15_000;
48
+ /** 子进程输出保留上限(诊断用,已 scrub) */
49
+ const OUTPUT_CAP_CHARS = 2_000;
50
+
51
+ // ── 校验 / 掩码 ────────────────────────────────────────────────────────────
52
+
53
+ export function isValidSecretName(name: string): boolean {
54
+ return SECRET_NAME_RE.test(name);
55
+ }
56
+
57
+ /** 展示用掩码:>12 字符显示前 4 + … + 后 4,否则 *** */
58
+ export function maskSecret(value: string): string {
59
+ if (value.length <= 12) return '***';
60
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
61
+ }
62
+
63
+ /** 把文本中出现的密钥值整体替换为 ***(错误输出/日志清洗用) */
64
+ export function scrubSecret(text: string, value: string): string {
65
+ if (!value) return text;
66
+ return text.split(value).join('***');
67
+ }
68
+
69
+ /** 纯函数:拼装 secrets store set 的 argv(不含 CLI 入口本身),便于测试 */
70
+ export function buildSecretsStoreSetArgs(
71
+ name: string,
72
+ kind: SecretStoreKind,
73
+ value: string,
74
+ ): string[] {
75
+ const args = ['secrets', 'store', 'set', name, '--kind', kind];
76
+ // secret 强制 stdin(CLI 拒绝 --value);env 走 --value
77
+ return kind === 'secret' ? [...args, '--value-file', '-'] : [...args, '--value', value];
78
+ }
79
+
80
+ // ── CLI 入口解析 ───────────────────────────────────────────────────────────
81
+
82
+ export interface CliCommand {
83
+ cmd: string;
84
+ /** CLI 入口自身的前置参数(如 [cli.js]) */
85
+ args: string[];
86
+ }
87
+
88
+ let cachedCli: CliCommand | undefined;
89
+
90
+ /**
91
+ * 解析 openclaw CLI 命令形态。
92
+ *
93
+ * 优先级:
94
+ * 1. 正在运行的网关自身的 openclaw 安装(process.argv[1] 向上定位包根)。
95
+ * CLI 直写本地 state 库,而库 schema 归网关版本所有,CLI 必须与网关同源;
96
+ * dev 场景插件仓库 node_modules 里常 pin 着旧版副本,旧 CLI 会拒写新
97
+ * schema 库(2026-08-30:dev 副本 2026.8.1-beta.3/schema 9 拒写全局
98
+ * 2026.9.1-beta.1/schema 12 的库,exit 1)。
99
+ * 2. require.resolve('openclaw')(生产安装经 preload symlink 指向全局安装)。
100
+ * 两种解析均得 `[node, cli.mjs]`(跨平台、不依赖 PATH);兜底 PATH 上的
101
+ * `openclaw`。
102
+ */
103
+ export function resolveOpenClawCli(): CliCommand {
104
+ if (cachedCli) return cachedCli;
105
+ const gatewayEntry = process.argv[1];
106
+ if (gatewayEntry) {
107
+ const cli = cliFromPackageRoot(
108
+ findOpenClawPackageRoot(path.dirname(path.resolve(gatewayEntry))),
109
+ );
110
+ if (cli) {
111
+ cachedCli = cli;
112
+ return cachedCli;
113
+ }
114
+ }
115
+ try {
116
+ const entryPath = require_.resolve('openclaw');
117
+ const cli = cliFromPackageRoot(findOpenClawPackageRoot(path.dirname(entryPath)));
118
+ if (cli) {
119
+ cachedCli = cli;
120
+ return cachedCli;
121
+ }
122
+ } catch {
123
+ // openclaw 不可解析(理论上不会——preload 保证 peer 可用)→ 走 PATH
124
+ }
125
+ cachedCli = { cmd: 'openclaw', args: [] };
126
+ return cachedCli;
127
+ }
128
+
129
+ /** 从起始目录向上查找 name 为 openclaw 的包根(最多 6 层,跳过无关包) */
130
+ function findOpenClawPackageRoot(startDir: string): string | undefined {
131
+ let dir = startDir;
132
+ for (let i = 0; i < 6; i += 1) {
133
+ const pkgPath = path.join(dir, 'package.json');
134
+ if (fs.existsSync(pkgPath)) {
135
+ try {
136
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { name?: string };
137
+ if (pkg.name === 'openclaw') return dir;
138
+ } catch {
139
+ // 损坏的 package.json → 继续向上
140
+ }
141
+ }
142
+ const parent = path.dirname(dir);
143
+ if (parent === dir) return undefined;
144
+ dir = parent;
145
+ }
146
+ return undefined;
147
+ }
148
+
149
+ /** 读取包根 bin 字段,构造 [node, <bin>] 形态的 CLI 命令;不可用返回 undefined */
150
+ function cliFromPackageRoot(pkgRoot: string | undefined): CliCommand | undefined {
151
+ if (!pkgRoot) return undefined;
152
+ try {
153
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')) as {
154
+ bin?: Record<string, string> | string;
155
+ };
156
+ const binField = pkg.bin;
157
+ const binEntry =
158
+ typeof binField === 'string'
159
+ ? binField
160
+ : binField?.openclaw ?? Object.values(binField ?? {})[0];
161
+ if (binEntry) {
162
+ return { cmd: process.execPath, args: [path.resolve(pkgRoot, binEntry)] };
163
+ }
164
+ } catch {
165
+ // 不可读 → 落下一优先级
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ /** 测试辅助:清除缓存的 CLI 解析结果 */
171
+ export function __resetCachedCliForTest(): void {
172
+ cachedCli = undefined;
173
+ }
174
+
175
+ // ── 子进程执行 ─────────────────────────────────────────────────────────────
176
+
177
+ /** 子进程流的最小结构约定(不依赖 @types/node 全局命名空间) */
178
+ interface MinimalReadable {
179
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
180
+ on(event: string, listener: (...args: any[]) => void): unknown;
181
+ }
182
+
183
+ interface MinimalWritable {
184
+ write(chunk: string | Uint8Array): unknown;
185
+ end(cb?: () => void): unknown;
186
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
187
+ on?(event: string, listener: (...args: any[]) => void): unknown;
188
+ }
189
+
190
+ /** spawn 返回对象的最小结构约定(真实 ChildProcess 结构兼容) */
191
+ export interface SpawnLikeChild {
192
+ stdin: MinimalWritable | null;
193
+ stdout: MinimalReadable | null;
194
+ stderr: MinimalReadable | null;
195
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
196
+ on(event: string, listener: (...args: any[]) => void): SpawnLikeChild;
197
+ kill(signal?: string | number): void;
198
+ }
199
+
200
+ export type SpawnFn = (
201
+ cmd: string,
202
+ args: readonly string[],
203
+ options: { stdio: ['pipe', 'pipe', 'pipe'] },
204
+ ) => SpawnLikeChild;
205
+
206
+ export interface SecretsStoreSetResult {
207
+ ok: boolean;
208
+ exitCode: number | null;
209
+ timedOut: boolean;
210
+ /** spawn 本身失败(ENOENT 等),已 scrub */
211
+ error?: string;
212
+ /** stdout + stderr 合并摘要,已 scrub */
213
+ output: string;
214
+ }
215
+
216
+ interface ExecCliParams {
217
+ cliArgs: readonly string[];
218
+ /** secret kind 时经 stdin 写入的值 */
219
+ stdinValue?: string;
220
+ timeoutMs: number;
221
+ /** 参与输出清洗的密钥值 */
222
+ secret: string;
223
+ /** 测试注入:覆盖 CLI 入口解析 */
224
+ cliOverride?: CliCommand;
225
+ }
226
+
227
+ function execCli(
228
+ params: ExecCliParams,
229
+ deps: { spawnFn?: SpawnFn } = {},
230
+ ): Promise<SecretsStoreSetResult> {
231
+ const { spawnFn = spawn as unknown as SpawnFn } = deps;
232
+ const cli = params.cliOverride ?? resolveOpenClawCli();
233
+ return new Promise((resolve) => {
234
+ let child: SpawnLikeChild;
235
+ try {
236
+ child = spawnFn(cli.cmd, [...cli.args, ...params.cliArgs], {
237
+ stdio: ['pipe', 'pipe', 'pipe'],
238
+ });
239
+ } catch (err) {
240
+ resolve({
241
+ ok: false,
242
+ exitCode: null,
243
+ timedOut: false,
244
+ error: scrubSecret(
245
+ `无法启动 openclaw CLI: ${err instanceof Error ? err.message : String(err)}`,
246
+ params.secret,
247
+ ),
248
+ output: '',
249
+ });
250
+ return;
251
+ }
252
+
253
+ let stdout = '';
254
+ let stderr = '';
255
+ let timedOut = false;
256
+ let settled = false;
257
+
258
+ const timer = setTimeout(() => {
259
+ timedOut = true;
260
+ child.kill('SIGKILL');
261
+ }, params.timeoutMs);
262
+ timer.unref?.();
263
+
264
+ const finish = (ok: boolean, exitCode: number | null, error?: string) => {
265
+ if (settled) return;
266
+ settled = true;
267
+ clearTimeout(timer);
268
+ const output = scrubSecret(
269
+ `${stdout}${stderr ? (stdout ? '\n' : '') + stderr : ''}`.trim().slice(0, OUTPUT_CAP_CHARS),
270
+ params.secret,
271
+ );
272
+ resolve({ ok, exitCode, timedOut, error: error ? scrubSecret(error, params.secret) : undefined, output });
273
+ };
274
+
275
+ child.stdout?.on('data', (chunk: Buffer | string) => {
276
+ if (stdout.length < OUTPUT_CAP_CHARS) stdout += String(chunk);
277
+ });
278
+ child.stderr?.on('data', (chunk: Buffer | string) => {
279
+ if (stderr.length < OUTPUT_CAP_CHARS) stderr += String(chunk);
280
+ });
281
+ child.on('error', (err: unknown) => {
282
+ finish(false, null, `openclaw CLI 执行失败: ${err instanceof Error ? err.message : String(err)}`);
283
+ });
284
+ child.on('close', (code: unknown) => {
285
+ finish(!timedOut && code === 0, typeof code === 'number' ? code : null);
286
+ });
287
+
288
+ if (params.stdinValue !== undefined) {
289
+ child.stdin?.on?.('error', () => {/* EPIPE:CLI 提前退出,交给 close 收尾 */});
290
+ child.stdin?.write(params.stdinValue);
291
+ child.stdin?.end();
292
+ } else {
293
+ child.stdin?.end();
294
+ }
295
+ });
296
+ }
297
+
298
+ /** 执行 openclaw secrets store set(值的合法性校验由调用方先行完成) */
299
+ export async function runSecretsStoreSet(
300
+ params: { name: string; kind: SecretStoreKind; value: string },
301
+ deps: { spawnFn?: SpawnFn; cli?: CliCommand } = {},
302
+ ): Promise<SecretsStoreSetResult> {
303
+ return execCli(
304
+ {
305
+ cliArgs: buildSecretsStoreSetArgs(params.name, params.kind, params.value),
306
+ stdinValue: params.kind === 'secret' ? params.value : undefined,
307
+ timeoutMs: SET_TIMEOUT_MS,
308
+ secret: params.value,
309
+ cliOverride: deps.cli,
310
+ },
311
+ deps,
312
+ );
313
+ }
314
+
315
+ /** best-effort 执行 openclaw secrets reload(写入后被 SecretRef 引用的名字才会即时生效) */
316
+ export async function runSecretsReload(
317
+ deps: { spawnFn?: SpawnFn; cli?: CliCommand } = {},
318
+ ): Promise<boolean> {
319
+ const result = await execCli(
320
+ { cliArgs: ['secrets', 'reload'], timeoutMs: RELOAD_TIMEOUT_MS, secret: '', cliOverride: deps.cli },
321
+ deps,
322
+ );
323
+ return result.ok;
324
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 出站消息 → typing 续期信号
3
+ *
4
+ * QQ 客户端收到机器人消息后会自动终止"正在输入"显示。若框架任务
5
+ * 仍在进行(如思维链等中间消息),typing 中间件需要在消息发出后
6
+ * 延迟数秒补发一次续期,恢复指示器显示。
7
+ *
8
+ * 本模块提供进程内信号:出站层(QQBotGateway)在消息发送成功后
9
+ * notify,活跃的 typing 会话按 accountId + scope + targetId 订阅。
10
+ * 任务已完成的会话已取消订阅,notify 自然不产生任何效果。
11
+ */
12
+
13
+ type Listener = () => void;
14
+
15
+ const listeners = new Map<string, Set<Listener>>();
16
+
17
+ function key(accountId: string, scope: string, targetId: string): string {
18
+ return `${accountId}:${scope}:${targetId}`;
19
+ }
20
+
21
+ /**
22
+ * 订阅指定会话的出站消息信号。
23
+ * @returns 取消订阅函数
24
+ */
25
+ export function subscribeOutboundMessage(
26
+ accountId: string,
27
+ scope: string,
28
+ targetId: string,
29
+ listener: Listener,
30
+ ): () => void {
31
+ const k = key(accountId, scope, targetId);
32
+ let set = listeners.get(k);
33
+ if (!set) {
34
+ set = new Set();
35
+ listeners.set(k, set);
36
+ }
37
+ set.add(listener);
38
+ return () => {
39
+ set.delete(listener);
40
+ if (set.size === 0) listeners.delete(k);
41
+ };
42
+ }
43
+
44
+ /**
45
+ * 消息发送成功后由出站层调用:通知该会话活跃的 typing 会话补发续期。
46
+ */
47
+ export function notifyOutboundMessageSent(accountId: string, scope: string, targetId: string): void {
48
+ const set = listeners.get(key(accountId, scope, targetId));
49
+ if (!set) return;
50
+ for (const listener of [...set]) listener();
51
+ }
@@ -0,0 +1,166 @@
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
+ }