@tencent-connect/openclaw-qqbot 1.7.2 → 2.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 (291) hide show
  1. package/README.md +25 -8
  2. package/README.zh.md +24 -8
  3. package/bin/qqbot-cli.js +5 -2
  4. package/dist/index.cjs +15549 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +890 -0
  7. package/index.ts +56 -19
  8. package/openclaw.plugin.json +19 -3
  9. package/package.json +14 -23
  10. package/preload.cjs +17 -14
  11. package/scripts/link-sdk-core.cjs +133 -50
  12. package/scripts/upgrade-via-npm.sh +42 -11
  13. package/scripts/upgrade-via-source.sh +4 -4
  14. package/skills/qqbot-channel/SKILL.md +34 -12
  15. package/src/adapter/contract.ts +69 -0
  16. package/src/adapter/gateway.ts +61 -0
  17. package/src/adapter/index.ts +15 -0
  18. package/src/adapter/lint.ts +144 -0
  19. package/src/adapter/media.ts +112 -0
  20. package/src/adapter/pairing.ts +99 -0
  21. package/src/adapter/resolve.ts +333 -0
  22. package/src/adapter/setup.ts +55 -0
  23. package/src/adapter/webhook.ts +248 -0
  24. package/src/adapter/workspace.ts +29 -0
  25. package/src/bot-instance.ts +60 -0
  26. package/src/channel.ts +202 -413
  27. package/src/commands/bot-approve.ts +143 -0
  28. package/src/commands/bot-clear-storage.ts +114 -0
  29. package/src/commands/bot-group-always.ts +62 -0
  30. package/src/commands/bot-help.ts +40 -0
  31. package/src/commands/bot-logs.ts +248 -0
  32. package/src/commands/bot-me.ts +18 -0
  33. package/src/commands/bot-pairing.ts +57 -0
  34. package/src/commands/bot-ping.ts +33 -0
  35. package/src/commands/bot-streaming.ts +55 -0
  36. package/src/commands/bot-upgrade.ts +56 -0
  37. package/src/commands/bot-version.ts +41 -0
  38. package/src/commands/config-util.ts +96 -0
  39. package/src/commands/index.ts +49 -0
  40. package/src/config.ts +46 -19
  41. package/src/dispatch/body-assembler.ts +356 -0
  42. package/src/dispatch/ctx-builder.ts +110 -0
  43. package/src/dispatch/dispatch.ts +352 -0
  44. package/src/dispatch/envelope-builder.ts +112 -0
  45. package/src/dispatch/index.ts +2 -0
  46. package/src/{approval-handler.ts → features/approval-handler.ts} +40 -94
  47. package/src/features/approval-utils.ts +41 -0
  48. package/src/features/credential-backup.ts +82 -0
  49. package/src/features/history-store.ts +19 -0
  50. package/src/features/msgid-cache.ts +55 -0
  51. package/src/features/onboarding.ts +38 -0
  52. package/src/{proactive.ts → features/proactive.ts} +68 -49
  53. package/src/features/ref-index-store.ts +282 -0
  54. package/src/{update-checker.ts → features/update-checker.ts} +15 -35
  55. package/src/gateway/event-handlers.ts +241 -0
  56. package/src/gateway/index.ts +3 -0
  57. package/src/gateway/lifecycle.ts +221 -0
  58. package/src/gateway/middleware-setup.ts +139 -0
  59. package/src/gateway/qqbot-gateway.ts +342 -0
  60. package/src/middleware/access-control.ts +130 -0
  61. package/src/middleware/attachment.ts +314 -0
  62. package/src/middleware/policy-injector.ts +66 -0
  63. package/src/openclaw-plugin-sdk.d.ts +81 -10
  64. package/src/outbound/cron-scheduler.ts +130 -0
  65. package/src/outbound/debounce.ts +102 -0
  66. package/src/outbound/deliver-pipeline.ts +235 -0
  67. package/src/outbound/image-size.ts +123 -0
  68. package/src/outbound/index.ts +3 -0
  69. package/src/outbound/local-file-router.ts +145 -0
  70. package/src/outbound/media-send.ts +377 -0
  71. package/src/outbound/outbound-service.ts +222 -0
  72. package/src/outbound/reply-limiter.ts +128 -0
  73. package/src/outbound/sanitize.ts +32 -0
  74. package/src/outbound/streaming-controller.ts +228 -0
  75. package/src/outbound/target.ts +63 -0
  76. package/src/outbound/tts-provider.ts +84 -0
  77. package/src/request-context.ts +9 -8
  78. package/src/runtime.ts +31 -6
  79. package/src/setup/account-key.ts +41 -0
  80. package/src/setup/finalize.ts +110 -0
  81. package/src/setup/login.ts +197 -0
  82. package/src/setup/surface.ts +40 -0
  83. package/src/tools/platform.ts +149 -0
  84. package/src/tools/remind.ts +2 -0
  85. package/src/types-augment.d.ts +54 -0
  86. package/src/types.ts +51 -9
  87. package/src/utils/mention.ts +52 -0
  88. package/src/utils/pkg-version.ts +84 -47
  89. package/src/utils/platform.ts +31 -7
  90. package/src/utils/plugin-logger.ts +104 -0
  91. package/src/utils/ssrf-guard.ts +32 -2
  92. package/src/utils/stt.ts +137 -0
  93. package/src/utils/voice-text.ts +61 -0
  94. package/tsconfig.json +2 -1
  95. package/tsup.config.ts +57 -0
  96. package/dist/index.d.ts +0 -18
  97. package/dist/index.js +0 -27
  98. package/dist/src/admin-resolver.d.ts +0 -33
  99. package/dist/src/admin-resolver.js +0 -157
  100. package/dist/src/api.d.ts +0 -309
  101. package/dist/src/api.js +0 -923
  102. package/dist/src/approval-handler.d.ts +0 -47
  103. package/dist/src/approval-handler.js +0 -372
  104. package/dist/src/channel.d.ts +0 -29
  105. package/dist/src/channel.js +0 -528
  106. package/dist/src/config.d.ts +0 -60
  107. package/dist/src/config.js +0 -288
  108. package/dist/src/credential-backup.d.ts +0 -31
  109. package/dist/src/credential-backup.js +0 -66
  110. package/dist/src/deliver-debounce.d.ts +0 -74
  111. package/dist/src/deliver-debounce.js +0 -174
  112. package/dist/src/gateway.d.ts +0 -18
  113. package/dist/src/gateway.js +0 -2025
  114. package/dist/src/group-history.d.ts +0 -136
  115. package/dist/src/group-history.js +0 -226
  116. package/dist/src/image-server.d.ts +0 -87
  117. package/dist/src/image-server.js +0 -570
  118. package/dist/src/inbound-attachments.d.ts +0 -60
  119. package/dist/src/inbound-attachments.js +0 -248
  120. package/dist/src/known-users.d.ts +0 -100
  121. package/dist/src/known-users.js +0 -263
  122. package/dist/src/message-gating.d.ts +0 -53
  123. package/dist/src/message-gating.js +0 -107
  124. package/dist/src/message-queue.d.ts +0 -91
  125. package/dist/src/message-queue.js +0 -257
  126. package/dist/src/onboarding.d.ts +0 -10
  127. package/dist/src/onboarding.js +0 -203
  128. package/dist/src/outbound-deliver.d.ts +0 -48
  129. package/dist/src/outbound-deliver.js +0 -392
  130. package/dist/src/outbound.d.ts +0 -220
  131. package/dist/src/outbound.js +0 -975
  132. package/dist/src/proactive.d.ts +0 -170
  133. package/dist/src/proactive.js +0 -399
  134. package/dist/src/ref-index-store.d.ts +0 -101
  135. package/dist/src/ref-index-store.js +0 -298
  136. package/dist/src/reply-dispatcher.d.ts +0 -35
  137. package/dist/src/reply-dispatcher.js +0 -311
  138. package/dist/src/request-context.d.ts +0 -25
  139. package/dist/src/request-context.js +0 -37
  140. package/dist/src/runtime.d.ts +0 -3
  141. package/dist/src/runtime.js +0 -13
  142. package/dist/src/session-store.d.ts +0 -52
  143. package/dist/src/session-store.js +0 -254
  144. package/dist/src/slash-commands.d.ts +0 -82
  145. package/dist/src/slash-commands.js +0 -2233
  146. package/dist/src/startup-greeting.d.ts +0 -30
  147. package/dist/src/startup-greeting.js +0 -97
  148. package/dist/src/streaming.d.ts +0 -247
  149. package/dist/src/streaming.js +0 -899
  150. package/dist/src/stt.d.ts +0 -21
  151. package/dist/src/stt.js +0 -70
  152. package/dist/src/tools/channel.d.ts +0 -16
  153. package/dist/src/tools/channel.js +0 -231
  154. package/dist/src/tools/remind.d.ts +0 -2
  155. package/dist/src/tools/remind.js +0 -255
  156. package/dist/src/transport/index.d.ts +0 -10
  157. package/dist/src/transport/index.js +0 -9
  158. package/dist/src/transport/webhook-transport.d.ts +0 -67
  159. package/dist/src/transport/webhook-transport.js +0 -245
  160. package/dist/src/transport/webhook-verify.d.ts +0 -48
  161. package/dist/src/transport/webhook-verify.js +0 -98
  162. package/dist/src/types.d.ts +0 -462
  163. package/dist/src/types.js +0 -22
  164. package/dist/src/typing-keepalive.d.ts +0 -27
  165. package/dist/src/typing-keepalive.js +0 -64
  166. package/dist/src/update-checker.d.ts +0 -36
  167. package/dist/src/update-checker.js +0 -171
  168. package/dist/src/utils/audio-convert.d.ts +0 -98
  169. package/dist/src/utils/audio-convert.js +0 -783
  170. package/dist/src/utils/chunked-upload.d.ts +0 -68
  171. package/dist/src/utils/chunked-upload.js +0 -341
  172. package/dist/src/utils/file-utils.d.ts +0 -61
  173. package/dist/src/utils/file-utils.js +0 -172
  174. package/dist/src/utils/image-size.d.ts +0 -51
  175. package/dist/src/utils/image-size.js +0 -234
  176. package/dist/src/utils/media-send.d.ts +0 -158
  177. package/dist/src/utils/media-send.js +0 -502
  178. package/dist/src/utils/media-tags.d.ts +0 -14
  179. package/dist/src/utils/media-tags.js +0 -165
  180. package/dist/src/utils/payload.d.ts +0 -112
  181. package/dist/src/utils/payload.js +0 -186
  182. package/dist/src/utils/pkg-version.d.ts +0 -5
  183. package/dist/src/utils/pkg-version.js +0 -61
  184. package/dist/src/utils/platform.d.ts +0 -137
  185. package/dist/src/utils/platform.js +0 -390
  186. package/dist/src/utils/ssrf-guard.d.ts +0 -25
  187. package/dist/src/utils/ssrf-guard.js +0 -91
  188. package/dist/src/utils/text-parsing.d.ts +0 -31
  189. package/dist/src/utils/text-parsing.js +0 -74
  190. package/dist/src/utils/upload-cache.d.ts +0 -34
  191. package/dist/src/utils/upload-cache.js +0 -93
  192. package/node_modules/@eshaz/web-worker/LICENSE +0 -201
  193. package/node_modules/@eshaz/web-worker/README.md +0 -134
  194. package/node_modules/@eshaz/web-worker/browser.js +0 -17
  195. package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
  196. package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
  197. package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
  198. package/node_modules/@eshaz/web-worker/node.js +0 -223
  199. package/node_modules/@eshaz/web-worker/package.json +0 -54
  200. package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
  201. package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
  202. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
  203. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
  204. package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
  205. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
  206. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
  207. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
  208. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
  209. package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
  210. package/node_modules/mpg123-decoder/README.md +0 -265
  211. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
  212. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
  213. package/node_modules/mpg123-decoder/index.js +0 -8
  214. package/node_modules/mpg123-decoder/package.json +0 -58
  215. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
  216. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
  217. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
  218. package/node_modules/mpg123-decoder/types.d.ts +0 -30
  219. package/node_modules/silk-wasm/LICENSE +0 -21
  220. package/node_modules/silk-wasm/README.md +0 -85
  221. package/node_modules/silk-wasm/lib/index.cjs +0 -16
  222. package/node_modules/silk-wasm/lib/index.d.ts +0 -70
  223. package/node_modules/silk-wasm/lib/index.mjs +0 -16
  224. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  225. package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
  226. package/node_modules/silk-wasm/package.json +0 -39
  227. package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
  228. package/node_modules/simple-yenc/.prettierignore +0 -1
  229. package/node_modules/simple-yenc/LICENSE +0 -7
  230. package/node_modules/simple-yenc/README.md +0 -163
  231. package/node_modules/simple-yenc/dist/esm.js +0 -1
  232. package/node_modules/simple-yenc/dist/index.js +0 -1
  233. package/node_modules/simple-yenc/package.json +0 -50
  234. package/node_modules/simple-yenc/rollup.config.js +0 -27
  235. package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
  236. package/node_modules/ws/LICENSE +0 -20
  237. package/node_modules/ws/README.md +0 -548
  238. package/node_modules/ws/browser.js +0 -8
  239. package/node_modules/ws/index.js +0 -13
  240. package/node_modules/ws/lib/buffer-util.js +0 -131
  241. package/node_modules/ws/lib/constants.js +0 -19
  242. package/node_modules/ws/lib/event-target.js +0 -292
  243. package/node_modules/ws/lib/extension.js +0 -203
  244. package/node_modules/ws/lib/limiter.js +0 -55
  245. package/node_modules/ws/lib/permessage-deflate.js +0 -528
  246. package/node_modules/ws/lib/receiver.js +0 -706
  247. package/node_modules/ws/lib/sender.js +0 -602
  248. package/node_modules/ws/lib/stream.js +0 -161
  249. package/node_modules/ws/lib/subprotocol.js +0 -62
  250. package/node_modules/ws/lib/validation.js +0 -152
  251. package/node_modules/ws/lib/websocket-server.js +0 -554
  252. package/node_modules/ws/lib/websocket.js +0 -1393
  253. package/node_modules/ws/package.json +0 -69
  254. package/node_modules/ws/wrapper.mjs +0 -8
  255. package/scripts/postinstall-link-sdk.js +0 -170
  256. package/skills/qqbot-media/SKILL.md +0 -60
  257. package/src/admin-resolver.ts +0 -181
  258. package/src/api.ts +0 -1333
  259. package/src/credential-backup.ts +0 -72
  260. package/src/deliver-debounce.ts +0 -229
  261. package/src/gateway.ts +0 -2272
  262. package/src/group-history.ts +0 -328
  263. package/src/image-server.ts +0 -675
  264. package/src/inbound-attachments.ts +0 -321
  265. package/src/known-users.ts +0 -353
  266. package/src/message-gating.ts +0 -190
  267. package/src/message-queue.ts +0 -354
  268. package/src/onboarding.ts +0 -282
  269. package/src/outbound-deliver.ts +0 -486
  270. package/src/outbound.ts +0 -1179
  271. package/src/ref-index-store.ts +0 -412
  272. package/src/reply-dispatcher.ts +0 -334
  273. package/src/session-store.ts +0 -303
  274. package/src/slash-commands.ts +0 -2431
  275. package/src/startup-greeting.ts +0 -120
  276. package/src/streaming.ts +0 -1077
  277. package/src/stt.ts +0 -86
  278. package/src/tools/channel.ts +0 -275
  279. package/src/transport/index.ts +0 -11
  280. package/src/transport/webhook-transport.ts +0 -332
  281. package/src/transport/webhook-verify.ts +0 -119
  282. package/src/typing-keepalive.ts +0 -59
  283. package/src/utils/audio-convert.ts +0 -887
  284. package/src/utils/chunked-upload.ts +0 -483
  285. package/src/utils/file-utils.ts +0 -193
  286. package/src/utils/image-size.ts +0 -266
  287. package/src/utils/media-send.ts +0 -636
  288. package/src/utils/media-tags.ts +0 -183
  289. package/src/utils/payload.ts +0 -265
  290. package/src/utils/text-parsing.ts +0 -85
  291. package/src/utils/upload-cache.ts +0 -128
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Gateway 生命周期管理
3
+ *
4
+ * 封装 startAccount / logoutAccount 的业务逻辑:
5
+ * - 凭证恢复
6
+ * - QQBotGateway 实例创建与注册
7
+ * - Features 初始化(update-checker、approval-handler)
8
+ * - 登出时凭证清除
9
+ */
10
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
11
+ import type { ResolvedQQBotAccount } from '../types.js';
12
+ import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount, applyQQBotAccountConfig } from '../config.js';
13
+ import { getQQBotRuntime } from '../runtime.js';
14
+ import { getAdapters } from '../adapter/resolve.js';
15
+ import { QQBotGateway } from './qqbot-gateway.js';
16
+ import { createPluginLogger } from '../utils/plugin-logger.js';
17
+ import type { PluginLogger } from '../utils/plugin-logger.js';
18
+ import { registerGateway, unregisterGateway, getGateway } from '../outbound/outbound-service.js';
19
+ import { saveCredentialBackup, loadCredentialBackup } from '../features/credential-backup.js';
20
+ import { triggerUpdateCheck } from '../features/update-checker.js';
21
+ import { QQBotApprovalHandler, registerApprovalHandler, unregisterApprovalHandler, getApprovalHandler } from '../features/approval-handler.js';
22
+
23
+ export interface StartAccountContext {
24
+ account: ResolvedQQBotAccount;
25
+ abortSignal?: AbortSignal;
26
+ cfg: any;
27
+ /**
28
+ * 框架传入的基础 logger(无 child 方法)。内部会自动包装为 PluginLogger。
29
+ * 写日志的优先级:info > warn > error > debug,方法均为必选。
30
+ */
31
+ log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug: (msg: string) => void };
32
+ getStatus: () => Record<string, unknown>;
33
+ setStatus: (s: Record<string, unknown>) => void;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ /**
38
+ * 启动账户(含凭证恢复 + features 初始化)
39
+ */
40
+ export async function startAccountWithCredentialRecovery(ctx: StartAccountContext): Promise<void> {
41
+ let { account } = ctx;
42
+ const { abortSignal, cfg } = ctx;
43
+ const log: PluginLogger = createPluginLogger({
44
+ prefix: `[${account.accountId}]`,
45
+ ...(ctx.log?.info ? { output: ctx.log as PluginLogger } : {}),
46
+ });
47
+ const runtime = getQQBotRuntime();
48
+
49
+ // 凭证恢复:配置中 appId/secret 为空时尝试从暂存文件恢复
50
+ if (!account.appId || !account.clientSecret) {
51
+ const backup = loadCredentialBackup(account.accountId);
52
+ if (backup) {
53
+ log?.info(`[qqbot:${account.accountId}] 从暂存文件恢复凭证 (appId=${backup.appId})`);
54
+ try {
55
+ const restoredCfg = applyQQBotAccountConfig(cfg, account.accountId, {
56
+ appId: backup.appId,
57
+ clientSecret: backup.clientSecret,
58
+ });
59
+ const adapters = getAdapters(runtime);
60
+ if (adapters.persistConfig) {
61
+ await adapters.persistConfig(() => restoredCfg);
62
+ }
63
+ account = resolveQQBotAccount(restoredCfg, account.accountId);
64
+ } catch (e) {
65
+ log?.error(`[qqbot:${account.accountId}] 凭证恢复失败: ${e}`);
66
+ }
67
+ }
68
+ }
69
+
70
+ // 创建 gateway 实例并注册
71
+ const gw = new QQBotGateway(account, runtime, log);
72
+ registerGateway(account.accountId, gw);
73
+
74
+ await gw.start(
75
+ {
76
+ onReady: () => {
77
+ saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
78
+ ctx.setStatus({
79
+ ...ctx.getStatus(),
80
+ running: true,
81
+ connected: true,
82
+ lastConnectedAt: Date.now(),
83
+ });
84
+
85
+ // ── Features 初始化(gateway ready 后触发)──
86
+ initFeatures(account, cfg, log).catch((e) => {
87
+ log?.error(`[qqbot:${account.accountId}] initFeatures error: ${e}`);
88
+ });
89
+ },
90
+ onError: (error) => {
91
+ log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
92
+ ctx.setStatus({ ...ctx.getStatus(), lastError: error.message });
93
+ },
94
+ },
95
+ abortSignal,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Gateway ready 后初始化各 feature 模块
101
+ */
102
+ async function initFeatures(account: ResolvedQQBotAccount, cfg: any, log: PluginLogger): Promise<void> {
103
+ // 1. 版本更新检测(后台预热,fire-and-forget)
104
+ triggerUpdateCheck(log);
105
+
106
+ const existing = getApprovalHandler(account.accountId);
107
+ if (existing) {
108
+ await existing.stop();
109
+ unregisterApprovalHandler(account.accountId);
110
+ }
111
+ const approvalLog = log.child('approval');
112
+ try {
113
+ const handler = new QQBotApprovalHandler({
114
+ accountId: account.accountId,
115
+ appId: account.appId,
116
+ clientSecret: account.clientSecret,
117
+ cfg,
118
+ log: approvalLog,
119
+ });
120
+ registerApprovalHandler(account.accountId, handler);
121
+ await handler.start();
122
+ approvalLog.info('registered');
123
+ } catch (e) {
124
+ approvalLog.debug(`not available: ${e}`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 停止账户 — 主动调用,与 abort 信号双保险。
130
+ *
131
+ * 框架先 abort.abort() 让 startAccount promise 自然 resolve,
132
+ * 再调用 stopAccount 给插件机会做主动清理(关 WebSocket、注销处理器、刷新持久化)。
133
+ *
134
+ * 实现策略:
135
+ * 1. 主动调用 bot.stop() — 比 abort 信号更立刻、不依赖事件循环时机
136
+ * 2. 注销 approval handler / gateway
137
+ * 3. 立即返回;剩余资源(typing keepalive 定时器等)由 abort 信号触发收尾
138
+ */
139
+ export async function stopAccountGracefully(params: {
140
+ accountId: string;
141
+ log?: PluginLogger;
142
+ }): Promise<void> {
143
+ const { accountId, log } = params;
144
+ const gw = getGateway(accountId);
145
+
146
+ // 1. 主动停止 bot
147
+ if (gw) {
148
+ try {
149
+ await gw.stop();
150
+ log?.info(`[qqbot:${accountId}] gateway stopped`);
151
+ } catch (err) {
152
+ log?.error(`[qqbot:${accountId}] gateway stop error: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ }
155
+
156
+ unregisterGateway(accountId);
157
+ try {
158
+ const h = getApprovalHandler(accountId);
159
+ if (h) await h.stop();
160
+ } catch {
161
+ }
162
+ unregisterApprovalHandler(accountId);
163
+ }
164
+
165
+ /**
166
+ * 登出账户(清除凭证)
167
+ */
168
+ export async function logoutAndClearCredentials(params: {
169
+ accountId: string;
170
+ cfg: any;
171
+ }): Promise<{ ok: boolean; cleared: boolean; envToken: boolean; loggedOut: boolean }> {
172
+ const { accountId, cfg } = params;
173
+ unregisterGateway(accountId);
174
+ try {
175
+ const h = getApprovalHandler(accountId);
176
+ if (h) await h.stop();
177
+ } catch {
178
+ }
179
+ unregisterApprovalHandler(accountId);
180
+
181
+ const nextCfg = { ...cfg } as OpenClawConfig;
182
+ const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
183
+ let cleared = false;
184
+ let changed = false;
185
+
186
+ if (nextQQBot) {
187
+ const qqbot = nextQQBot as Record<string, unknown>;
188
+ if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
189
+ delete qqbot.clientSecret;
190
+ cleared = true;
191
+ changed = true;
192
+ }
193
+ const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
194
+ if (accounts && accountId in accounts) {
195
+ const entry = accounts[accountId];
196
+ if (entry && 'clientSecret' in entry) {
197
+ delete entry.clientSecret;
198
+ cleared = true;
199
+ changed = true;
200
+ }
201
+ if (entry && Object.keys(entry).length === 0) {
202
+ delete accounts[accountId];
203
+ changed = true;
204
+ }
205
+ }
206
+ }
207
+
208
+ if (changed && nextQQBot) {
209
+ nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
210
+ const runtime = getQQBotRuntime();
211
+ const adapters = getAdapters(runtime);
212
+ if (adapters.persistConfig) {
213
+ await adapters.persistConfig(() => nextCfg);
214
+ }
215
+ }
216
+
217
+ const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
218
+ const loggedOut = resolved.secretSource === 'none';
219
+ const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
220
+ return { ok: true, cleared, envToken, loggedOut };
221
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * SDK 中间件编排
3
+ *
4
+ * 根据账户配置组装 SDK 内置中间件链。
5
+ * 中间件负责过滤和上下文富化;concurrencyGuard 负责串行+合并,
6
+ * 合并后的消息继续走完剩余中间件链,最终统一由 bot.on("message") 处理转发。
7
+ */
8
+ import type { QQBot, MiddlewareContext } from '@tencent-connect/qqbot-nodejs';
9
+ import {
10
+ messageFilter,
11
+ contentSanitizer,
12
+ rateLimiter,
13
+ concurrencyGuard,
14
+ mentionGate,
15
+ quoteRef,
16
+ historyBuffer,
17
+ envelopeFormatter,
18
+ typingIndicator,
19
+ slashCommand,
20
+ errorHandler,
21
+ } from '@tencent-connect/qqbot-nodejs';
22
+ import type { ResolvedQQBotAccount } from '../types.js';
23
+ import { buildCommandList } from '../commands/index.js';
24
+ import { attachmentProcessor } from '../middleware/attachment.js';
25
+ import { assembleBody } from '../dispatch/body-assembler.js';
26
+ import { getPersistedRefIndexStore } from '../features/ref-index-store.js';
27
+ import { createPolicyInjector } from '../middleware/policy-injector.js';
28
+ import { getHistoryStore, historyGroupKey } from '../features/history-store.js';
29
+ import { dynamicAccessControl } from '../middleware/access-control.js';
30
+ import { stripMentionText } from '../utils/mention.js';
31
+
32
+ export interface MiddlewareSetupOptions {
33
+ /** 获取 runtime */
34
+ getRuntime: () => any;
35
+ }
36
+
37
+ /**
38
+ * 为 QQBot 实例编排完整的中间件链
39
+ */
40
+ export function setupMiddlewares(bot: QQBot, account: ResolvedQQBotAccount, opts: MiddlewareSetupOptions): void {
41
+ // 1. 错误兜底(最外层洋葱皮)
42
+ bot.use(errorHandler());
43
+
44
+ // 2. 消息过滤:bot 回声 + 消息去重
45
+ bot.use(messageFilter({ skipSelfEcho: false }));
46
+
47
+ // 3. 动态策略注入 — 每条消息注入 ctx.state.policy
48
+ // 后续 dynamicAccessControl / mentionGate / historyBuffer 自动读取
49
+ bot.use(createPolicyInjector(account));
50
+
51
+ // 4. 群历史缓冲 — 放在门控之前,确保所有消息(含未 @bot)都计入上下文
52
+ // limit 从 ctx.state.policy.group.historyLimit 读取,key 带 accountId 前缀隔离多账号
53
+ bot.use(historyBuffer({
54
+ store: getHistoryStore(),
55
+ groupKey: (ctx) => {
56
+ const gid = ctx.message.groupOpenid;
57
+ if (ctx.message.kind !== 'group' || !gid) return undefined;
58
+ return historyGroupKey(account.accountId, gid);
59
+ },
60
+ }));
61
+
62
+ // 5. 动态访问控制 — 从 ctx.state.policy 动态读取,支持 pairing
63
+ bot.use(dynamicAccessControl({
64
+ accountId: account.accountId,
65
+ getRuntime: opts.getRuntime,
66
+ }));
67
+
68
+ // 6. 群聊 @bot 门控(从 ctx.state.policy.group 读取动态配置)
69
+ bot.use(mentionGate());
70
+ // 7. 内容清洗(去 @marker、表情标签、多余空白)
71
+ // SDK 用 appId 匹配 @标记,但 QQ openid 不等于 appId,追加 stripMentionText 正确剥离
72
+ bot.use(contentSanitizer({
73
+ parseFaceTags: true,
74
+ transform: (content, ctx) => stripMentionText(content, (ctx.message as any).mentions),
75
+ }));
76
+
77
+ // 8. 三层限流(sender / group / global)
78
+ bot.use(rateLimiter());
79
+
80
+ // 9. 斜杠命令(在并发锁之前,命令匹配后直接 reply + stop,不排队)
81
+ // 依赖:ctx.state.policy(policyInjector, #3)、ctx.message.*(原始消息)
82
+ // 注意:/stop 是框架级命令,不在 qqbot 命令列表中,仍由 urgentPredicate 处理
83
+ const slash = slashCommand({ commands: buildCommandList(account, { getRuntime: opts.getRuntime }) });
84
+ bot.use(slash.middleware);
85
+
86
+ // 10. 并发串行+合并(在副作用中间件之前)
87
+ // - 同 peer 串行处理,避免平台 session conflict
88
+ // - 处理中消息暂存 buffer;完成后合并为一条,继续走完剩余中间件链
89
+ // (typingIndicator/quoteRef/attachmentProcessor/... 直到 bot.on("message"))
90
+ // - 合并时清除 assembledBody 让 dispatch.ts 用合并后 content 重建
91
+ // - 超时后 abort 处理链(取消 LLM 调用),释放锁并排空缓冲
92
+ bot.use(concurrencyGuard({
93
+ strategy: 'merge',
94
+ maxQueue: 50,
95
+ maxProcessingMs: account.processingTimeoutMs,
96
+ /** 紧急指令(/stop)跳过排队,立即处理 */
97
+ urgentPredicate: (ctx: MiddlewareContext) => {
98
+ return (ctx.message.content as string ?? '').trim() === '/stop';
99
+ },
100
+ onMerge: (buffered) => {
101
+ const last = buffered[buffered.length - 1];
102
+ if (buffered.length === 1) return last;
103
+
104
+ // 透传原始消息列表,格式拼接下沉给下游 envelopeFormatter / assembleBody
105
+ (last.state as Record<string, unknown>).mergedMessages = buffered;
106
+
107
+ // 合并附件(所有 buffer 中的附件汇总到 survivor)
108
+ const attachments = buffered.flatMap((c) => c.message.attachments ?? []);
109
+ if (attachments.length > 0) {
110
+ last.message.attachments = attachments;
111
+ }
112
+
113
+ // 清除 assembledBody,让 dispatch.ts 用合并后的 ctx 重新构建
114
+ delete (last.state as Record<string, unknown>).assembledBody;
115
+
116
+ return last;
117
+ },
118
+ }));
119
+
120
+ // 11. C2C 输入状态指示器
121
+ bot.use(typingIndicator());
122
+
123
+ // 12. 引用消息解析(默认优先 msg_elements 获取文件名等丰富信息)
124
+ bot.use(quoteRef({
125
+ store: getPersistedRefIndexStore(account.accountId),
126
+ }));
127
+
128
+ // 13. 附件处理(语音 STT 转录 + 图片/文件下载)
129
+ bot.use(attachmentProcessor({ getRuntime: opts.getRuntime }));
130
+
131
+ // 14. 上下文组装(构建框架规约的 body)
132
+ bot.use(envelopeFormatter({
133
+ format: (ctx) => {
134
+ const assembled = assembleBody(ctx, ctx.message as never, account, opts.getRuntime);
135
+ (ctx.state as Record<string, unknown>).assembledBody = assembled;
136
+ return assembled.agentBody;
137
+ },
138
+ }));
139
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * QQBotGateway — 封装单个 Bot 实例的完整生命周期
3
+ */
4
+ import {
5
+ QQBot,
6
+ FileKVStore,
7
+ kvSessionPersistence,
8
+ MediaFileType,
9
+ type ReplyTarget,
10
+ type QQBotInboundMessage,
11
+ type MiddlewareContext,
12
+ type InteractionEvent,
13
+ type MessageResponse,
14
+ type StreamSession,
15
+ } from '@tencent-connect/qqbot-nodejs';
16
+ import os from 'node:os';
17
+ import type { PluginRuntime } from 'openclaw/plugin-sdk';
18
+ import type { ResolvedQQBotAccount } from '../types.js';
19
+ import type { PluginLogger } from '../utils/plugin-logger.js';
20
+ import { createPluginLogger } from '../utils/plugin-logger.js';
21
+ import { setupMiddlewares } from './middleware-setup.js';
22
+ import { handleMessage, handleInteraction } from './event-handlers.js';
23
+ import { getQQBotDataDir } from '../utils/platform.js';
24
+ import { buildUserAgent } from '../bot-instance.js';
25
+ import { createPluginWebhookAdapter } from '../adapter/webhook.js';
26
+ import { getPersistedRefIndexStore } from '../features/ref-index-store.js';
27
+ import { getCachedMsgId } from '../features/msgid-cache.js';
28
+
29
+ export interface GatewayCallbacks {
30
+ onReady?: () => void;
31
+ onError?: (error: Error) => void;
32
+ }
33
+
34
+ export interface SendOptions {
35
+ msgId?: string;
36
+ text?: string;
37
+ }
38
+
39
+ // ── 超时常量 ──
40
+
41
+ const TEXT_TIMEOUT_MS = 30_000;
42
+ const MEDIA_TIMEOUT_MS = 300_000;
43
+
44
+ function resolveMs(envKey: string, defaultMs: number): number {
45
+ const env = process.env[envKey];
46
+ if (env) {
47
+ const v = Number(env);
48
+ if (!Number.isNaN(v) && v > 0) return v;
49
+ }
50
+ return defaultMs;
51
+ }
52
+
53
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
54
+ if (ms <= 0) return promise;
55
+ return Promise.race([
56
+ promise,
57
+ new Promise<T>((_, reject) =>
58
+ setTimeout(() => reject(new Error(`出站超时: ${label} (${ms}ms)`)), ms),
59
+ ),
60
+ ]);
61
+ }
62
+
63
+ export class QQBotGateway {
64
+ readonly bot: QQBot;
65
+ private readonly account: ResolvedQQBotAccount;
66
+ private readonly runtime: PluginRuntime;
67
+ readonly log: PluginLogger;
68
+ private readonly textTimeout: number;
69
+ private readonly mediaTimeout: number;
70
+
71
+ constructor(account: ResolvedQQBotAccount, runtime: PluginRuntime, log?: PluginLogger) {
72
+ this.textTimeout = resolveMs('OPENCLAW_OUTBOUND_TIMEOUT_MS', TEXT_TIMEOUT_MS);
73
+ this.mediaTimeout = resolveMs('OPENCLAW_OUTBOUND_MEDIA_TIMEOUT_MS', MEDIA_TIMEOUT_MS);
74
+ this.account = account;
75
+ this.runtime = runtime;
76
+ this.log = log ?? createPluginLogger({ prefix: `[qqbot:${account.accountId}]` });
77
+
78
+ const dataDir = getQQBotDataDir(account.accountId);
79
+
80
+ const isWebhook = account.config.transport === 'webhook';
81
+
82
+ this.bot = new QQBot({
83
+ appId: account.appId,
84
+ appSecret: account.clientSecret,
85
+ accountId: account.accountId,
86
+ markdownSupport: account.markdownSupport,
87
+ userAgent: buildUserAgent(account.userAgentSuffix),
88
+ baseUrl: process.env.QQBOT_BASE_URL?.replace(/\/+$/, '') || 'https://api.sgroup.qq.com',
89
+ tokenBaseUrl: process.env.QQBOT_TOKEN_BASE_URL?.replace(/\/+$/, '') || 'https://bots.qq.com',
90
+ transport: account.config.transport,
91
+ webhook: isWebhook ? { path: account.config.webhook?.path, server: createPluginWebhookAdapter({ account, log: this.log }) } : undefined,
92
+ sessionPersistence: kvSessionPersistence({
93
+ store: new FileKVStore({ dir: dataDir, fileName: 'session.json' }),
94
+ accountId: account.accountId,
95
+ }),
96
+ tokenPrefetch: 'sync',
97
+ logger: this.log,
98
+ });
99
+
100
+ // 包装 sendText/sendMedia,回复后自动写入 ref-index store
101
+ this.wrapBotSendForRefIndex();
102
+
103
+ // concurrencyGuard 的 merge 策略合并后会继续走完剩余中间件链,
104
+ // 最终与单条消息一样统一由下方 bot.on('message') 处理转发,
105
+ // 因此这里不再需要单独的 onMergeDispatch 回调。
106
+ setupMiddlewares(this.bot, account, {
107
+ getRuntime: () => runtime,
108
+ });
109
+ }
110
+
111
+ async start(callbacks?: GatewayCallbacks, signal?: AbortSignal): Promise<void> {
112
+ const handleReady = () => {
113
+ this.log.info(`Gateway ready`);
114
+ callbacks?.onReady?.();
115
+ };
116
+ this.bot.on(`ready`, handleReady);
117
+ this.bot.on(`resumed`, handleReady);
118
+
119
+
120
+ this.bot.on('error', (err: Error) => {
121
+ this.log.error(`Gateway error: ${err.message}`);
122
+ callbacks?.onError?.(err);
123
+ });
124
+
125
+ const gatewayLog = this.log.child('gateway');
126
+
127
+ this.bot.on('message', async (ctx: MiddlewareContext, msg: QQBotInboundMessage) => {
128
+ gatewayLog.debug(`message msgId=${msg.messageId}`);
129
+ try {
130
+ await handleMessage(ctx, msg, this.account, this.runtime, this.log);
131
+ } catch (err) {
132
+ gatewayLog.error(`Dispatch error: ${err instanceof Error ? err.message : String(err)}`);
133
+ }
134
+ });
135
+
136
+ this.bot.on('interaction', (_ctx, event: InteractionEvent) => {
137
+ handleInteraction(event, this.account, this.runtime, this.log, (id, code, data) =>
138
+ this.bot.acknowledgeInteraction(id, code, data),
139
+ ).catch((err) => {
140
+ this.log.error(`Interaction error: ${err}`);
141
+ });
142
+ });
143
+
144
+ await this.bot.start(signal);
145
+ }
146
+
147
+ async stop(): Promise<void> {
148
+ await this.bot.stop();
149
+ }
150
+
151
+ async sendText(target: ReplyTarget, text: string, opts?: SendOptions): Promise<MessageResponse> {
152
+ return withTimeout(
153
+ this.bot.sendText(attachMsgId(target, opts), text),
154
+ this.textTimeout, 'sendText',
155
+ );
156
+ }
157
+
158
+ async sendMedia(
159
+ target: ReplyTarget,
160
+ source: string,
161
+ opts?: SendOptions & { fileType?: MediaFileType },
162
+ ): Promise<MessageResponse> {
163
+ const resolvedTarget = attachMsgId(target, opts);
164
+ const fileType = opts?.fileType ?? MediaFileType.IMAGE;
165
+ const sourceOpts = resolveMediaSource(source);
166
+ const result = await withTimeout(
167
+ this.bot.sendMedia({ target: resolvedTarget, fileType, ...sourceOpts, content: opts?.text }),
168
+ this.mediaTimeout, 'sendMedia',
169
+ );
170
+ return result.message ?? { id: '', timestamp: Date.now() };
171
+ }
172
+
173
+ async sendVoice(
174
+ target: ReplyTarget,
175
+ source: { url?: string; base64?: string; localPath?: string },
176
+ opts?: SendOptions,
177
+ ): Promise<MessageResponse> {
178
+ const resolvedTarget = attachMsgId(target, opts);
179
+
180
+ if (source.base64) {
181
+ const result = await withTimeout(
182
+ this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VOICE, fileData: source.base64, content: opts?.text }),
183
+ this.mediaTimeout, 'sendVoice(base64)',
184
+ );
185
+ return result.message ?? { id: '', timestamp: Date.now() };
186
+ }
187
+ if (source.localPath) {
188
+ const result = await withTimeout(
189
+ this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VOICE, localPath: source.localPath, content: opts?.text }),
190
+ this.mediaTimeout, 'sendVoice(path)',
191
+ );
192
+ return result.message ?? { id: '', timestamp: Date.now() };
193
+ }
194
+ const result = await withTimeout(
195
+ this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VOICE, url: source.url!, content: opts?.text }),
196
+ this.mediaTimeout, 'sendVoice(url)',
197
+ );
198
+ return result.message ?? { id: '', timestamp: Date.now() };
199
+ }
200
+
201
+ async sendVideo(
202
+ target: ReplyTarget,
203
+ source: string,
204
+ opts?: SendOptions,
205
+ ): Promise<MessageResponse> {
206
+ const resolvedTarget = attachMsgId(target, opts);
207
+ const sourceOpts = resolveMediaSource(source);
208
+ const result = await withTimeout(
209
+ this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VIDEO, ...sourceOpts, content: opts?.text }),
210
+ this.mediaTimeout, 'sendVideo',
211
+ );
212
+ return result.message ?? { id: '', timestamp: Date.now() };
213
+ }
214
+
215
+ async sendFile(
216
+ target: ReplyTarget,
217
+ source: string,
218
+ opts?: SendOptions & { fileName?: string },
219
+ ): Promise<MessageResponse> {
220
+ const resolvedTarget = attachMsgId(target, opts);
221
+ const sourceOpts = resolveMediaSource(source);
222
+ const result = await withTimeout(
223
+ this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.FILE, ...sourceOpts, fileName: opts?.fileName, content: opts?.text }),
224
+ this.mediaTimeout, 'sendFile',
225
+ );
226
+ return result.message ?? { id: '', timestamp: Date.now() };
227
+ }
228
+
229
+ openStream(target: ReplyTarget, msgId: string): StreamSession {
230
+ return this.bot.openStream({
231
+ target: { ...target, msgId },
232
+ });
233
+ }
234
+
235
+ async sendTyping(target: ReplyTarget): Promise<void> {
236
+ await this.bot.sendTyping(target);
237
+ }
238
+
239
+ private wrapBotSendForRefIndex(): void {
240
+ const { accountId, appId } = this.account;
241
+ const senderName = this.account.config.name ?? appId;
242
+
243
+ const storeEntry = (msg: MessageResponse, content: string, scope: string, mediaKind?: string): void => {
244
+ const refIdx = msg.ext_info?.ref_idx;
245
+ if (!refIdx) return;
246
+ // 空内容降级为媒体类型标签
247
+ const finalContent = content || mediaKind
248
+ ? mediaKind === 'voice' ? '[语音]'
249
+ : mediaKind === 'image' ? '[图片]'
250
+ : mediaKind ? `[${mediaKind}]`
251
+ : content
252
+ : '';
253
+ const entry = {
254
+ messageId: msg.id, content: finalContent, senderId: appId, senderName,
255
+ timestamp: typeof msg.timestamp === 'number' ? new Date(msg.timestamp).toISOString() : msg.timestamp,
256
+ isBot: true, scope,
257
+ };
258
+ getPersistedRefIndexStore(accountId).set(refIdx, entry as any);
259
+ };
260
+
261
+ // sendText → 直接捕获 text content
262
+ const origSendText = this.bot.sendText.bind(this.bot);
263
+ this.bot.sendText = async (target, text, ...rest) => {
264
+ const result = await origSendText(target, text, ...rest);
265
+ storeEntry(result, text, target.scope);
266
+ return result;
267
+ };
268
+
269
+ // sendMedia → ext_info 在 result.message 里
270
+ const origSendMedia = this.bot.sendMedia.bind(this.bot);
271
+ this.bot.sendMedia = async (params: any) => {
272
+ const result = await origSendMedia(params);
273
+ const msg = (result as any).message as MessageResponse | undefined;
274
+ if (msg) storeEntry(msg, '', params.target?.scope ?? '', params.mediaKind);
275
+ return result;
276
+ };
277
+
278
+ // openStream → 流式消息 complete() 时才返回 ext_info.ref_idx
279
+ const origOpenStream = this.bot.openStream.bind(this.bot);
280
+ this.bot.openStream = (opts) => {
281
+ const session = origOpenStream(opts);
282
+ let lastContent = '';
283
+ const origUpdate = session.update.bind(session);
284
+ session.update = async (content: string) => {
285
+ lastContent = content;
286
+ return origUpdate(content);
287
+ };
288
+ const origComplete = session.complete.bind(session);
289
+ session.complete = async (): Promise<any> => {
290
+ const result = await origComplete();
291
+ if (result?.ext_info?.ref_idx) {
292
+ getPersistedRefIndexStore(accountId).set(result.ext_info.ref_idx, {
293
+ messageId: result.id, content: lastContent, senderId: appId, senderName,
294
+ timestamp: typeof result.timestamp === 'number' ? new Date(result.timestamp).toISOString() : result.timestamp,
295
+ isBot: true, scope: opts.target?.scope ?? '',
296
+ } as any);
297
+ }
298
+ return result;
299
+ };
300
+ return session;
301
+ };
302
+ }
303
+ }
304
+
305
+ function attachMsgId(target: ReplyTarget, opts?: SendOptions): ReplyTarget {
306
+ if (opts?.msgId) return { ...target, msgId: opts.msgId };
307
+ // 无显式 msgId 时尝试从缓存获取
308
+ const cached = getCachedMsgId(target.scope, target.targetId);
309
+ return cached ? { ...target, msgId: cached } : target;
310
+ }
311
+
312
+ function resolveMediaSource(source: string): { url?: string; localPath?: string; fileData?: string } {
313
+ if (source.startsWith('data:')) {
314
+ const commaIdx = source.indexOf(',');
315
+ if (commaIdx > 0) {
316
+ return { fileData: source.slice(commaIdx + 1) };
317
+ }
318
+ return { fileData: source };
319
+ }
320
+ if (source.startsWith('http://') || source.startsWith('https://')) {
321
+ return { url: source };
322
+ }
323
+ if (source.startsWith('file://')) {
324
+ let p = source.slice('file://'.length);
325
+ if (/^\/[a-zA-Z]:[\\/]/.test(p)) p = p.slice(1);
326
+ try { p = decodeURIComponent(p); } catch {}
327
+ return { localPath: p };
328
+ }
329
+ if (source === '~' || source.startsWith('~/') || source.startsWith('~\\')) {
330
+ return { localPath: source.replace(/^~/, os.homedir()) };
331
+ }
332
+ if (
333
+ source.startsWith('/') ||
334
+ source.startsWith('./') || source.startsWith('../') ||
335
+ source.startsWith('.\\') || source.startsWith('..\\') ||
336
+ /^[a-zA-Z]:[\\/]/.test(source) ||
337
+ source.startsWith('\\\\')
338
+ ) {
339
+ return { localPath: source };
340
+ }
341
+ return { url: source };
342
+ }