@jerryliang122/openclaw-qqbot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +967 -0
  3. package/README.zh.md +790 -0
  4. package/dist/index.cjs +18072 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +1222 -0
  7. package/index.ts +92 -0
  8. package/openclaw.plugin.json +38 -0
  9. package/package.json +69 -0
  10. package/preload.cjs +19 -0
  11. package/scripts/link-sdk-core.cjs +268 -0
  12. package/scripts/proactive-api-server.ts +369 -0
  13. package/scripts/send-proactive.ts +293 -0
  14. package/scripts/test-sendmedia.ts +116 -0
  15. package/skills/qqbot-channel/SKILL.md +285 -0
  16. package/skills/qqbot-channel/references/api_references.md +521 -0
  17. package/skills/qqbot-remind/SKILL.md +159 -0
  18. package/skills/qqbot-upgrade/SKILL.md +56 -0
  19. package/src/adapter/contract.ts +63 -0
  20. package/src/adapter/lint.ts +144 -0
  21. package/src/adapter/media.ts +40 -0
  22. package/src/adapter/pairing.ts +95 -0
  23. package/src/adapter/resolve.ts +255 -0
  24. package/src/adapter/setup.ts +13 -0
  25. package/src/adapter/webhook.ts +248 -0
  26. package/src/adapter/workspace.ts +21 -0
  27. package/src/agent-prompt-adapter.ts +26 -0
  28. package/src/bot-instance.ts +60 -0
  29. package/src/channel.ts +230 -0
  30. package/src/commands/bot-approve.ts +143 -0
  31. package/src/commands/bot-clear-storage.ts +114 -0
  32. package/src/commands/bot-group-always.ts +62 -0
  33. package/src/commands/bot-group-info.ts +48 -0
  34. package/src/commands/bot-help.ts +40 -0
  35. package/src/commands/bot-logs.ts +248 -0
  36. package/src/commands/bot-me.ts +18 -0
  37. package/src/commands/bot-pairing.ts +50 -0
  38. package/src/commands/bot-ping.ts +33 -0
  39. package/src/commands/bot-streaming.ts +55 -0
  40. package/src/commands/bot-upgrade.ts +56 -0
  41. package/src/commands/bot-version.ts +41 -0
  42. package/src/commands/config-util.ts +96 -0
  43. package/src/commands/index.ts +51 -0
  44. package/src/config.ts +403 -0
  45. package/src/constants.ts +6 -0
  46. package/src/dispatch/body-assembler.ts +308 -0
  47. package/src/dispatch/ctx-builder.ts +127 -0
  48. package/src/dispatch/dispatch.ts +667 -0
  49. package/src/dispatch/envelope-builder.ts +112 -0
  50. package/src/dispatch/index.ts +2 -0
  51. package/src/features/approval-capability.ts +302 -0
  52. package/src/features/approval-helpers.ts +271 -0
  53. package/src/features/approval-utils.ts +21 -0
  54. package/src/features/command-panel.ts +301 -0
  55. package/src/features/credential-backup.ts +74 -0
  56. package/src/features/group-mode-store.ts +79 -0
  57. package/src/features/history-store.ts +75 -0
  58. package/src/features/msgid-cache.ts +55 -0
  59. package/src/features/outbound-echo-store.ts +46 -0
  60. package/src/features/proactive-budget.ts +57 -0
  61. package/src/features/proactive.ts +549 -0
  62. package/src/features/question-helpers.ts +771 -0
  63. package/src/features/quota-manager.ts +173 -0
  64. package/src/features/ref-index-store.ts +289 -0
  65. package/src/features/secret-input-store.ts +118 -0
  66. package/src/features/secret-store-cli.ts +324 -0
  67. package/src/features/typing-refresh.ts +51 -0
  68. package/src/features/update-checker.ts +166 -0
  69. package/src/gateway/event-handlers.ts +456 -0
  70. package/src/gateway/index.ts +3 -0
  71. package/src/gateway/lifecycle.ts +236 -0
  72. package/src/gateway/middleware-setup.ts +173 -0
  73. package/src/gateway/qqbot-gateway.ts +458 -0
  74. package/src/gateway-adapter.ts +44 -0
  75. package/src/heartbeat-adapter.ts +57 -0
  76. package/src/message-adapter.ts +40 -0
  77. package/src/messaging-adapter.ts +78 -0
  78. package/src/middleware/access-control.ts +125 -0
  79. package/src/middleware/attachment.ts +373 -0
  80. package/src/middleware/inbound-guard.ts +102 -0
  81. package/src/middleware/policy-injector.ts +71 -0
  82. package/src/middleware/secret-capture.ts +161 -0
  83. package/src/middleware/typing.ts +110 -0
  84. package/src/openclaw-plugin-sdk.d.ts +543 -0
  85. package/src/outbound/chunker.ts +80 -0
  86. package/src/outbound/debounce.ts +102 -0
  87. package/src/outbound/deliver-pipeline.ts +235 -0
  88. package/src/outbound/index.ts +3 -0
  89. package/src/outbound/local-file-router.ts +145 -0
  90. package/src/outbound/media-send.ts +408 -0
  91. package/src/outbound/outbound-service.ts +298 -0
  92. package/src/outbound/reply-limiter.ts +139 -0
  93. package/src/outbound/sanitize.ts +32 -0
  94. package/src/outbound/streaming-controller.ts +332 -0
  95. package/src/outbound/target.ts +109 -0
  96. package/src/outbound-adapter.ts +323 -0
  97. package/src/plugin-base.ts +42 -0
  98. package/src/request-context.ts +50 -0
  99. package/src/runtime.ts +42 -0
  100. package/src/setup/account-key.ts +41 -0
  101. package/src/setup/finalize.ts +110 -0
  102. package/src/setup/login.ts +197 -0
  103. package/src/setup/surface.ts +40 -0
  104. package/src/status-adapter.ts +56 -0
  105. package/src/tools/platform.ts +149 -0
  106. package/src/tools/remind.ts +308 -0
  107. package/src/tools/secret-input.ts +185 -0
  108. package/src/types-augment.d.ts +54 -0
  109. package/src/types-plugin.ts +82 -0
  110. package/src/types.ts +620 -0
  111. package/src/typing-lifecycle.ts +182 -0
  112. package/src/utils/mention.ts +52 -0
  113. package/src/utils/pkg-version.ts +23 -0
  114. package/src/utils/platform.ts +459 -0
  115. package/src/utils/plugin-logger.ts +104 -0
  116. package/src/utils/ssrf-guard.ts +132 -0
  117. package/src/utils/stt.ts +150 -0
  118. package/src/utils/voice-text.ts +61 -0
  119. package/tsconfig.json +17 -0
  120. package/tsup.config.ts +64 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * 统一富媒体发送入口
3
+ *
4
+ * 职责:
5
+ * 1. 路径安全校验(只允许白名单目录下的本地文件)
6
+ * 2. 类型推断(扩展名 / MIME / 显式指定)
7
+ * 3. 路由分发(image / voice / video / file)
8
+ * 4. 语音发送失败 fallback 到文件
9
+ *
10
+ * 所有出站媒体发送(channel.outbound.sendMedia / deliver pipeline / Message 工具)
11
+ * 统一经过此入口。
12
+ */
13
+ import * as path from 'node:path';
14
+ import * as fs from 'node:fs';
15
+ import * as os from 'node:os';
16
+ import { resolveAgentWorkspace } from '../adapter/workspace.js';
17
+ import type { ReplyTarget } from '@tencent-connect/qqbot-nodejs';
18
+ import type { QQBotGateway } from '../gateway/index.js';
19
+ import { tryGetQQBotRuntime } from '../runtime.js';
20
+ import { getAdapters } from '../adapter/resolve.js';
21
+ import type { PluginLogger } from '../utils/plugin-logger.js';
22
+ import { validateRemoteUrl } from '../utils/ssrf-guard.js';
23
+ import { getGateway } from './outbound-service.js';
24
+ import { parseTarget } from './target.js';
25
+ import { checkAndConsumePassiveReplyQuota } from '../features/quota-manager.js';
26
+ import {
27
+ isLocalFilePath,
28
+ isDataUrl,
29
+ normalizePath,
30
+ inferMediaKind,
31
+ inferMediaKindFromMime,
32
+ isPathInAllowedRoots,
33
+ } from './local-file-router.js';
34
+ import type { MediaKind } from './outbound-service.js';
35
+
36
+ // ── 类型 ──
37
+
38
+ export interface SendMediaParams {
39
+ /** 目标 (qqbot:c2c:xxx / qqbot:group:xxx) */
40
+ to: string;
41
+ /** 媒体源(URL / 本地路径 / data URL) */
42
+ source: string;
43
+ /** 附带文本 */
44
+ text?: string;
45
+ /** 显式指定类型(优先级最高) */
46
+ mediaKind?: MediaKind;
47
+ /** MIME type 提示(优先级次于 mediaKind) */
48
+ mimeType?: string;
49
+ /** 被动回复 ID */
50
+ replyToId?: string;
51
+ /** 账户 ID */
52
+ accountId: string;
53
+ /** 日志 */
54
+ log?: PluginLogger;
55
+ /** Agent ID(用于解析相对路径的工作区) */
56
+ agentId?: string;
57
+ /** 上层已原子预留配额;防止同一发送重复计数。 */
58
+ quotaReserved?: boolean;
59
+ }
60
+
61
+ export interface SendMediaResult {
62
+ messageId?: string;
63
+ error?: string;
64
+ /** 是否走了 fallback 路径 */
65
+ fallback?: boolean;
66
+ }
67
+
68
+ // ── 安全常量 ──
69
+
70
+ /** 收集临时目录根路径:os.tmpdir + Unix /tmp(处理 macOS /tmp→/private/tmp 符号链接) */
71
+ function resolveTempRoots(): string[] {
72
+ const roots = new Set<string>();
73
+ try {
74
+ const tmp = os.tmpdir();
75
+ roots.add(fs.existsSync(tmp) ? fs.realpathSync(tmp) : tmp);
76
+ } catch { /* skip */ }
77
+ // Unix: 解析 /tmp 真实路径,覆盖 macOS 符号链接场景
78
+ if (process.platform !== 'win32') {
79
+ try { roots.add(fs.realpathSync('/tmp')); } catch { /* skip */ }
80
+ }
81
+ return [...roots];
82
+ }
83
+
84
+ /**
85
+ * 构建动态白名单目录列表。
86
+ *
87
+ * 从 openclaw 核心已解析的 workspaceDir 反向推导配置目录,
88
+ * 同时保留已知配置目录名以向后兼容。
89
+ */
90
+ function buildDynamicAllowedRoots(workspaceDir?: string): string[] {
91
+ const home = os.homedir();
92
+ const derivedBase = workspaceDir ? path.dirname(workspaceDir) : path.join(home, '.openclaw');
93
+ const roots: string[] = [];
94
+ const added = new Set<string>();
95
+
96
+ const addRoot = (p: string) => {
97
+ try {
98
+ const real = fs.existsSync(p) ? fs.realpathSync(p) : p;
99
+ if (!added.has(real)) {
100
+ added.add(real);
101
+ roots.push(real);
102
+ }
103
+ } catch { /* skip */ }
104
+ };
105
+
106
+ // 已知配置目录名(包括默认和开发模式)
107
+ const knownBases = [path.join(home, '.openclaw'), path.join(home, '.openclaw-dev')];
108
+
109
+ // 当前配置目录优先(从 workspaceDir 反推)
110
+ if (workspaceDir) {
111
+ addRoot(path.join(derivedBase, 'media'));
112
+ addRoot(path.join(derivedBase, 'workspace'));
113
+ addRoot(path.join(derivedBase, 'outbound'));
114
+ addRoot(workspaceDir);
115
+ }
116
+
117
+ // 所有已知配置目录(去重,确保向后兼容)
118
+ for (const base of knownBases) {
119
+ addRoot(path.join(base, 'media'));
120
+ addRoot(path.join(base, 'workspace'));
121
+ addRoot(path.join(base, 'outbound'));
122
+ }
123
+
124
+ // 临时目录
125
+ for (const t of resolveTempRoots()) addRoot(t);
126
+
127
+ return roots;
128
+ }
129
+
130
+ /** 入站 Base64 / Data URL 最大字节数(10MB) */
131
+ const MAX_DATA_URL_BYTES = 10 * 1024 * 1024;
132
+
133
+ // ── 统一入口 ──
134
+
135
+ /**
136
+ * 统一富媒体发送入口
137
+ */
138
+ export async function sendMedia(params: SendMediaParams): Promise<SendMediaResult> {
139
+ const { source, accountId, log } = params;
140
+ const mlog = log?.child('media');
141
+
142
+ if (!source) {
143
+ mlog?.error('source is empty');
144
+ return { error: 'sendMedia: source is required' };
145
+ }
146
+
147
+ // 1. 安全校验 + 路径规范化
148
+ const wsDir = resolveWorkspaceFromAgent(params.agentId);
149
+ mlog?.debug(`resolveMediaPath source=${source} agentId=${params.agentId ?? 'none'} workspaceDir=${wsDir ?? 'none'}`);
150
+ const resolved = await resolveMediaPath(source, mlog, wsDir);
151
+ if (!resolved.ok) {
152
+ mlog?.error(`resolveMediaPath failed: ${resolved.error}`);
153
+ return { error: resolved.error };
154
+ }
155
+
156
+ // 2. 推断类型
157
+ const kind = params.mediaKind
158
+ ?? (params.mimeType ? inferMediaKindFromMime(params.mimeType) : undefined)
159
+ ?? inferMediaKind(resolved.path);
160
+
161
+ // 3. 获取 gateway
162
+ const gw = getGateway(accountId);
163
+ if (!gw) {
164
+ return { error: `Bot "${accountId}" not running` };
165
+ }
166
+
167
+ const target = parseTarget(params.to);
168
+
169
+ const reservation = params.quotaReserved || !params.replyToId
170
+ ? { canReply: Boolean(params.replyToId), rollback: () => {} }
171
+ : checkAndConsumePassiveReplyQuota({
172
+ accountId,
173
+ msgId: params.replyToId,
174
+ scope: target.scope,
175
+ log,
176
+ });
177
+ const sendParams: SendMediaParams = {
178
+ ...params,
179
+ replyToId: reservation.canReply ? params.replyToId : undefined,
180
+ };
181
+
182
+ // 4. 路由分发
183
+ let result: SendMediaResult;
184
+ switch (kind) {
185
+ case 'voice':
186
+ result = await sendVoiceMedia(gw, target, resolved.path, sendParams);
187
+ break;
188
+ case 'video':
189
+ result = await sendVideoMedia(gw, target, resolved.path, sendParams);
190
+ break;
191
+ case 'file':
192
+ result = await sendFileMedia(gw, target, resolved.path, sendParams);
193
+ break;
194
+ case 'image':
195
+ default:
196
+ result = await sendImageMedia(gw, target, resolved.path, sendParams);
197
+ break;
198
+ }
199
+ if (result.error) reservation.rollback();
200
+ return result;
201
+ }
202
+
203
+ // ── 路径安全校验 ──
204
+
205
+ interface ResolveResult {
206
+ ok: true;
207
+ path: string;
208
+ isLocal: boolean;
209
+ }
210
+
211
+ interface ResolveError {
212
+ ok: false;
213
+ error: string;
214
+ }
215
+
216
+ async function resolveMediaPath(source: string, log?: SendMediaParams['log'], workspaceDir?: string): Promise<ResolveResult | ResolveError> {
217
+ const normalized = normalizePath(source);
218
+
219
+ // Data URL → 大小限制检查
220
+ if (isDataUrl(normalized)) {
221
+ if (normalized.length > MAX_DATA_URL_BYTES) {
222
+ const sizeMB = (normalized.length / (1024 * 1024)).toFixed(1);
223
+ return { ok: false, error: `Data URL 过大(${sizeMB}MB,最大 10MB)` };
224
+ }
225
+ return { ok: true, path: normalized, isLocal: false };
226
+ }
227
+
228
+ // 远程 URL → SSRF 安全检查
229
+ if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
230
+ try {
231
+ await validateRemoteUrl(normalized);
232
+ } catch (err) {
233
+ log?.warn(`SSRF blocked for media URL: ${normalized}`);
234
+ return { ok: false, error: `媒体 URL 被 SSRF 防护拦截: ${err instanceof Error ? err.message : String(err)}` };
235
+ }
236
+ return { ok: true, path: normalized, isLocal: false };
237
+ }
238
+
239
+ // 纯文件名→工作区兜底查找
240
+ if (!isLocalFilePath(normalized)) {
241
+ const resolved = resolveWorkingFile(normalized, workspaceDir);
242
+ if (resolved) {
243
+ return resolveAllowedLocalPath(resolved, workspaceDir, log);
244
+ }
245
+ return { ok: true, path: normalized, isLocal: false };
246
+ }
247
+
248
+ return resolveAllowedLocalPath(normalized, workspaceDir, log);
249
+ }
250
+
251
+ /** 所有本地路径(包括纯文件名兜底)统一经过 realpath + 根目录白名单校验。 */
252
+ function resolveAllowedLocalPath(
253
+ source: string,
254
+ workspaceDir?: string,
255
+ log?: SendMediaParams['log'],
256
+ ): ResolveResult | ResolveError {
257
+ const resolved = path.resolve(source);
258
+ if (!fs.existsSync(resolved)) {
259
+ return { ok: false, error: `File not found: ${resolved}` };
260
+ }
261
+
262
+ let real: string;
263
+ try {
264
+ real = fs.realpathSync(resolved);
265
+ } catch {
266
+ return { ok: false, error: `Cannot resolve path: ${resolved}` };
267
+ }
268
+
269
+ // 动态白名单:静态根目录 + 当前 agent 工作区
270
+ const dynamicRoots = buildDynamicAllowedRoots(workspaceDir);
271
+ const allowed = isPathInAllowedRoots(real, dynamicRoots);
272
+
273
+ if (!allowed) {
274
+ log?.warn(`path blocked — not in allowed directory: ${real}`);
275
+ return { ok: false, error: `文件路径不在允许的目录中` };
276
+ }
277
+
278
+ return { ok: true, path: real, isLocal: true };
279
+ }
280
+
281
+ /** Agent ID → workspaceDir(动态加载 plugin-sdk/health) */
282
+ function resolveWorkspaceFromAgent(agentId?: string): string | undefined {
283
+ const cfg = resolveConfigViaAdapter();
284
+ if (!cfg) return undefined;
285
+ return resolveAgentWorkspace(cfg, agentId);
286
+ }
287
+
288
+ /** 通过 adapter 获取配置 */
289
+ function resolveConfigViaAdapter(): Record<string, unknown> | undefined {
290
+ try {
291
+ const rt = tryGetQQBotRuntime();
292
+ if (!rt) return undefined;
293
+ return getAdapters(rt).getConfig?.();
294
+ } catch { return undefined; }
295
+ }
296
+
297
+ /** 纯文件名在 cwd + 工作区兜底查找 */
298
+ function resolveWorkingFile(name: string, workspaceDir?: string): string | null {
299
+ for (const p of [path.resolve(name), workspaceDir ? path.join(workspaceDir, name) : null]) {
300
+ if (p && fs.existsSync(p)) return p;
301
+ }
302
+ return null;
303
+ }
304
+
305
+ // ── 各类型 sender ──
306
+
307
+ async function sendImageMedia(
308
+ gw: QQBotGateway,
309
+ target: ReplyTarget,
310
+ source: string,
311
+ params: SendMediaParams,
312
+ ): Promise<SendMediaResult> {
313
+ try {
314
+ const result = await gw.sendMedia(target, source, {
315
+ text: params.text,
316
+ msgId: params.replyToId,
317
+ });
318
+ return { messageId: result.id };
319
+ } catch (err) {
320
+ return { error: formatErr(err) };
321
+ }
322
+ }
323
+
324
+ async function sendVoiceMedia(
325
+ gw: QQBotGateway,
326
+ target: ReplyTarget,
327
+ source: string,
328
+ params: SendMediaParams,
329
+ ): Promise<SendMediaResult> {
330
+ // 语音源路由:本地 → { localPath },URL → { url },其他 → { base64 }
331
+ const voiceSource = resolveVoiceSource(source);
332
+
333
+ try {
334
+ const result = await gw.sendVoice(target, voiceSource, {
335
+ msgId: params.replyToId,
336
+ });
337
+ return { messageId: result.id };
338
+ } catch (err) {
339
+ // 语音失败 → fallback 到文件发送
340
+ params.log?.child('media')?.warn(`sendVoice failed (${formatErr(err)}), falling back to sendFile`);
341
+ try {
342
+ const fileName = path.basename(source);
343
+ const fallback = await gw.sendFile(target, source, {
344
+ text: params.text,
345
+ msgId: params.replyToId,
346
+ fileName,
347
+ });
348
+ return { messageId: fallback.id, fallback: true };
349
+ } catch (fallbackErr) {
350
+ return { error: `voice: ${formatErr(err)} | fallback file: ${formatErr(fallbackErr)}` };
351
+ }
352
+ }
353
+ }
354
+
355
+ async function sendVideoMedia(
356
+ gw: QQBotGateway,
357
+ target: ReplyTarget,
358
+ source: string,
359
+ params: SendMediaParams,
360
+ ): Promise<SendMediaResult> {
361
+ try {
362
+ const result = await gw.sendVideo(target, source, {
363
+ text: params.text,
364
+ msgId: params.replyToId,
365
+ });
366
+ return { messageId: result.id };
367
+ } catch (err) {
368
+ return { error: formatErr(err) };
369
+ }
370
+ }
371
+
372
+ async function sendFileMedia(
373
+ gw: QQBotGateway,
374
+ target: ReplyTarget,
375
+ source: string,
376
+ params: SendMediaParams,
377
+ ): Promise<SendMediaResult> {
378
+ try {
379
+ const fileName = path.basename(source);
380
+ const result = await gw.sendFile(target, source, {
381
+ text: params.text,
382
+ msgId: params.replyToId,
383
+ fileName,
384
+ });
385
+ return { messageId: result.id };
386
+ } catch (err) {
387
+ return { error: formatErr(err) };
388
+ }
389
+ }
390
+
391
+ // ── 辅助 ──
392
+
393
+ function resolveVoiceSource(source: string): { url?: string; base64?: string; localPath?: string } {
394
+ if (source.startsWith('http://') || source.startsWith('https://')) {
395
+ return { url: source };
396
+ }
397
+ if (source.startsWith('data:') || (!source.startsWith('/') && !source.startsWith('./') && !source.startsWith('../') && !source.startsWith('~'))) {
398
+ // data URL 或纯 base64 字符串
399
+ const commaIdx = source.indexOf(',');
400
+ return { base64: commaIdx > 0 ? source.slice(commaIdx + 1) : source };
401
+ }
402
+ return { localPath: source };
403
+ }
404
+
405
+ function formatErr(err: unknown): string {
406
+ if (err instanceof Error) return err.message;
407
+ return String(err);
408
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * 出站消息服务
3
+ *
4
+ * 负责将 AI 回复通过 QQBotGateway 发送到 QQ。
5
+ * 超时保护由 QQBotGateway 内部统一处理,本层做 target 解析 + 被动回复限额管控。
6
+ */
7
+ import * as path from 'node:path';
8
+ import { MediaFileType } from '@tencent-connect/qqbot-nodejs';
9
+ import type { QQBotGateway } from '../gateway/index.js';
10
+ import type { ResolvedQQBotAccount } from '../types.js';
11
+ import { parseTarget } from './target.js';
12
+ import {
13
+ checkAndConsumePassiveReplyQuota,
14
+ clearQuotaCacheForAccount,
15
+ rollbackPassiveReplyQuota,
16
+ } from '../features/quota-manager.js';
17
+
18
+ // ── Gateway 注册表(生命周期由 channel.ts 管理)──
19
+
20
+ const gateways = new Map<string, QQBotGateway>();
21
+
22
+ /**
23
+ * 为一次实际 API 调用预留被动回复配额。
24
+ * quotaReserved=true 表示上层 adapter 已经原子预留,避免同一发送重复计数。
25
+ */
26
+ function reservePassiveReply(params: {
27
+ replyToId?: string;
28
+ accountId: string;
29
+ scope: 'c2c' | 'group';
30
+ quotaReserved?: boolean;
31
+ }): { msgId?: string; rollback: () => void } {
32
+ if (!params.replyToId) return { rollback: () => {} };
33
+ if (params.quotaReserved) {
34
+ return { msgId: params.replyToId, rollback: () => {} };
35
+ }
36
+ const reservation = checkAndConsumePassiveReplyQuota({
37
+ accountId: params.accountId,
38
+ msgId: params.replyToId,
39
+ scope: params.scope,
40
+ });
41
+ return {
42
+ msgId: reservation.canReply ? params.replyToId : undefined,
43
+ rollback: reservation.canReply ? reservation.rollback : () => {},
44
+ };
45
+ }
46
+
47
+ /**
48
+ * 尝试为 typing 指示器占用一个被动回复配额(带 msg_id 发送)。
49
+ *
50
+ * typing 通知与回复消息共享同一 msg_id 的被动回复配额,必须经统一
51
+ * quota-manager 记账。配额不可用时调用方应降级为主动发送(不带 msg_id)。
52
+ *
53
+ * @returns 是否占得被动配额;false 表示应不带 msg_id 主动发送
54
+ */
55
+ export function tryAcquirePassiveSlot(accountId: string, msgId: string | undefined): boolean {
56
+ if (!msgId) return false; // 无 msg_id 无法走被动通道
57
+ return checkAndConsumePassiveReplyQuota({ accountId, msgId, scope: 'c2c' }).canReply;
58
+ }
59
+
60
+ export function rollbackPassiveSlot(accountId: string, msgId: string | undefined): void {
61
+ if (!msgId) return;
62
+ rollbackPassiveReplyQuota({ accountId, msgId, scope: 'c2c' });
63
+ }
64
+
65
+ export function registerGateway(accountId: string, gw: QQBotGateway): void {
66
+ gateways.set(accountId, gw);
67
+ }
68
+
69
+ export function unregisterGateway(accountId: string): void {
70
+ gateways.delete(accountId);
71
+ clearQuotaCacheForAccount(accountId);
72
+ }
73
+
74
+ export function getGateway(accountId: string): QQBotGateway | undefined {
75
+ return gateways.get(accountId);
76
+ }
77
+
78
+ // ── 媒体类型映射 ──
79
+
80
+ export type MediaKind = 'image' | 'voice' | 'video' | 'file';
81
+
82
+ const MEDIA_KIND_TO_FILE_TYPE: Record<MediaKind, MediaFileType> = {
83
+ image: MediaFileType.IMAGE,
84
+ voice: MediaFileType.VOICE,
85
+ video: MediaFileType.VIDEO,
86
+ file: MediaFileType.FILE,
87
+ };
88
+
89
+ export interface SendResult {
90
+ messageId?: string;
91
+ error?: string;
92
+ errorCode?: string;
93
+ qqBizCode?: number;
94
+ }
95
+
96
+ // ── 公开 API(channel.ts / deliver-pipeline.ts 调用)──
97
+
98
+ export async function sendText(params: {
99
+ to: string;
100
+ text: string;
101
+ accountId?: string;
102
+ replyToId?: string;
103
+ account: ResolvedQQBotAccount;
104
+ quotaReserved?: boolean;
105
+ }): Promise<SendResult> {
106
+ const accountId = params.account.accountId;
107
+ const gw = gateways.get(accountId);
108
+ if (!gw) return { error: `Bot "${accountId}" not running` };
109
+ const target = parseTarget(params.to);
110
+ const reservation = reservePassiveReply({
111
+ replyToId: params.replyToId,
112
+ accountId,
113
+ scope: target.scope,
114
+ quotaReserved: params.quotaReserved,
115
+ });
116
+ try {
117
+ const result = await gw.sendText(target, params.text, { msgId: reservation.msgId });
118
+ return { messageId: result.id };
119
+ } catch (err: unknown) {
120
+ reservation.rollback();
121
+ return formatError(err);
122
+ }
123
+ }
124
+
125
+ export async function sendMedia(params: {
126
+ to: string;
127
+ text?: string;
128
+ mediaUrl: string;
129
+ mediaKind?: MediaKind;
130
+ accountId?: string;
131
+ replyToId?: string;
132
+ account: ResolvedQQBotAccount;
133
+ quotaReserved?: boolean;
134
+ }): Promise<SendResult> {
135
+ const accountId = params.account.accountId;
136
+ const gw = gateways.get(accountId);
137
+ if (!gw) return { error: `Bot "${accountId}" not running` };
138
+ const target = parseTarget(params.to);
139
+ const reservation = reservePassiveReply({
140
+ replyToId: params.replyToId,
141
+ accountId,
142
+ scope: target.scope,
143
+ quotaReserved: params.quotaReserved,
144
+ });
145
+ try {
146
+ const kind = params.mediaKind ?? 'image';
147
+ const msgId = reservation.msgId;
148
+ if (kind === 'voice') {
149
+ const source = resolveVoiceSource(params.mediaUrl);
150
+ const result = await gw.sendVoice(target, source, { text: params.text, msgId });
151
+ return { messageId: result.id };
152
+ }
153
+ if (kind === 'video') {
154
+ const result = await gw.sendVideo(target, params.mediaUrl, { text: params.text, msgId });
155
+ return { messageId: result.id };
156
+ }
157
+ if (kind === 'file') {
158
+ const result = await gw.sendFile(target, params.mediaUrl, { text: params.text, msgId });
159
+ return { messageId: result.id };
160
+ }
161
+ const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
162
+ const result = await gw.sendMedia(target, params.mediaUrl, { text: params.text, msgId, fileType });
163
+ return { messageId: result.id };
164
+ } catch (err: unknown) {
165
+ reservation.rollback();
166
+ return formatError(err);
167
+ }
168
+ }
169
+
170
+ export async function sendVoice(params: {
171
+ to: string;
172
+ source: { url?: string; base64?: string };
173
+ accountId?: string;
174
+ replyToId?: string;
175
+ account: ResolvedQQBotAccount;
176
+ quotaReserved?: boolean;
177
+ }): Promise<SendResult> {
178
+ const accountId = params.account.accountId;
179
+ const gw = gateways.get(accountId);
180
+ if (!gw) return { error: `Bot "${accountId}" not running` };
181
+ const target = parseTarget(params.to);
182
+ const reservation = reservePassiveReply({
183
+ replyToId: params.replyToId,
184
+ accountId,
185
+ scope: target.scope,
186
+ quotaReserved: params.quotaReserved,
187
+ });
188
+ try {
189
+ const result = await gw.sendVoice(target, params.source, { msgId: reservation.msgId });
190
+ return { messageId: result.id };
191
+ } catch (err: unknown) {
192
+ reservation.rollback();
193
+ return formatError(err);
194
+ }
195
+ }
196
+
197
+ export async function sendVideo(params: {
198
+ to: string;
199
+ videoUrl: string;
200
+ accountId?: string;
201
+ replyToId?: string;
202
+ account: ResolvedQQBotAccount;
203
+ quotaReserved?: boolean;
204
+ }): Promise<SendResult> {
205
+ const accountId = params.account.accountId;
206
+ const gw = gateways.get(accountId);
207
+ if (!gw) return { error: `Bot "${accountId}" not running` };
208
+ const target = parseTarget(params.to);
209
+ const reservation = reservePassiveReply({
210
+ replyToId: params.replyToId,
211
+ accountId,
212
+ scope: target.scope,
213
+ quotaReserved: params.quotaReserved,
214
+ });
215
+ try {
216
+ const result = await gw.sendVideo(target, params.videoUrl, { msgId: reservation.msgId });
217
+ return { messageId: result.id };
218
+ } catch (err: unknown) {
219
+ reservation.rollback();
220
+ return formatError(err);
221
+ }
222
+ }
223
+
224
+ // ── OutboundService(deliver-pipeline 专用)──
225
+
226
+ export class OutboundService {
227
+ constructor(private readonly gw: QQBotGateway, private readonly accountId: string) {}
228
+
229
+ async sendText(to: string, text: string, msgId?: string): Promise<SendResult> {
230
+ const target = parseTarget(to);
231
+ const reservation = reservePassiveReply({
232
+ replyToId: msgId,
233
+ accountId: this.accountId,
234
+ scope: target.scope,
235
+ });
236
+ try {
237
+ const result = await this.gw.sendText(target, text, { msgId: reservation.msgId });
238
+ return { messageId: result.id };
239
+ } catch (err: unknown) {
240
+ reservation.rollback();
241
+ return formatError(err);
242
+ }
243
+ }
244
+
245
+ async sendMedia(to: string, source: string, opts?: { text?: string; msgId?: string; mediaKind?: MediaKind }): Promise<SendResult> {
246
+ const target = parseTarget(to);
247
+ const reservation = reservePassiveReply({
248
+ replyToId: opts?.msgId,
249
+ accountId: this.accountId,
250
+ scope: target.scope,
251
+ });
252
+ try {
253
+ const kind = opts?.mediaKind ?? 'image';
254
+ const resolvedMsgId = reservation.msgId;
255
+ if (kind === 'voice') {
256
+ const voiceSource = resolveVoiceSource(source);
257
+ const result = await this.gw.sendVoice(target, voiceSource, { text: opts?.text, msgId: resolvedMsgId });
258
+ return { messageId: result.id };
259
+ }
260
+ if (kind === 'video') {
261
+ const result = await this.gw.sendVideo(target, source, { text: opts?.text, msgId: resolvedMsgId });
262
+ return { messageId: result.id };
263
+ }
264
+ if (kind === 'file') {
265
+ const result = await this.gw.sendFile(target, source, { text: opts?.text, msgId: resolvedMsgId, fileName: path.basename(source) });
266
+ return { messageId: result.id };
267
+ }
268
+ const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
269
+ const result = await this.gw.sendMedia(target, source, { text: opts?.text, msgId: resolvedMsgId, fileType });
270
+ return { messageId: result.id };
271
+ } catch (err: unknown) {
272
+ reservation.rollback();
273
+ return formatError(err);
274
+ }
275
+ }
276
+ }
277
+
278
+ // ── 辅助 ──
279
+
280
+ function resolveVoiceSource(source: string): { url?: string; base64?: string; localPath?: string } {
281
+ if (source.startsWith('http://') || source.startsWith('https://')) return { url: source };
282
+ if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) return { localPath: source };
283
+ if (source.startsWith('data:')) {
284
+ const i = source.indexOf(',');
285
+ return { base64: i > 0 ? source.slice(i + 1) : source };
286
+ }
287
+ return { base64: source };
288
+ }
289
+
290
+ function formatError(err: unknown): SendResult {
291
+ if (err instanceof Error) {
292
+ const result: SendResult = { error: err.message };
293
+ if ('code' in err) result.errorCode = String((err as any).code);
294
+ if ('qqBizCode' in err) result.qqBizCode = (err as any).qqBizCode;
295
+ return result;
296
+ }
297
+ return { error: String(err) };
298
+ }