@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,173 @@
1
+ /**
2
+ * SDK 中间件编排
3
+ *
4
+ * 根据账户配置组装 SDK 内置中间件链。
5
+ * 中间件负责过滤和上下文富化。
6
+ *
7
+ * 并发控制说明(群聊/私聊差异化):
8
+ * - 群聊:使用消息合并中间件,所有消息都应该被处理,快速消息应该被合并
9
+ * - 私聊:依赖 OpenClaw 框架的 session lane 机制,用户可以"插嘴"(新消息取消旧消息)
10
+ * - 这样与 Telegram 等其他 channel 保持一致
11
+ */
12
+ import type { QQBot, MiddlewareContext } from '@tencent-connect/qqbot-nodejs';
13
+ import {
14
+ messageFilter,
15
+ contentSanitizer,
16
+ rateLimiter,
17
+ mentionGate,
18
+ quoteRef,
19
+ historyBuffer,
20
+ envelopeFormatter,
21
+ slashCommand,
22
+ errorHandler,
23
+ } from '@tencent-connect/qqbot-nodejs';
24
+ import type { ResolvedQQBotAccount } from '../types.js';
25
+ import { buildCommandList } from '../commands/index.js';
26
+ import { attachmentProcessor } from '../middleware/attachment.js';
27
+ import { assembleBody } from '../dispatch/body-assembler.js';
28
+ import { getPersistedRefIndexStore } from '../features/ref-index-store.js';
29
+ import { createPolicyInjector } from '../middleware/policy-injector.js';
30
+ import { getHistoryStore, historyGroupKey } from '../features/history-store.js';
31
+ import { dynamicAccessControl } from '../middleware/access-control.js';
32
+ import { inboundGuard } from '../middleware/inbound-guard.js';
33
+ import { secretCapture } from '../middleware/secret-capture.js';
34
+ import { c2cTypingIndicator } from '../middleware/typing.js';
35
+ import { stripMentionText } from '../utils/mention.js';
36
+ import { resolveGroupConfigFromAccount } from '../config.js';
37
+
38
+ export interface MiddlewareSetupOptions {
39
+ /** 获取 runtime */
40
+ getRuntime: () => any;
41
+ }
42
+
43
+ /**
44
+ * 为 QQBot 实例编排完整的中间件链
45
+ */
46
+ export function setupMiddlewares(bot: QQBot, account: ResolvedQQBotAccount, opts: MiddlewareSetupOptions): void {
47
+ // 1. 错误兜底(最外层洋葱皮)
48
+ bot.use(errorHandler());
49
+
50
+ // 2. 消息过滤:bot 回声 + 消息去重
51
+ // skipSelfEcho 必须开启:SDK 注明 QQ 会把 bot 自身出站消息回投为入站
52
+ // 事件(群聊尤其常见,按 author.bot 识别);私聊回声无该标记,
53
+ // 由 inboundGuard 的出站 id 比对兜底
54
+ bot.use(messageFilter({ skipSelfEcho: true }));
55
+
56
+ // 2.5 入站事件守卫:出站回声(c2c)+ 重复推送长窗口去重 + 空内容事件
57
+ // 三个检查都打 INFO 日志留痕;详见 src/middleware/inbound-guard.ts
58
+ bot.use(inboundGuard({ accountId: account.accountId }));
59
+
60
+ // 3. 动态策略注入 — 每条消息注入 ctx.state.policy
61
+ // 后续 dynamicAccessControl / mentionGate / historyBuffer 自动读取
62
+ bot.use(createPolicyInjector(account));
63
+
64
+ // 4. 群历史缓冲 — 放在门控之前,确保所有消息(含未 @bot)都计入上下文
65
+ // limit 从 ctx.state.policy.group.historyLimit 读取,key 带 accountId 前缀隔离多账号;
66
+ // room_event 群返回 undefined 跳过记录——该模式下每条消息已作为入站事件
67
+ // 进框架持久化 transcript,插件历史快照会造成双份上下文
68
+ bot.use(historyBuffer({
69
+ store: getHistoryStore(),
70
+ groupKey: (ctx) => {
71
+ const gid = ctx.message.groupOpenid;
72
+ if (ctx.message.kind !== 'group' || !gid) return undefined;
73
+ if (resolveGroupConfigFromAccount(account, gid).unmentionedInbound === 'room_event') {
74
+ return undefined;
75
+ }
76
+ return historyGroupKey(account.accountId, gid);
77
+ },
78
+ }));
79
+
80
+ // 5. 动态访问控制 — 从 ctx.state.policy 动态读取,支持 pairing
81
+ bot.use(dynamicAccessControl({
82
+ accountId: account.accountId,
83
+ getRuntime: opts.getRuntime,
84
+ }));
85
+
86
+ // 6. 群聊 @bot 门控(从 ctx.state.policy.group 读取动态配置)
87
+ // isImplicitMention:引用 bot 出站消息 = 隐式被点名(对齐 telegram
88
+ // reply-to-bot 语义)。ref-index get 为同步内存查询,可直接放同步回调;
89
+ // 仅全量模式(GROUP_MESSAGE_CREATE)下未 @ 消息才可能带引用进到这里。
90
+ bot.use(mentionGate({
91
+ isImplicitMention: (ctx) => {
92
+ const msg = ctx.message as { refMsgIdx?: string };
93
+ if (!msg?.refMsgIdx) return false;
94
+ try {
95
+ const entry = getPersistedRefIndexStore(account.accountId).get(msg.refMsgIdx) as
96
+ | { isBot?: boolean }
97
+ | undefined;
98
+ return entry?.isBot === true;
99
+ } catch (err) {
100
+ ctx.log.debug?.(`[mention] implicit quote-bot check failed: ${err instanceof Error ? err.message : String(err)}`);
101
+ return false;
102
+ }
103
+ },
104
+ }));
105
+ // 7. 内容清洗(去 @marker、表情标签、多余空白)
106
+ // SDK 用 appId 匹配 @标记,但 QQ openid 不等于 appId,追加 stripMentionText 正确剥离
107
+ bot.use(contentSanitizer({
108
+ parseFaceTags: true,
109
+ transform: (content, ctx) => stripMentionText(content, (ctx.message as any).mentions),
110
+ }));
111
+
112
+ // 8. 三层限流(sender / group / global)
113
+ // 默认启用保守阈值(20/min、60/min、300/min),正常使用不会触发;
114
+ // 触发即 INFO 留痕(不自动回复,避免烧被动配额/刷屏)。账号级
115
+ // channels.qqbot.rateLimit 可覆盖或 {enabled:false} 关闭。
116
+ {
117
+ const rl = account.config.rateLimit ?? {};
118
+ if (rl.enabled !== false) {
119
+ bot.use(rateLimiter({
120
+ perSender: rl.perSender ?? { max: 20, windowMs: 60_000 },
121
+ perGroup: rl.perGroup ?? { max: 60, windowMs: 60_000 },
122
+ global: rl.global ?? { max: 300, windowMs: 60_000 },
123
+ onLimit: (ctx, tier) => {
124
+ ctx.log.info?.(
125
+ `[rate-limit] dropped (${tier}) sender=${(ctx.message as any).senderId ?? '-'} group=${(ctx.message as any).groupOpenid ?? '-'}`,
126
+ );
127
+ },
128
+ }));
129
+ }
130
+ }
131
+
132
+ // 9. 斜杠命令(命令匹配后直接 reply + stop)
133
+ // 依赖:ctx.state.policy(policyInjector, #3)、ctx.message.*(原始消息)
134
+ const slash = slashCommand({ commands: buildCommandList(account, { getRuntime: opts.getRuntime }) });
135
+ bot.use(slash.middleware);
136
+
137
+ // 9.5 密钥捕获 — pending 密钥输入时拦截该 c2c 用户的下一条文本消息,
138
+ // 就地执行 openclaw secrets store set 并直接回执,消息不进框架
139
+ // (密钥绝不进入 AI 转录)。放在斜杠命令之后:/bot-* 优先可用;
140
+ // 拦截后不再触发 typing / envelope;多问题 ask_user 答案消息红线放行
141
+ bot.use(secretCapture({ accountId: account.accountId }));
142
+
143
+ // 10. 群聊消息排队已完全交给框架队列(dispatch 侧按 coalesce.enabled 传
144
+ // queueModeOverride collect/followup),插件内不再做 busy-buffering。
145
+
146
+ // 11. C2C 输入状态指示器(配额感知:优先占被动回复配额,耗尽后与回复
147
+ // 消息一样降级为主动发送;续期间隔默认 20s 且不低于 20s,
148
+ // 详见 src/middleware/typing.ts)
149
+ const typingCfg = account.config.typing;
150
+ if (typingCfg?.enabled !== false) {
151
+ bot.use(c2cTypingIndicator({
152
+ accountId: account.accountId,
153
+ intervalMs: typingCfg?.intervalMs,
154
+ }));
155
+ }
156
+
157
+ // 12. 引用消息解析(默认优先 msg_elements 获取文件名等丰富信息)
158
+ bot.use(quoteRef({
159
+ store: getPersistedRefIndexStore(account.accountId),
160
+ }));
161
+
162
+ // 13. 附件处理(语音 STT 转录 + 图片/文件下载)
163
+ bot.use(attachmentProcessor({ getRuntime: opts.getRuntime }));
164
+
165
+ // 14. 上下文组装(构建框架规约的 body)
166
+ bot.use(envelopeFormatter({
167
+ format: (ctx) => {
168
+ const assembled = assembleBody(ctx, ctx.message as never, account, opts.getRuntime);
169
+ (ctx.state as Record<string, unknown>).assembledBody = assembled;
170
+ return assembled.agentBody;
171
+ },
172
+ }));
173
+ }
@@ -0,0 +1,458 @@
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 { recordOutboundMessageId } from '../features/outbound-echo-store.js';
28
+ import { recordOutboundToGroupHistory } from '../features/history-store.js';
29
+ import { resolveGroupConfigFromAccount } from '../config.js';
30
+ import { checkAndConsumePassiveReplyQuota } from '../features/quota-manager.js';
31
+ import { recordProactiveSend } from '../features/proactive-budget.js';
32
+ import { getCachedMsgId } from '../features/msgid-cache.js';
33
+ import { notifyOutboundMessageSent } from '../features/typing-refresh.js';
34
+
35
+ export interface GatewayCallbacks {
36
+ onReady?: () => void;
37
+ onError?: (error: Error) => void;
38
+ }
39
+
40
+ export interface SendOptions {
41
+ msgId?: string;
42
+ text?: string;
43
+ }
44
+
45
+ // ── 超时常量 ──
46
+
47
+ const TEXT_TIMEOUT_MS = 30_000;
48
+ const MEDIA_TIMEOUT_MS = 300_000;
49
+
50
+ function resolveMs(envKey: string, defaultMs: number): number {
51
+ const env = process.env[envKey];
52
+ if (env) {
53
+ const v = Number(env);
54
+ if (!Number.isNaN(v) && v > 0) return v;
55
+ }
56
+ return defaultMs;
57
+ }
58
+
59
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
60
+ if (ms <= 0) return promise;
61
+ return Promise.race([
62
+ promise,
63
+ new Promise<T>((_, reject) =>
64
+ setTimeout(() => reject(new Error(`出站超时: ${label} (${ms}ms)`)), ms),
65
+ ),
66
+ ]);
67
+ }
68
+
69
+ export class QQBotGateway {
70
+ readonly bot: QQBot;
71
+ private readonly account: ResolvedQQBotAccount;
72
+ private readonly runtime: PluginRuntime;
73
+ readonly log: PluginLogger;
74
+ private readonly textTimeout: number;
75
+ private readonly mediaTimeout: number;
76
+
77
+ constructor(account: ResolvedQQBotAccount, runtime: PluginRuntime, log?: PluginLogger) {
78
+ this.textTimeout = resolveMs('OPENCLAW_OUTBOUND_TIMEOUT_MS', TEXT_TIMEOUT_MS);
79
+ this.mediaTimeout = resolveMs('OPENCLAW_OUTBOUND_MEDIA_TIMEOUT_MS', MEDIA_TIMEOUT_MS);
80
+ this.account = account;
81
+ this.runtime = runtime;
82
+ this.log = log ?? createPluginLogger({ prefix: `[qqbot:${account.accountId}]` });
83
+
84
+ const dataDir = getQQBotDataDir(account.accountId);
85
+
86
+ const isWebhook = account.config.transport === 'webhook';
87
+
88
+ this.bot = new QQBot({
89
+ appId: account.appId,
90
+ appSecret: account.clientSecret,
91
+ accountId: account.accountId,
92
+ markdownSupport: account.markdownSupport,
93
+ userAgent: buildUserAgent(account.userAgentSuffix),
94
+ baseUrl: process.env.QQBOT_BASE_URL?.replace(/\/+$/, '') || 'https://api.sgroup.qq.com',
95
+ tokenBaseUrl: process.env.QQBOT_TOKEN_BASE_URL?.replace(/\/+$/, '') || 'https://bots.qq.com',
96
+ transport: account.config.transport,
97
+ webhook: isWebhook ? { path: account.config.webhook?.path, server: createPluginWebhookAdapter({ account, log: this.log }) } : undefined,
98
+ sessionPersistence: kvSessionPersistence({
99
+ store: new FileKVStore({ dir: dataDir, fileName: 'session.json' }),
100
+ accountId: account.accountId,
101
+ }),
102
+ tokenPrefetch: 'sync',
103
+ logger: this.log,
104
+ });
105
+
106
+ // 包装 sendText/sendMedia,回复后自动写入 ref-index store
107
+ this.wrapBotSendForRefIndex();
108
+
109
+ // concurrencyGuard 已移除,消息不再在此处合并
110
+ // 所有消息都直接走完中间件链,由 bot.on('message') 处理转发
111
+ // 并发控制由 OpenClaw 框架的 session lane 机制处理
112
+ setupMiddlewares(this.bot, account, {
113
+ getRuntime: () => runtime,
114
+ });
115
+ }
116
+
117
+ async start(callbacks?: GatewayCallbacks, signal?: AbortSignal): Promise<void> {
118
+ const handleReady = () => {
119
+ this.log.info(`Gateway ready`);
120
+ callbacks?.onReady?.();
121
+ };
122
+ this.bot.on(`ready`, handleReady);
123
+ this.bot.on(`resumed`, handleReady);
124
+
125
+
126
+ this.bot.on('error', (err: Error) => {
127
+ this.log.error(`Gateway error: ${err.message}`);
128
+ callbacks?.onError?.(err);
129
+ });
130
+
131
+ const gatewayLog = this.log.child('gateway');
132
+
133
+ this.bot.on('message', async (ctx: MiddlewareContext, msg: QQBotInboundMessage) => {
134
+ gatewayLog.debug(`message msgId=${msg.messageId}`);
135
+ try {
136
+ await handleMessage(ctx, msg, this.account, this.runtime, this.log);
137
+ } catch (err) {
138
+ gatewayLog.error(`Dispatch error: ${err instanceof Error ? err.message : String(err)}`);
139
+ }
140
+ });
141
+
142
+ this.bot.on('interaction', (_ctx, event: InteractionEvent) => {
143
+ handleInteraction(event, this.account, this.runtime, this.log, (id, code, data) =>
144
+ this.bot.acknowledgeInteraction(id, code, data),
145
+ ).catch((err) => {
146
+ this.log.error(`Interaction error: ${err}`);
147
+ });
148
+ });
149
+
150
+ // 平台原始事件观测:一切未被 SDK 映射为 message/interaction 的推送
151
+ // (入群申请、好友变动、reaction、media_upload_finish、未来新增类型)
152
+ // 都走这里 —— 此前被静默丢弃,是入站诊断的主要盲区
153
+ this.bot.on('rawEvent', (evt: { eventType?: string; data?: unknown }) => {
154
+ let preview = '{}';
155
+ try {
156
+ preview = JSON.stringify(evt?.data ?? {});
157
+ } catch {
158
+ preview = '<unserializable>';
159
+ }
160
+ if (preview.length > 300) preview = `${preview.slice(0, 300)}…`;
161
+ this.log.info(`[rawEvent] t=${evt?.eventType ?? '?'} payload=${preview}`);
162
+ });
163
+
164
+ await this.bot.start(signal);
165
+ }
166
+
167
+ async stop(): Promise<void> {
168
+ await this.bot.stop();
169
+ }
170
+
171
+ async sendText(target: ReplyTarget, text: string, opts?: SendOptions): Promise<MessageResponse> {
172
+ const { target: resolved, rollback } = this.attachMsgIdWithQuota(target, opts);
173
+ if (!resolved.msgId) recordProactiveSend(this.account.accountId);
174
+ try {
175
+ const result = await withTimeout(
176
+ this.bot.sendText(resolved, text),
177
+ this.textTimeout, 'sendText',
178
+ );
179
+ this.notifyTypingRefresh(target);
180
+ return result;
181
+ } catch (err) {
182
+ rollback();
183
+ throw err;
184
+ }
185
+ }
186
+
187
+ async sendMedia(
188
+ target: ReplyTarget,
189
+ source: string,
190
+ opts?: SendOptions & { fileType?: MediaFileType },
191
+ ): Promise<MessageResponse> {
192
+ const { target: resolvedTarget, rollback } = this.attachMsgIdWithQuota(target, opts);
193
+ if (!resolvedTarget.msgId) recordProactiveSend(this.account.accountId);
194
+ const fileType = opts?.fileType ?? MediaFileType.IMAGE;
195
+ const sourceOpts = resolveMediaSource(source);
196
+ try {
197
+ const result = await withTimeout(
198
+ this.bot.sendMedia({ target: resolvedTarget, fileType, ...sourceOpts, content: opts?.text }),
199
+ this.mediaTimeout, 'sendMedia',
200
+ );
201
+ this.notifyTypingRefresh(target);
202
+ return result.message ?? { id: '', timestamp: Date.now() };
203
+ } catch (err) {
204
+ rollback();
205
+ throw err;
206
+ }
207
+ }
208
+
209
+ async sendVoice(
210
+ target: ReplyTarget,
211
+ source: { url?: string; base64?: string; localPath?: string },
212
+ opts?: SendOptions,
213
+ ): Promise<MessageResponse> {
214
+ const { target: resolvedTarget, rollback } = this.attachMsgIdWithQuota(target, opts);
215
+ if (!resolvedTarget.msgId) recordProactiveSend(this.account.accountId);
216
+
217
+ const send = (params: Record<string, unknown>, label: string) =>
218
+ withTimeout(
219
+ this.bot.sendMedia({
220
+ target: resolvedTarget,
221
+ fileType: MediaFileType.VOICE,
222
+ ...params,
223
+ content: opts?.text,
224
+ } as any),
225
+ this.mediaTimeout, label,
226
+ );
227
+ try {
228
+ let result: { message?: MessageResponse };
229
+ if (source.base64) {
230
+ result = await send({ fileData: source.base64 }, 'sendVoice(base64)');
231
+ } else if (source.localPath) {
232
+ result = await send({ localPath: source.localPath }, 'sendVoice(path)');
233
+ } else {
234
+ result = await send({ url: source.url! }, 'sendVoice(url)');
235
+ }
236
+ this.notifyTypingRefresh(target);
237
+ return result.message ?? { id: '', timestamp: Date.now() };
238
+ } catch (err) {
239
+ rollback();
240
+ throw err;
241
+ }
242
+ }
243
+
244
+ async sendVideo(
245
+ target: ReplyTarget,
246
+ source: string,
247
+ opts?: SendOptions,
248
+ ): Promise<MessageResponse> {
249
+ const { target: resolvedTarget, rollback } = this.attachMsgIdWithQuota(target, opts);
250
+ if (!resolvedTarget.msgId) recordProactiveSend(this.account.accountId);
251
+ const sourceOpts = resolveMediaSource(source);
252
+ try {
253
+ const result = await withTimeout(
254
+ this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.VIDEO, ...sourceOpts, content: opts?.text }),
255
+ this.mediaTimeout, 'sendVideo',
256
+ );
257
+ this.notifyTypingRefresh(target);
258
+ return result.message ?? { id: '', timestamp: Date.now() };
259
+ } catch (err) {
260
+ rollback();
261
+ throw err;
262
+ }
263
+ }
264
+
265
+ async sendFile(
266
+ target: ReplyTarget,
267
+ source: string,
268
+ opts?: SendOptions & { fileName?: string },
269
+ ): Promise<MessageResponse> {
270
+ const { target: resolvedTarget, rollback } = this.attachMsgIdWithQuota(target, opts);
271
+ if (!resolvedTarget.msgId) recordProactiveSend(this.account.accountId);
272
+ const sourceOpts = resolveMediaSource(source);
273
+ try {
274
+ const result = await withTimeout(
275
+ this.bot.sendMedia({ target: resolvedTarget, fileType: MediaFileType.FILE, ...sourceOpts, fileName: opts?.fileName, content: opts?.text }),
276
+ this.mediaTimeout, 'sendFile',
277
+ );
278
+ this.notifyTypingRefresh(target);
279
+ return result.message ?? { id: '', timestamp: Date.now() };
280
+ } catch (err) {
281
+ rollback();
282
+ throw err;
283
+ }
284
+ }
285
+
286
+ openStream(target: ReplyTarget, msgId: string): StreamSession {
287
+ return this.bot.openStream({
288
+ target: { ...target, msgId },
289
+ });
290
+ }
291
+
292
+ async sendTyping(target: ReplyTarget): Promise<void> {
293
+ await this.bot.sendTyping(target);
294
+ }
295
+
296
+ /**
297
+ * 消息发送成功后通知活跃的 typing 会话补发续期。
298
+ * QQ 客户端收到机器人消息(含思维链等中间输出)会终止"正在输入"
299
+ * 显示;若框架任务仍在进行,typing 中间件会在 5s 后补发恢复显示。
300
+ */
301
+ private notifyTypingRefresh(target: ReplyTarget): void {
302
+ notifyOutboundMessageSent(this.account.accountId, target.scope, target.targetId);
303
+ }
304
+
305
+ private wrapBotSendForRefIndex(): void {
306
+ const { accountId, appId } = this.account;
307
+ const senderName = this.account.config.name ?? appId;
308
+
309
+ const storeEntry = (msg: MessageResponse, content: string, target: { scope: string; targetId?: string }, mediaKind?: string): void => {
310
+ // 出站 id 先登记回声表(不依赖 ext_info.ref_idx 是否返回),
311
+ // 供 inboundGuard 识别平台回投的 bot 自身消息
312
+ recordOutboundMessageId(accountId, msg.id);
313
+
314
+ // 修复运算符优先级问题:当 content 非空时直接使用,否则回退到媒体标签
315
+ let finalContent = content;
316
+ if (!content && mediaKind) {
317
+ finalContent = mediaKind === 'voice' ? '[语音]'
318
+ : mediaKind === 'image' ? '[图片]'
319
+ : mediaKind === 'video' ? '[视频]'
320
+ : mediaKind === 'file' ? '[文件]'
321
+ : `[${mediaKind}]`;
322
+ }
323
+
324
+ // rolling 历史模式:群出站记入 historyBuffer(isBot 标记,
325
+ // 供"裁剪到最后一条 bot 发言"定位;historyLimit=0 或 room_event 群
326
+ // ——上下文以框架 transcript 为准——时 no-op)
327
+ if (
328
+ target.scope === 'group' &&
329
+ target.targetId &&
330
+ resolveGroupConfigFromAccount(this.account, target.targetId).unmentionedInbound !== 'room_event'
331
+ ) {
332
+ recordOutboundToGroupHistory(accountId, target.targetId, {
333
+ messageId: msg.id,
334
+ senderId: appId,
335
+ senderName,
336
+ content: finalContent || '[消息]',
337
+ }, resolveGroupConfigFromAccount(this.account, target.targetId).historyLimit);
338
+ }
339
+
340
+ const refIdx = msg.ext_info?.ref_idx;
341
+ if (!refIdx) return;
342
+
343
+ const entry = {
344
+ messageId: msg.id, content: finalContent, senderId: appId, senderName,
345
+ timestamp: typeof msg.timestamp === 'number' ? new Date(msg.timestamp).toISOString() : msg.timestamp,
346
+ isBot: true, scope: target.scope,
347
+ };
348
+ getPersistedRefIndexStore(accountId).set(refIdx, entry as any);
349
+ };
350
+
351
+ // sendText → 直接捕获 text content
352
+ const origSendText = this.bot.sendText.bind(this.bot);
353
+ this.bot.sendText = async (target, text, ...rest) => {
354
+ const result = await origSendText(target, text, ...rest);
355
+ storeEntry(result, text, target);
356
+ return result;
357
+ };
358
+
359
+ // sendMedia → ext_info 在 result.message 里
360
+ const origSendMedia = this.bot.sendMedia.bind(this.bot);
361
+ this.bot.sendMedia = async (params: any) => {
362
+ const result = await origSendMedia(params);
363
+ const msg = (result as any).message as MessageResponse | undefined;
364
+ if (msg) storeEntry(msg, '', params.target ?? { scope: '' }, params.mediaKind);
365
+ return result;
366
+ };
367
+
368
+ // openStream → 流式消息 complete() 时才返回 ext_info.ref_idx
369
+ const origOpenStream = this.bot.openStream.bind(this.bot);
370
+ this.bot.openStream = (opts) => {
371
+ const session = origOpenStream(opts);
372
+ let lastContent = '';
373
+ const origUpdate = session.update.bind(session);
374
+ session.update = async (content: string) => {
375
+ lastContent = content;
376
+ return origUpdate(content);
377
+ };
378
+ const origComplete = session.complete.bind(session);
379
+ session.complete = async (): Promise<any> => {
380
+ const result = await origComplete();
381
+ recordOutboundMessageId(accountId, result?.id);
382
+ if (result?.ext_info?.ref_idx) {
383
+ getPersistedRefIndexStore(accountId).set(result.ext_info.ref_idx, {
384
+ messageId: result.id, content: lastContent, senderId: appId, senderName,
385
+ timestamp: typeof result.timestamp === 'number' ? new Date(result.timestamp).toISOString() : result.timestamp,
386
+ isBot: true, scope: opts.target?.scope ?? '',
387
+ } as any);
388
+ }
389
+ return result;
390
+ };
391
+ return session;
392
+ };
393
+ }
394
+
395
+ /**
396
+ * 配额感知的 msg_id 挂接(被动优先)。
397
+ *
398
+ * - 显式 msgId(opts.msgId):上游 adapter 已做配额记账(quotaReserved),
399
+ * 直接透传,不重复消费。
400
+ * - 无显式 msgId:尝试挂 msgid-cache 最新条目;挂接前经 quota-manager
401
+ * 原子预检+扣减——配额耗尽则不挂(降级主动,避免平台 40034128 硬失败),
402
+ * API 抛错时由调用方 rollback 释放已扣减的额度。
403
+ */
404
+ private attachMsgIdWithQuota(
405
+ target: ReplyTarget,
406
+ opts?: SendOptions,
407
+ ): { target: ReplyTarget; rollback: () => void } {
408
+ if (opts?.msgId) {
409
+ return { target: { ...target, msgId: opts.msgId }, rollback: () => {} };
410
+ }
411
+ const cached = getCachedMsgId(target.scope, target.targetId);
412
+ if (!cached) return { target, rollback: () => {} };
413
+
414
+ const reservation = checkAndConsumePassiveReplyQuota({
415
+ accountId: this.account.accountId,
416
+ msgId: cached,
417
+ scope: target.scope,
418
+ });
419
+ if (!reservation.canReply) {
420
+ // INFO:降级主动消耗每日预算,是出站行为的可观测分界点
421
+ this.log.info?.(`[quota] passive quota exhausted for msgId=${cached}; falling back to proactive send`);
422
+ return { target, rollback: () => {} };
423
+ }
424
+ return { target: { ...target, msgId: cached }, rollback: reservation.rollback };
425
+ }
426
+ }
427
+
428
+ function resolveMediaSource(source: string): { url?: string; localPath?: string; fileData?: string } {
429
+ if (source.startsWith('data:')) {
430
+ const commaIdx = source.indexOf(',');
431
+ if (commaIdx > 0) {
432
+ return { fileData: source.slice(commaIdx + 1) };
433
+ }
434
+ return { fileData: source };
435
+ }
436
+ if (source.startsWith('http://') || source.startsWith('https://')) {
437
+ return { url: source };
438
+ }
439
+ if (source.startsWith('file://')) {
440
+ let p = source.slice('file://'.length);
441
+ if (/^\/[a-zA-Z]:[\\/]/.test(p)) p = p.slice(1);
442
+ try { p = decodeURIComponent(p); } catch {}
443
+ return { localPath: p };
444
+ }
445
+ if (source === '~' || source.startsWith('~/') || source.startsWith('~\\')) {
446
+ return { localPath: source.replace(/^~/, os.homedir()) };
447
+ }
448
+ if (
449
+ source.startsWith('/') ||
450
+ source.startsWith('./') || source.startsWith('../') ||
451
+ source.startsWith('.\\') || source.startsWith('..\\') ||
452
+ /^[a-zA-Z]:[\\/]/.test(source) ||
453
+ source.startsWith('\\\\')
454
+ ) {
455
+ return { localPath: source };
456
+ }
457
+ return { url: source };
458
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * QQBot Gateway 适配器
3
+ *
4
+ * 账户启动/停止/登出
5
+ */
6
+
7
+ import type { ResolvedQQBotAccount } from './types.js';
8
+ import {
9
+ startAccountWithCredentialRecovery,
10
+ stopAccountGracefully,
11
+ logoutAndClearCredentials,
12
+ } from './gateway/lifecycle.js';
13
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/config-contracts';
14
+
15
+ /**
16
+ * QQBot Gateway Adapter
17
+ */
18
+ export const qqbotGatewayAdapter = {
19
+ startAccount: async (ctx: {
20
+ account: ResolvedQQBotAccount;
21
+ accountId: string;
22
+ abortSignal: AbortSignal;
23
+ cfg: OpenClawConfig;
24
+ log?: any;
25
+ getStatus: () => Record<string, unknown>;
26
+ setStatus: (status: any) => void;
27
+ }) => {
28
+ return startAccountWithCredentialRecovery(ctx);
29
+ },
30
+
31
+ stopAccount: async (ctx: { accountId: string; log?: any }) => {
32
+ await stopAccountGracefully({
33
+ accountId: ctx.accountId,
34
+ log: ctx.log,
35
+ });
36
+ },
37
+
38
+ logoutAccount: async (params: {
39
+ accountId: string;
40
+ cfg: OpenClawConfig;
41
+ }) => {
42
+ return logoutAndClearCredentials(params);
43
+ },
44
+ };