@tencent-connect/openclaw-qqbot 1.7.1 → 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 (283) hide show
  1. package/README.md +206 -4
  2. package/README.zh.md +207 -4
  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 -18
  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 +400 -73
  13. package/scripts/upgrade-via-source.sh +126 -89
  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 +59 -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 +1 -1
  85. package/src/types-augment.d.ts +54 -0
  86. package/src/types.ts +71 -8
  87. package/src/typings/openclaw-webhook-ingress.d.ts +66 -0
  88. package/src/utils/mention.ts +52 -0
  89. package/src/utils/pkg-version.ts +84 -47
  90. package/src/utils/platform.ts +31 -7
  91. package/src/utils/plugin-logger.ts +104 -0
  92. package/src/utils/ssrf-guard.ts +32 -2
  93. package/src/utils/stt.ts +137 -0
  94. package/src/utils/voice-text.ts +61 -0
  95. package/tsconfig.json +2 -1
  96. package/tsup.config.ts +57 -0
  97. package/dist/index.d.ts +0 -17
  98. package/dist/index.js +0 -26
  99. package/dist/src/admin-resolver.d.ts +0 -33
  100. package/dist/src/admin-resolver.js +0 -157
  101. package/dist/src/api.d.ts +0 -307
  102. package/dist/src/api.js +0 -910
  103. package/dist/src/approval-handler.d.ts +0 -47
  104. package/dist/src/approval-handler.js +0 -372
  105. package/dist/src/channel.d.ts +0 -29
  106. package/dist/src/channel.js +0 -528
  107. package/dist/src/config.d.ts +0 -56
  108. package/dist/src/config.js +0 -278
  109. package/dist/src/credential-backup.d.ts +0 -31
  110. package/dist/src/credential-backup.js +0 -66
  111. package/dist/src/deliver-debounce.d.ts +0 -74
  112. package/dist/src/deliver-debounce.js +0 -174
  113. package/dist/src/gateway.d.ts +0 -18
  114. package/dist/src/gateway.js +0 -2063
  115. package/dist/src/group-history.d.ts +0 -136
  116. package/dist/src/group-history.js +0 -226
  117. package/dist/src/image-server.d.ts +0 -87
  118. package/dist/src/image-server.js +0 -570
  119. package/dist/src/inbound-attachments.d.ts +0 -60
  120. package/dist/src/inbound-attachments.js +0 -248
  121. package/dist/src/known-users.d.ts +0 -100
  122. package/dist/src/known-users.js +0 -263
  123. package/dist/src/message-gating.d.ts +0 -53
  124. package/dist/src/message-gating.js +0 -107
  125. package/dist/src/message-queue.d.ts +0 -91
  126. package/dist/src/message-queue.js +0 -257
  127. package/dist/src/onboarding.d.ts +0 -10
  128. package/dist/src/onboarding.js +0 -203
  129. package/dist/src/outbound-deliver.d.ts +0 -48
  130. package/dist/src/outbound-deliver.js +0 -392
  131. package/dist/src/outbound.d.ts +0 -220
  132. package/dist/src/outbound.js +0 -975
  133. package/dist/src/proactive.d.ts +0 -170
  134. package/dist/src/proactive.js +0 -399
  135. package/dist/src/ref-index-store.d.ts +0 -101
  136. package/dist/src/ref-index-store.js +0 -298
  137. package/dist/src/reply-dispatcher.d.ts +0 -35
  138. package/dist/src/reply-dispatcher.js +0 -311
  139. package/dist/src/request-context.d.ts +0 -25
  140. package/dist/src/request-context.js +0 -37
  141. package/dist/src/runtime.d.ts +0 -3
  142. package/dist/src/runtime.js +0 -13
  143. package/dist/src/session-store.d.ts +0 -52
  144. package/dist/src/session-store.js +0 -254
  145. package/dist/src/slash-commands.d.ts +0 -82
  146. package/dist/src/slash-commands.js +0 -2117
  147. package/dist/src/startup-greeting.d.ts +0 -30
  148. package/dist/src/startup-greeting.js +0 -97
  149. package/dist/src/streaming.d.ts +0 -247
  150. package/dist/src/streaming.js +0 -899
  151. package/dist/src/stt.d.ts +0 -21
  152. package/dist/src/stt.js +0 -70
  153. package/dist/src/tools/channel.d.ts +0 -16
  154. package/dist/src/tools/channel.js +0 -234
  155. package/dist/src/tools/remind.d.ts +0 -2
  156. package/dist/src/tools/remind.js +0 -256
  157. package/dist/src/types.d.ts +0 -443
  158. package/dist/src/types.js +0 -22
  159. package/dist/src/typing-keepalive.d.ts +0 -27
  160. package/dist/src/typing-keepalive.js +0 -64
  161. package/dist/src/update-checker.d.ts +0 -36
  162. package/dist/src/update-checker.js +0 -171
  163. package/dist/src/utils/audio-convert.d.ts +0 -98
  164. package/dist/src/utils/audio-convert.js +0 -755
  165. package/dist/src/utils/chunked-upload.d.ts +0 -68
  166. package/dist/src/utils/chunked-upload.js +0 -341
  167. package/dist/src/utils/file-utils.d.ts +0 -61
  168. package/dist/src/utils/file-utils.js +0 -172
  169. package/dist/src/utils/image-size.d.ts +0 -51
  170. package/dist/src/utils/image-size.js +0 -234
  171. package/dist/src/utils/media-send.d.ts +0 -158
  172. package/dist/src/utils/media-send.js +0 -502
  173. package/dist/src/utils/media-tags.d.ts +0 -14
  174. package/dist/src/utils/media-tags.js +0 -165
  175. package/dist/src/utils/payload.d.ts +0 -112
  176. package/dist/src/utils/payload.js +0 -186
  177. package/dist/src/utils/pkg-version.d.ts +0 -5
  178. package/dist/src/utils/pkg-version.js +0 -61
  179. package/dist/src/utils/platform.d.ts +0 -137
  180. package/dist/src/utils/platform.js +0 -390
  181. package/dist/src/utils/ssrf-guard.d.ts +0 -25
  182. package/dist/src/utils/ssrf-guard.js +0 -91
  183. package/dist/src/utils/text-parsing.d.ts +0 -31
  184. package/dist/src/utils/text-parsing.js +0 -74
  185. package/dist/src/utils/upload-cache.d.ts +0 -34
  186. package/dist/src/utils/upload-cache.js +0 -93
  187. package/node_modules/@eshaz/web-worker/LICENSE +0 -201
  188. package/node_modules/@eshaz/web-worker/README.md +0 -134
  189. package/node_modules/@eshaz/web-worker/browser.js +0 -17
  190. package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
  191. package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
  192. package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
  193. package/node_modules/@eshaz/web-worker/node.js +0 -223
  194. package/node_modules/@eshaz/web-worker/package.json +0 -54
  195. package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
  196. package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
  197. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
  198. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
  199. package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
  200. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
  201. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
  202. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
  203. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
  204. package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
  205. package/node_modules/mpg123-decoder/README.md +0 -265
  206. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
  207. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
  208. package/node_modules/mpg123-decoder/index.js +0 -8
  209. package/node_modules/mpg123-decoder/package.json +0 -58
  210. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
  211. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
  212. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
  213. package/node_modules/mpg123-decoder/types.d.ts +0 -30
  214. package/node_modules/silk-wasm/LICENSE +0 -21
  215. package/node_modules/silk-wasm/README.md +0 -85
  216. package/node_modules/silk-wasm/lib/index.cjs +0 -16
  217. package/node_modules/silk-wasm/lib/index.d.ts +0 -70
  218. package/node_modules/silk-wasm/lib/index.mjs +0 -16
  219. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  220. package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
  221. package/node_modules/silk-wasm/package.json +0 -39
  222. package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
  223. package/node_modules/simple-yenc/.prettierignore +0 -1
  224. package/node_modules/simple-yenc/LICENSE +0 -7
  225. package/node_modules/simple-yenc/README.md +0 -163
  226. package/node_modules/simple-yenc/dist/esm.js +0 -1
  227. package/node_modules/simple-yenc/dist/index.js +0 -1
  228. package/node_modules/simple-yenc/package.json +0 -50
  229. package/node_modules/simple-yenc/rollup.config.js +0 -27
  230. package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
  231. package/node_modules/ws/LICENSE +0 -20
  232. package/node_modules/ws/README.md +0 -548
  233. package/node_modules/ws/browser.js +0 -8
  234. package/node_modules/ws/index.js +0 -13
  235. package/node_modules/ws/lib/buffer-util.js +0 -131
  236. package/node_modules/ws/lib/constants.js +0 -19
  237. package/node_modules/ws/lib/event-target.js +0 -292
  238. package/node_modules/ws/lib/extension.js +0 -203
  239. package/node_modules/ws/lib/limiter.js +0 -55
  240. package/node_modules/ws/lib/permessage-deflate.js +0 -528
  241. package/node_modules/ws/lib/receiver.js +0 -706
  242. package/node_modules/ws/lib/sender.js +0 -602
  243. package/node_modules/ws/lib/stream.js +0 -161
  244. package/node_modules/ws/lib/subprotocol.js +0 -62
  245. package/node_modules/ws/lib/validation.js +0 -152
  246. package/node_modules/ws/lib/websocket-server.js +0 -554
  247. package/node_modules/ws/lib/websocket.js +0 -1393
  248. package/node_modules/ws/package.json +0 -69
  249. package/node_modules/ws/wrapper.mjs +0 -8
  250. package/scripts/postinstall-link-sdk.js +0 -126
  251. package/skills/qqbot-media/SKILL.md +0 -60
  252. package/src/admin-resolver.ts +0 -181
  253. package/src/api.ts +0 -1319
  254. package/src/credential-backup.ts +0 -72
  255. package/src/deliver-debounce.ts +0 -229
  256. package/src/gateway.ts +0 -2304
  257. package/src/group-history.ts +0 -328
  258. package/src/image-server.ts +0 -675
  259. package/src/inbound-attachments.ts +0 -321
  260. package/src/known-users.ts +0 -353
  261. package/src/message-gating.ts +0 -190
  262. package/src/message-queue.ts +0 -354
  263. package/src/onboarding.ts +0 -274
  264. package/src/outbound-deliver.ts +0 -486
  265. package/src/outbound.ts +0 -1179
  266. package/src/ref-index-store.ts +0 -412
  267. package/src/reply-dispatcher.ts +0 -334
  268. package/src/session-store.ts +0 -303
  269. package/src/slash-commands.ts +0 -2297
  270. package/src/startup-greeting.ts +0 -120
  271. package/src/streaming.ts +0 -1077
  272. package/src/stt.ts +0 -86
  273. package/src/tools/channel.ts +0 -281
  274. package/src/typing-keepalive.ts +0 -59
  275. package/src/utils/audio-convert.ts +0 -859
  276. package/src/utils/chunked-upload.ts +0 -483
  277. package/src/utils/file-utils.ts +0 -193
  278. package/src/utils/image-size.ts +0 -266
  279. package/src/utils/media-send.ts +0 -636
  280. package/src/utils/media-tags.ts +0 -183
  281. package/src/utils/payload.ts +0 -265
  282. package/src/utils/text-parsing.ts +0 -85
  283. 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
+ }