@ryantest/openclaw-qqbot 0.0.3 → 1.6.6-alpha.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 (89) hide show
  1. package/README.md +2 -15
  2. package/README.zh.md +3 -16
  3. package/dist/src/admin-resolver.d.ts +12 -6
  4. package/dist/src/admin-resolver.js +69 -34
  5. package/dist/src/api.d.ts +105 -1
  6. package/dist/src/api.js +164 -15
  7. package/dist/src/channel.js +13 -0
  8. package/dist/src/config.js +3 -10
  9. package/dist/src/deliver-debounce.d.ts +74 -0
  10. package/dist/src/deliver-debounce.js +174 -0
  11. package/dist/src/gateway.js +450 -248
  12. package/dist/src/image-server.d.ts +27 -8
  13. package/dist/src/image-server.js +179 -71
  14. package/dist/src/inbound-attachments.d.ts +3 -1
  15. package/dist/src/inbound-attachments.js +28 -14
  16. package/dist/src/outbound-deliver.js +77 -148
  17. package/dist/src/outbound.d.ts +6 -4
  18. package/dist/src/outbound.js +266 -442
  19. package/dist/src/reply-dispatcher.js +4 -4
  20. package/dist/src/request-context.d.ts +18 -0
  21. package/dist/src/request-context.js +30 -0
  22. package/dist/src/slash-commands.js +277 -32
  23. package/dist/src/startup-greeting.d.ts +5 -5
  24. package/dist/src/startup-greeting.js +32 -13
  25. package/dist/src/streaming.d.ts +244 -0
  26. package/dist/src/streaming.js +907 -0
  27. package/dist/src/tools/remind.js +11 -10
  28. package/dist/src/types.d.ts +101 -0
  29. package/dist/src/types.js +17 -1
  30. package/dist/src/update-checker.js +2 -8
  31. package/dist/src/utils/audio-convert.d.ts +9 -0
  32. package/dist/src/utils/audio-convert.js +51 -0
  33. package/dist/src/utils/chunked-upload.d.ts +59 -0
  34. package/dist/src/utils/chunked-upload.js +289 -0
  35. package/dist/src/utils/file-utils.d.ts +7 -1
  36. package/dist/src/utils/file-utils.js +24 -2
  37. package/dist/src/utils/media-send.d.ts +147 -0
  38. package/dist/src/utils/media-send.js +434 -0
  39. package/dist/src/utils/pkg-version.d.ts +5 -0
  40. package/dist/src/utils/pkg-version.js +51 -0
  41. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  42. package/dist/src/utils/ssrf-guard.js +91 -0
  43. package/node_modules/ws/index.js +15 -6
  44. package/node_modules/ws/lib/permessage-deflate.js +6 -6
  45. package/node_modules/ws/lib/websocket-server.js +5 -5
  46. package/node_modules/ws/lib/websocket.js +6 -6
  47. package/node_modules/ws/package.json +4 -3
  48. package/node_modules/ws/wrapper.mjs +14 -1
  49. package/openclaw.plugin.json +1 -0
  50. package/package.json +11 -22
  51. package/scripts/postinstall-link-sdk.js +113 -0
  52. package/scripts/upgrade-via-npm.ps1 +161 -6
  53. package/scripts/upgrade-via-npm.sh +311 -104
  54. package/scripts/upgrade-via-source.sh +117 -0
  55. package/skills/qqbot-media/SKILL.md +9 -5
  56. package/skills/qqbot-remind/SKILL.md +3 -3
  57. package/src/admin-resolver.ts +76 -35
  58. package/src/api.ts +284 -12
  59. package/src/channel.ts +12 -0
  60. package/src/config.ts +3 -10
  61. package/src/deliver-debounce.ts +229 -0
  62. package/src/gateway.ts +277 -67
  63. package/src/image-server.ts +213 -77
  64. package/src/inbound-attachments.ts +32 -15
  65. package/src/outbound-deliver.ts +77 -157
  66. package/src/outbound.ts +304 -451
  67. package/src/reply-dispatcher.ts +4 -4
  68. package/src/request-context.ts +39 -0
  69. package/src/slash-commands.ts +303 -33
  70. package/src/startup-greeting.ts +35 -13
  71. package/src/streaming.ts +1096 -0
  72. package/src/tools/remind.ts +15 -11
  73. package/src/types.ts +111 -0
  74. package/src/update-checker.ts +2 -7
  75. package/src/utils/audio-convert.ts +56 -0
  76. package/src/utils/chunked-upload.ts +419 -0
  77. package/src/utils/file-utils.ts +28 -2
  78. package/src/utils/media-send.ts +563 -0
  79. package/src/utils/pkg-version.ts +54 -0
  80. package/src/utils/ssrf-guard.ts +102 -0
  81. package/clawdbot.plugin.json +0 -16
  82. package/dist/src/user-messages.d.ts +0 -8
  83. package/dist/src/user-messages.js +0 -8
  84. package/moltbot.plugin.json +0 -16
  85. package/scripts/upgrade-via-alt-pkg.sh +0 -307
  86. package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
  87. package/src/gateway.log +0 -43
  88. package/src/openclaw-2026-03-21.log +0 -3729
  89. package/src/user-messages.ts +0 -7
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 远程 URL 安全校验
3
+ *
4
+ * 下载外部资源前,确保目标地址不会命中内部网络或云元数据端点,
5
+ * 避免模型输出的恶意链接触达内网服务。
6
+ */
7
+ /**
8
+ * 检查给定 IP 是否落在不可路由 / 私有网段内。
9
+ *
10
+ * 覆盖:
11
+ * - IPv4: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 0.0.0.0
12
+ * - IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA)
13
+ */
14
+ export declare function isReservedAddr(ip: string): boolean;
15
+ /**
16
+ * 校验远程 URL 是否可安全请求。
17
+ *
18
+ * 规则:
19
+ * 1. 仅放行 http / https 协议
20
+ * 2. 若 URL 直接携带 IP 则即时判定
21
+ * 3. 若为域名则先做 DNS 解析,逐条检查解析结果
22
+ *
23
+ * @throws {Error} 当 URL 指向受限地址时
24
+ */
25
+ export declare function validateRemoteUrl(raw: string): Promise<void>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 远程 URL 安全校验
3
+ *
4
+ * 下载外部资源前,确保目标地址不会命中内部网络或云元数据端点,
5
+ * 避免模型输出的恶意链接触达内网服务。
6
+ */
7
+ import net from "node:net";
8
+ import dns from "node:dns/promises";
9
+ /* ---------- 内网 / 保留地址判定 ---------- */
10
+ /** IPv4 保留网段前缀(覆盖 RFC 1918、链路本地、回环等) */
11
+ const RESERVED_V4_PREFIXES = [
12
+ "127.", // loopback
13
+ "10.", // class-A private
14
+ "192.168.", // class-C private
15
+ "169.254.", // link-local / cloud metadata
16
+ ];
17
+ /** 172.16.0.0 – 172.31.255.255 需要单独用正则匹配 */
18
+ const PRIVATE_172_RE = /^172\.(1[6-9]|2\d|3[01])\./;
19
+ /**
20
+ * 检查给定 IP 是否落在不可路由 / 私有网段内。
21
+ *
22
+ * 覆盖:
23
+ * - IPv4: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 0.0.0.0
24
+ * - IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA)
25
+ */
26
+ export function isReservedAddr(ip) {
27
+ // --- IPv4 ---
28
+ if (ip === "0.0.0.0")
29
+ return true;
30
+ for (const pfx of RESERVED_V4_PREFIXES) {
31
+ if (ip.startsWith(pfx))
32
+ return true;
33
+ }
34
+ if (PRIVATE_172_RE.test(ip))
35
+ return true;
36
+ // --- IPv6 ---
37
+ const lower = ip.toLowerCase();
38
+ if (lower === "::1" || lower === "::")
39
+ return true;
40
+ if (lower.startsWith("fe80:"))
41
+ return true; // link-local
42
+ if (lower.startsWith("fc") || lower.startsWith("fd"))
43
+ return true; // unique local
44
+ return false;
45
+ }
46
+ /* ---------- URL 合法性校验 ---------- */
47
+ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
48
+ /**
49
+ * 校验远程 URL 是否可安全请求。
50
+ *
51
+ * 规则:
52
+ * 1. 仅放行 http / https 协议
53
+ * 2. 若 URL 直接携带 IP 则即时判定
54
+ * 3. 若为域名则先做 DNS 解析,逐条检查解析结果
55
+ *
56
+ * @throws {Error} 当 URL 指向受限地址时
57
+ */
58
+ export async function validateRemoteUrl(raw) {
59
+ const url = new URL(raw);
60
+ if (!ALLOWED_SCHEMES.has(url.protocol)) {
61
+ throw new Error(`不支持的协议 "${url.protocol}",仅允许 http/https(URL: ${raw})`);
62
+ }
63
+ // 去掉 IPv6 方括号
64
+ const host = url.hostname.replace(/^\[|\]$/g, "");
65
+ if (net.isIP(host)) {
66
+ assertPublicAddr(host, raw);
67
+ return;
68
+ }
69
+ // 域名 → 解析后逐条检查
70
+ try {
71
+ const ips = await dns.resolve(host);
72
+ for (const ip of ips) {
73
+ assertPublicAddr(ip, raw, host);
74
+ }
75
+ }
76
+ catch (err) {
77
+ // 已经是我们自己抛的安全错误,继续向上传播
78
+ if (err instanceof Error && err.message.includes("内网"))
79
+ throw err;
80
+ // DNS 查询失败不阻塞,后续 fetch 会产生网络错误
81
+ console.warn(`[url-check] DNS 解析 "${host}" 失败: ${err}`);
82
+ }
83
+ }
84
+ /* ---------- 内部辅助 ---------- */
85
+ /** 断言 IP 为公网地址,否则抛出错误 */
86
+ function assertPublicAddr(ip, originalUrl, domain) {
87
+ if (!isReservedAddr(ip))
88
+ return;
89
+ const target = domain ? `域名 "${domain}" 解析到内网地址 "${ip}"` : `内网地址 "${ip}"`;
90
+ throw new Error(`禁止访问${target},已拦截潜在的 SSRF 请求(URL: ${originalUrl})`);
91
+ }
@@ -1,13 +1,22 @@
1
1
  'use strict';
2
2
 
3
+ const createWebSocketStream = require('./lib/stream');
4
+ const extension = require('./lib/extension');
5
+ const PerMessageDeflate = require('./lib/permessage-deflate');
6
+ const Receiver = require('./lib/receiver');
7
+ const Sender = require('./lib/sender');
8
+ const subprotocol = require('./lib/subprotocol');
3
9
  const WebSocket = require('./lib/websocket');
10
+ const WebSocketServer = require('./lib/websocket-server');
4
11
 
5
- WebSocket.createWebSocketStream = require('./lib/stream');
6
- WebSocket.Server = require('./lib/websocket-server');
7
- WebSocket.Receiver = require('./lib/receiver');
8
- WebSocket.Sender = require('./lib/sender');
9
-
12
+ WebSocket.createWebSocketStream = createWebSocketStream;
13
+ WebSocket.extension = extension;
14
+ WebSocket.PerMessageDeflate = PerMessageDeflate;
15
+ WebSocket.Receiver = Receiver;
16
+ WebSocket.Sender = Sender;
17
+ WebSocket.Server = WebSocketServer;
18
+ WebSocket.subprotocol = subprotocol;
10
19
  WebSocket.WebSocket = WebSocket;
11
- WebSocket.WebSocketServer = WebSocket.Server;
20
+ WebSocket.WebSocketServer = WebSocketServer;
12
21
 
13
22
  module.exports = WebSocket;
@@ -37,6 +37,9 @@ class PerMessageDeflate {
37
37
  * acknowledge disabling of client context takeover
38
38
  * @param {Number} [options.concurrencyLimit=10] The number of concurrent
39
39
  * calls to zlib
40
+ * @param {Boolean} [options.isServer=false] Create the instance in either
41
+ * server or client mode
42
+ * @param {Number} [options.maxPayload=0] The maximum allowed message length
40
43
  * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
41
44
  * use of a custom server window size
42
45
  * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
@@ -47,16 +50,13 @@ class PerMessageDeflate {
47
50
  * deflate
48
51
  * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
49
52
  * inflate
50
- * @param {Boolean} [isServer=false] Create the instance in either server or
51
- * client mode
52
- * @param {Number} [maxPayload=0] The maximum allowed message length
53
53
  */
54
- constructor(options, isServer, maxPayload) {
55
- this._maxPayload = maxPayload | 0;
54
+ constructor(options) {
56
55
  this._options = options || {};
57
56
  this._threshold =
58
57
  this._options.threshold !== undefined ? this._options.threshold : 1024;
59
- this._isServer = !!isServer;
58
+ this._maxPayload = this._options.maxPayload | 0;
59
+ this._isServer = !!this._options.isServer;
60
60
  this._deflate = null;
61
61
  this._inflate = null;
62
62
 
@@ -293,11 +293,11 @@ class WebSocketServer extends EventEmitter {
293
293
  this.options.perMessageDeflate &&
294
294
  secWebSocketExtensions !== undefined
295
295
  ) {
296
- const perMessageDeflate = new PerMessageDeflate(
297
- this.options.perMessageDeflate,
298
- true,
299
- this.options.maxPayload
300
- );
296
+ const perMessageDeflate = new PerMessageDeflate({
297
+ ...this.options.perMessageDeflate,
298
+ isServer: true,
299
+ maxPayload: this.options.maxPayload
300
+ });
301
301
 
302
302
  try {
303
303
  const offers = extension.parse(secWebSocketExtensions);
@@ -693,7 +693,7 @@ function initAsClient(websocket, address, protocols, options) {
693
693
  } else {
694
694
  try {
695
695
  parsedUrl = new URL(address);
696
- } catch (e) {
696
+ } catch {
697
697
  throw new SyntaxError(`Invalid URL: ${address}`);
698
698
  }
699
699
  }
@@ -755,11 +755,11 @@ function initAsClient(websocket, address, protocols, options) {
755
755
  opts.timeout = opts.handshakeTimeout;
756
756
 
757
757
  if (opts.perMessageDeflate) {
758
- perMessageDeflate = new PerMessageDeflate(
759
- opts.perMessageDeflate !== true ? opts.perMessageDeflate : {},
760
- false,
761
- opts.maxPayload
762
- );
758
+ perMessageDeflate = new PerMessageDeflate({
759
+ ...opts.perMessageDeflate,
760
+ isServer: false,
761
+ maxPayload: opts.maxPayload
762
+ });
763
763
  opts.headers['Sec-WebSocket-Extensions'] = format({
764
764
  [PerMessageDeflate.extensionName]: perMessageDeflate.offer()
765
765
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ws",
3
- "version": "8.19.0",
3
+ "version": "8.20.0",
4
4
  "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
5
5
  "keywords": [
6
6
  "HyBi",
@@ -55,12 +55,13 @@
55
55
  }
56
56
  },
57
57
  "devDependencies": {
58
+ "@eslint/js": "^10.0.1",
58
59
  "benchmark": "^2.1.4",
59
60
  "bufferutil": "^4.0.1",
60
- "eslint": "^9.0.0",
61
+ "eslint": "^10.0.1",
61
62
  "eslint-config-prettier": "^10.0.1",
62
63
  "eslint-plugin-prettier": "^5.0.0",
63
- "globals": "^16.0.0",
64
+ "globals": "^17.0.0",
64
65
  "mocha": "^8.4.0",
65
66
  "nyc": "^15.0.0",
66
67
  "prettier": "^3.0.0",
@@ -1,8 +1,21 @@
1
1
  import createWebSocketStream from './lib/stream.js';
2
+ import extension from './lib/extension.js';
3
+ import PerMessageDeflate from './lib/permessage-deflate.js';
2
4
  import Receiver from './lib/receiver.js';
3
5
  import Sender from './lib/sender.js';
6
+ import subprotocol from './lib/subprotocol.js';
4
7
  import WebSocket from './lib/websocket.js';
5
8
  import WebSocketServer from './lib/websocket-server.js';
6
9
 
7
- export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer };
10
+ export {
11
+ createWebSocketStream,
12
+ extension,
13
+ PerMessageDeflate,
14
+ Receiver,
15
+ Sender,
16
+ subprotocol,
17
+ WebSocket,
18
+ WebSocketServer
19
+ };
20
+
8
21
  export default WebSocket;
@@ -3,6 +3,7 @@
3
3
  "name": "OpenClaw QQ Bot",
4
4
  "description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "channels": ["qqbot"],
6
+ "extensions": ["./dist/index.js"],
6
7
  "skills": ["skills/qqbot-channel", "skills/qqbot-remind", "skills/qqbot-media"],
7
8
  "capabilities": {
8
9
  "proactiveMessaging": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryantest/openclaw-qqbot",
3
- "version": "0.0.3",
3
+ "version": "1.6.6-alpha.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,32 +16,23 @@
16
16
  "scripts",
17
17
  "index.ts",
18
18
  "tsconfig.json",
19
- "openclaw.plugin.json",
20
- "clawdbot.plugin.json",
21
- "moltbot.plugin.json"
19
+ "openclaw.plugin.json"
22
20
  ],
23
- "clawdbot": {
24
- "id": "openclaw-qqbot",
25
- "extensions": [
26
- "./index.ts"
27
- ]
28
- },
29
- "moltbot": {
30
- "id": "openclaw-qqbot",
31
- "extensions": [
32
- "./index.ts"
33
- ]
34
- },
35
21
  "openclaw": {
36
22
  "id": "openclaw-qqbot",
37
23
  "extensions": [
38
- "./index.ts"
39
- ]
24
+ "./dist/index.js"
25
+ ],
26
+ "channel": {
27
+ "id": "qqbot",
28
+ "label": "QQ Bot"
29
+ }
40
30
  },
41
31
  "scripts": {
42
32
  "build": "tsc || true",
43
33
  "dev": "tsc --watch",
44
- "prepack": "npm install --omit=dev"
34
+ "prepack": "npm install --omit=dev",
35
+ "postinstall": "node scripts/postinstall-link-sdk.js 2>/dev/null || true"
45
36
  },
46
37
  "dependencies": {
47
38
  "mpg123-decoder": "^1.0.3",
@@ -59,8 +50,6 @@
59
50
  "typescript": "^5.9.3"
60
51
  },
61
52
  "peerDependencies": {
62
- "clawdbot": "*",
63
- "moltbot": "*",
64
53
  "openclaw": "*"
65
54
  },
66
55
  "homepage": "https://github.com/tencent-connect/openclaw-qqbot",
@@ -73,4 +62,4 @@
73
62
  "silk-wasm",
74
63
  "ws"
75
64
  ]
76
- }
65
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ // When installed as an openclaw extension under ~/.openclaw/extensions/,
4
+ // the plugin needs access to `openclaw/plugin-sdk` at runtime.
5
+ // openclaw's jiti loader resolves this via alias by walking up from the plugin
6
+ // path to find the openclaw package root — but ~/.openclaw/extensions/ is not
7
+ // under the openclaw package tree, so the alias lookup fails.
8
+ //
9
+ // This script creates a symlink from the plugin's node_modules/openclaw to the
10
+ // globally installed openclaw package, allowing Node's native ESM resolver
11
+ // (used by jiti with tryNative:true for .js files) to find `openclaw/plugin-sdk`.
12
+
13
+ import { existsSync, symlinkSync, mkdirSync, realpathSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { execSync } from "node:child_process";
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const pluginRoot = resolve(__dirname, "..");
20
+
21
+ // Only run when installed under an openclaw-like extensions directory
22
+ // (supports openclaw, clawdbot, moltbot, etc.)
23
+ if (!pluginRoot.includes("extensions")) {
24
+ process.exit(0);
25
+ }
26
+
27
+ const linkTarget = join(pluginRoot, "node_modules", "openclaw");
28
+
29
+ // Already linked or exists
30
+ if (existsSync(linkTarget)) {
31
+ process.exit(0);
32
+ }
33
+
34
+ // CLI names to try (openclaw and its aliases)
35
+ const CLI_NAMES = ["openclaw", "clawdbot", "moltbot"];
36
+
37
+ // Find the global openclaw installation
38
+ let openclawRoot = null;
39
+
40
+ // Strategy 1: npm root -g → look for any known CLI package name
41
+ if (!openclawRoot) {
42
+ try {
43
+ const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
44
+ for (const name of CLI_NAMES) {
45
+ const candidate = join(globalRoot, name);
46
+ if (existsSync(join(candidate, "package.json"))) {
47
+ openclawRoot = candidate;
48
+ break;
49
+ }
50
+ }
51
+ } catch {}
52
+ }
53
+
54
+ // Strategy 2: resolve from the CLI binary (which openclaw / clawdbot / moltbot)
55
+ if (!openclawRoot) {
56
+ const whichCmd = process.platform === "win32" ? "where" : "which";
57
+ for (const name of CLI_NAMES) {
58
+ try {
59
+ const bin = execSync(`${whichCmd} ${name}`, { encoding: "utf-8" }).trim().split("\n")[0];
60
+ if (!bin) continue;
61
+ // Resolve symlinks to get actual binary location
62
+ const realBin = realpathSync(bin);
63
+ // bin is typically <prefix>/bin/<name> -> ../lib/node_modules/<name>/...
64
+ const candidate = resolve(dirname(realBin), "..", "lib", "node_modules", name);
65
+ if (existsSync(join(candidate, "package.json"))) {
66
+ openclawRoot = candidate;
67
+ break;
68
+ }
69
+ // Also try: binary might be inside the package itself (e.g. .../node_modules/<name>/bin/<name>)
70
+ const candidate2 = resolve(dirname(realBin), "..");
71
+ if (existsSync(join(candidate2, "package.json")) && existsSync(join(candidate2, "plugin-sdk"))) {
72
+ openclawRoot = candidate2;
73
+ break;
74
+ }
75
+ } catch {}
76
+ }
77
+ }
78
+
79
+ // Strategy 3: walk up from the extensions directory to find the CLI's data root,
80
+ // then look for a global node_modules sibling
81
+ if (!openclawRoot) {
82
+ // pluginRoot is like /home/user/.openclaw/extensions/openclaw-qqbot
83
+ // The CLI data dir is /home/user/.openclaw (or .clawdbot, .moltbot)
84
+ const extensionsDir = dirname(pluginRoot);
85
+ const dataDir = dirname(extensionsDir);
86
+ const dataDirName = dataDir.split("/").pop() || dataDir.split("\\").pop() || "";
87
+ // dataDirName is like ".openclaw" → strip the dot to get "openclaw"
88
+ const cliName = dataDirName.replace(/^\./, "");
89
+ if (cliName) {
90
+ try {
91
+ const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
92
+ const candidate = join(globalRoot, cliName);
93
+ if (existsSync(join(candidate, "package.json"))) {
94
+ openclawRoot = candidate;
95
+ }
96
+ } catch {}
97
+ }
98
+ }
99
+
100
+ if (!openclawRoot) {
101
+ // Not fatal — plugin may work if openclaw loads it with proper alias resolution
102
+ // But log a warning so upgrade scripts can detect the failure
103
+ console.error("[postinstall-link-sdk] WARNING: could not find openclaw/clawdbot/moltbot global installation, symlink not created");
104
+ process.exit(0);
105
+ }
106
+
107
+ try {
108
+ mkdirSync(join(pluginRoot, "node_modules"), { recursive: true });
109
+ symlinkSync(openclawRoot, linkTarget, "junction");
110
+ console.log(`[postinstall-link-sdk] symlink created: node_modules/openclaw -> ${openclawRoot}`);
111
+ } catch (e) {
112
+ console.error(`[postinstall-link-sdk] WARNING: symlink creation failed: ${e.message}`);
113
+ }
@@ -95,7 +95,7 @@ Write-Host "==========================================="
95
95
  Write-Host ""
96
96
 
97
97
  # [1/3] Download and extract new version
98
- Write-Host "[1/3] Downloading new version..."
98
+ Write-Host "[1/5] Downloading new version..."
99
99
  $TMPDIR_PACK = Join-Path ([System.IO.Path]::GetTempPath()) "qqbot-pack-$([guid]::NewGuid().ToString('N').Substring(0,8))"
100
100
  $EXTRACT_DIR = Join-Path ([System.IO.Path]::GetTempPath()) "qqbot-extract-$([guid]::NewGuid().ToString('N').Substring(0,8))"
101
101
  New-Item -ItemType Directory -Path $TMPDIR_PACK -Force | Out-Null
@@ -172,9 +172,108 @@ try {
172
172
  if (Test-Path $EXTRACT_DIR) { Remove-Item -Recurse -Force $EXTRACT_DIR -ErrorAction SilentlyContinue }
173
173
  }
174
174
 
175
- # [2/3] Replace plugin directory (in-place overwrite to avoid file-lock issues)
175
+ # ── Preflight: validate new package before writing to extensions ──
176
176
  Write-Host ""
177
- Write-Host "[2/3] Replacing plugin directory..."
177
+ Write-Host "[2/5] Preflight checks..."
178
+ $PreflightOK = $true
179
+
180
+ # (a) package.json exists and has version
181
+ $StagingPkg = Join-Path $STAGING_DIR "package.json"
182
+ $StagingVersion = ""
183
+ if (-not (Test-Path $StagingPkg)) {
184
+ Write-Host " [FAIL] New package missing package.json" -ForegroundColor Red
185
+ $PreflightOK = $false
186
+ } else {
187
+ try {
188
+ $spkg = Get-Content $StagingPkg -Raw | ConvertFrom-Json
189
+ $StagingVersion = $spkg.version
190
+ if (-not $StagingVersion) { throw "no version" }
191
+ Write-Host " [OK] Version: $StagingVersion"
192
+ } catch {
193
+ Write-Host " [FAIL] package.json unreadable or missing version" -ForegroundColor Red
194
+ $PreflightOK = $false
195
+ }
196
+ }
197
+
198
+ # (b) Entry file exists
199
+ $EntryFile = ""
200
+ foreach ($candidate in @("dist\index.js", "index.js")) {
201
+ if (Test-Path (Join-Path $STAGING_DIR $candidate)) {
202
+ $EntryFile = $candidate
203
+ break
204
+ }
205
+ }
206
+ if (-not $EntryFile) {
207
+ Write-Host " [FAIL] Missing entry file (dist\index.js or index.js)" -ForegroundColor Red
208
+ $PreflightOK = $false
209
+ } else {
210
+ Write-Host " [OK] Entry file: $EntryFile"
211
+ }
212
+
213
+ # (c) Core directory dist/src
214
+ $CoreSrcDir = Join-Path $STAGING_DIR "dist" "src"
215
+ if (-not (Test-Path $CoreSrcDir)) {
216
+ Write-Host " [FAIL] Missing core directory dist\src\" -ForegroundColor Red
217
+ $PreflightOK = $false
218
+ } else {
219
+ $CoreJsCount = (Get-ChildItem -Path $CoreSrcDir -Filter "*.js" -File -Recurse -ErrorAction SilentlyContinue | Measure-Object).Count
220
+ Write-Host " [OK] dist\src\ contains $CoreJsCount JS files"
221
+ if ($CoreJsCount -lt 5) {
222
+ Write-Host " [FAIL] JS file count too low (expected >= 5, got $CoreJsCount)" -ForegroundColor Red
223
+ $PreflightOK = $false
224
+ }
225
+ }
226
+
227
+ # (d) Critical module files
228
+ $MissingModules = @()
229
+ foreach ($mod in @("dist\src\gateway.js", "dist\src\api.js", "dist\src\admin-resolver.js")) {
230
+ if (-not (Test-Path (Join-Path $STAGING_DIR $mod))) {
231
+ $MissingModules += $mod
232
+ }
233
+ }
234
+ if ($MissingModules.Count -gt 0) {
235
+ Write-Host " [FAIL] Missing critical modules: $($MissingModules -join ', ')" -ForegroundColor Red
236
+ $PreflightOK = $false
237
+ } else {
238
+ Write-Host " [OK] Critical modules intact"
239
+ }
240
+
241
+ # (e) Bundled node_modules health check
242
+ $nmDir = Join-Path $STAGING_DIR "node_modules"
243
+ if (Test-Path $nmDir) {
244
+ $BundledOK = $true
245
+ foreach ($dep in @("ws", "silk-wasm")) {
246
+ if (-not (Test-Path (Join-Path $nmDir $dep))) {
247
+ Write-Host " [WARN] Bundled dependency missing: $dep" -ForegroundColor Yellow
248
+ $BundledOK = $false
249
+ }
250
+ }
251
+ if ($BundledOK) {
252
+ Write-Host " [OK] Core bundled dependencies intact"
253
+ }
254
+ }
255
+
256
+ # (f) Version sanity check
257
+ if ($StagingVersion) {
258
+ $StagingMajor = ($StagingVersion -split "\.")[0]
259
+ if ($StagingMajor -eq "0") {
260
+ Write-Host " [WARN] Major version is 0 ($StagingVersion), may not be a production release" -ForegroundColor Yellow
261
+ }
262
+ }
263
+
264
+ # Preflight result
265
+ if (-not $PreflightOK) {
266
+ Write-Host ""
267
+ Write-Host "[ABORT] Preflight checks failed, upgrade cancelled (old version unaffected)" -ForegroundColor Red
268
+ Remove-Item -Recurse -Force $STAGING_DIR -ErrorAction SilentlyContinue
269
+ exit 1
270
+ }
271
+ Write-Host " [OK] All preflight checks passed"
272
+
273
+ # [3/5] Replace plugin directory (in-place overwrite to avoid file-lock issues)
274
+ Write-Host ""
275
+ Write-Host "[3/5] Replacing plugin directory..."
276
+ if (-not (Test-Path $EXTENSIONS_DIR)) { New-Item -ItemType Directory -Path $EXTENSIONS_DIR -Force | Out-Null }
178
277
  $TARGET_DIR = Join-Path $EXTENSIONS_DIR "openclaw-qqbot"
179
278
 
180
279
  if (-not (Test-Path $TARGET_DIR)) {
@@ -217,9 +316,50 @@ foreach ($legacyName in @("qqbot", "openclaw-qq")) {
217
316
  }
218
317
  Write-Host " Installed to: $TARGET_DIR"
219
318
 
220
- # [3/3] Verify installation
319
+ # Execute postinstall script to create openclaw SDK symlink
320
+ # (upgrade-via-npm is pure file operation, npm install is not run, so postinstall won't trigger automatically)
321
+ $PostinstallScript = Join-Path $TARGET_DIR "scripts" "postinstall-link-sdk.js"
322
+ if (Test-Path $PostinstallScript) {
323
+ Write-Host " Running postinstall: creating openclaw SDK symlink..."
324
+ try {
325
+ Push-Location $TARGET_DIR
326
+ $postOutput = & node $PostinstallScript 2>&1
327
+ Pop-Location
328
+ if ($postOutput) { Write-Host " $postOutput" }
329
+ } catch {
330
+ Write-Host " [WARN] postinstall script failed (non-fatal)" -ForegroundColor Yellow
331
+ try { Pop-Location } catch {}
332
+ }
333
+ # Verify symlink creation
334
+ $symlinkPath = Join-Path $TARGET_DIR "node_modules" "openclaw"
335
+ if (Test-Path $symlinkPath) {
336
+ Write-Host " [OK] openclaw SDK symlink ready"
337
+ } else {
338
+ Write-Host " [WARN] openclaw SDK symlink not created, attempting manual fallback..." -ForegroundColor Yellow
339
+ $cliDataDir = Split-Path $EXTENSIONS_DIR -Parent
340
+ $cliName = (Split-Path $cliDataDir -Leaf) -replace '^\.',''
341
+ try {
342
+ $globalRoot = (& npm root -g 2>$null).Trim()
343
+ $globalPkg = Join-Path $globalRoot $cliName
344
+ if ($globalRoot -and (Test-Path $globalPkg)) {
345
+ $nmDir = Join-Path $TARGET_DIR "node_modules"
346
+ if (-not (Test-Path $nmDir)) { New-Item -ItemType Directory -Path $nmDir -Force | Out-Null }
347
+ New-Item -ItemType Junction -Path $symlinkPath -Target $globalPkg -Force | Out-Null
348
+ Write-Host " [OK] Manual symlink created: -> $globalPkg"
349
+ } else {
350
+ Write-Host " [ERROR] Cannot locate global $cliName installation (npm root -g: $globalRoot)" -ForegroundColor Red
351
+ }
352
+ } catch {
353
+ Write-Host " [ERROR] Manual symlink creation also failed: $($_.Exception.Message)" -ForegroundColor Red
354
+ }
355
+ }
356
+ } else {
357
+ Write-Host " [WARN] postinstall script not found, skipping symlink creation" -ForegroundColor Yellow
358
+ }
359
+
360
+ # [4/5] Verify installation
221
361
  Write-Host ""
222
- Write-Host "[3/3] Verifying installation..."
362
+ Write-Host "[4/5] Verifying installation..."
223
363
  $NEW_VERSION = "unknown"
224
364
  try {
225
365
  $newPkgPath = Join-Path $TARGET_DIR "package.json"
@@ -249,7 +389,7 @@ if ($NoRestart) {
249
389
  exit 0
250
390
  }
251
391
 
252
- # [4/4] Configure appid/secret
392
+ # [配置] Configure appid/secret
253
393
  if ($AppId -and $Secret) {
254
394
  Write-Host ""
255
395
  Write-Host "[Config] Writing qqbot channel config..."
@@ -285,11 +425,26 @@ if ($AppId -and $Secret) {
285
425
 
286
426
  # [5/5] Restart gateway
287
427
  Write-Host ""
428
+
429
+ # Manual upgrade: write startup-marker before restart to prevent bot from sending duplicate notification
430
+ if ($NEW_VERSION -and $NEW_VERSION -ne "unknown") {
431
+ $MarkerDir = Join-Path $HOME_DIR ".openclaw" "qqbot" "data"
432
+ if (-not (Test-Path $MarkerDir)) { New-Item -ItemType Directory -Path $MarkerDir -Force | Out-Null }
433
+ $MarkerFile = Join-Path $MarkerDir "startup-marker.json"
434
+ $Now = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
435
+ @{ version = $NEW_VERSION; startedAt = $Now; greetedAt = $Now } | ConvertTo-Json -Compress | Set-Content $MarkerFile -Encoding UTF8
436
+ }
437
+
288
438
  Write-Host "[Restart] Restarting gateway..."
289
439
  try {
290
440
  & $CMD gateway restart 2>&1 | Out-Null
291
441
  if ($LASTEXITCODE -eq 0) {
292
442
  Write-Host " Gateway restarted"
443
+ # Print the same upgrade greeting as bot notification (no need to push via bot in manual upgrade)
444
+ if ($NEW_VERSION -and $NEW_VERSION -ne "unknown") {
445
+ Write-Host ""
446
+ Write-Host "🎉 QQBot 插件已更新至 v${NEW_VERSION},在线等候你的吩咐。"
447
+ }
293
448
  } else { throw "restart failed" }
294
449
  } catch {
295
450
  Write-Host " [WARN] Gateway restart failed, please run manually: $CMD gateway restart" -ForegroundColor Yellow