@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,323 @@
1
+ /**
2
+ * QQBot Outbound 适配器
3
+ *
4
+ * 封装 sendText/sendMedia 流式等出站操作,提供配额感知的发送能力。
5
+ *
6
+ * 两层结构:
7
+ * - createQQBotOutboundAdapter —— 内部配额包装(sendTextWithQuota / sendMediaWithQuota),
8
+ * 供中间件/流式路径使用;
9
+ * - createQQBotChannelOutbound —— 框架 ChannelOutboundAdapter 契约入口
10
+ * (sendText / sendMedia / chunker / sanitizeText 等)。
11
+ * openclaw 的 channel-selection 通过 outbound.sendText(或 message.send.text /
12
+ * deliveryMode === "gateway")判定通道可用,缺失会导致
13
+ * "Channel is unavailable: qqbot"。
14
+ */
15
+
16
+ import { checkAndConsumePassiveReplyQuota, checkPassiveReplyQuota, inferQQBotScope } from './features/quota-manager.js';
17
+ import type { PluginLogger } from './utils/plugin-logger.js';
18
+ import type { ResolvedQQBotAccount } from './types.js';
19
+ import type { SendMediaParams, SendMediaResult } from './outbound/media-send.js';
20
+ import type { SendResult } from './outbound/outbound-service.js';
21
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk';
22
+ import { resolveQQBotAccount } from './config.js';
23
+ import { parseTarget } from './outbound/target.js';
24
+ import { getCachedMsgId } from './features/msgid-cache.js';
25
+ import { sanitizeQQBotText } from './outbound/sanitize.js';
26
+ import { chunkQQBotMarkdown } from './outbound/chunker.js';
27
+ import { isApprovalPayload } from './features/approval-utils.js';
28
+ import { TEXT_CHUNK_LIMIT } from './constants.js';
29
+ import { tryGetQQBotRuntime } from './runtime.js';
30
+ import { getAdapters } from './adapter/resolve.js';
31
+
32
+ export type SendTextFn = (params: {
33
+ to: string;
34
+ text: string;
35
+ accountId?: string;
36
+ replyToId?: string;
37
+ account: ResolvedQQBotAccount;
38
+ quotaReserved?: boolean;
39
+ }) => Promise<SendResult>;
40
+
41
+ export type SendMediaFn = (params: SendMediaParams) => Promise<SendMediaResult>;
42
+
43
+ export interface QQBotOutboundAdapterParams {
44
+ sendText?: SendTextFn;
45
+ sendMedia?: SendMediaFn;
46
+ shouldSuppressLocalPayloadPrompt?: (params: { payload: unknown }) => boolean;
47
+ shouldTreatDeliveredTextAsVisible?: (params: { kind: string; text?: string }) => boolean;
48
+ preferFinalAssistantVisibleText?: boolean;
49
+ beforeDeliverPayload?: (params: {
50
+ cfg: unknown;
51
+ target: { to: string; accountId?: string; replyToId?: string };
52
+ hint?: unknown;
53
+ payload?: unknown;
54
+ }) => Promise<{ mode: 'passive' | 'proactive'; msgId?: string } | void>;
55
+ }
56
+
57
+ export interface QQBotOutboundAdapter {
58
+ sendTextWithQuota: (params: {
59
+ to: string;
60
+ text: string;
61
+ accountId?: string;
62
+ replyToId?: string;
63
+ account: ResolvedQQBotAccount;
64
+ log?: PluginLogger;
65
+ }) => Promise<{ messageId?: string; error?: string }>;
66
+ sendMediaWithQuota: (params: {
67
+ to: string;
68
+ source: string;
69
+ text?: string;
70
+ accountId?: string;
71
+ replyToId?: string;
72
+ account: ResolvedQQBotAccount;
73
+ agentId?: string;
74
+ log?: PluginLogger;
75
+ }) => Promise<{ messageId?: string; error?: string }>;
76
+ canSendTyping: (params: {
77
+ to: string;
78
+ accountId: string;
79
+ replyToId: string;
80
+ log?: PluginLogger;
81
+ }) => Promise<boolean>;
82
+ shouldSuppressLocalPayloadPrompt: (params: { payload: unknown }) => boolean;
83
+ shouldTreatDeliveredTextAsVisible: (params: { kind: string; text?: string }) => boolean;
84
+ preferFinalAssistantVisibleText: boolean;
85
+ }
86
+
87
+ let lazySendText: SendTextFn | undefined;
88
+ let lazySendMedia: SendMediaFn | undefined;
89
+
90
+ async function getSendText(): Promise<SendTextFn> {
91
+ if (!lazySendText) {
92
+ const mod = await import('./outbound/outbound-service.js');
93
+ lazySendText = mod.sendText;
94
+ }
95
+ return lazySendText;
96
+ }
97
+
98
+ async function getSendMedia(): Promise<SendMediaFn> {
99
+ if (!lazySendMedia) {
100
+ const mod = await import('./outbound/media-send.js');
101
+ lazySendMedia = mod.sendMedia;
102
+ }
103
+ return lazySendMedia;
104
+ }
105
+
106
+ /**
107
+ * 创建 QQBot Outbound 适配器
108
+ */
109
+ export function createQQBotOutboundAdapter(params: QQBotOutboundAdapterParams): QQBotOutboundAdapter {
110
+ const {
111
+ sendText: injectedSendText,
112
+ sendMedia: injectedSendMedia,
113
+ shouldSuppressLocalPayloadPrompt = () => false,
114
+ shouldTreatDeliveredTextAsVisible = () => true,
115
+ preferFinalAssistantVisibleText = true,
116
+ beforeDeliverPayload,
117
+ } = params;
118
+
119
+ return {
120
+ sendTextWithQuota: async ({ to, text, accountId, replyToId, account, log }) => {
121
+ const scope = inferQQBotScope(to);
122
+ const resolvedAccountId = accountId || account.accountId;
123
+
124
+ // 使用原子操作检查并消耗配额
125
+ const { canReply, rollback } = await checkAndConsumePassiveReplyQuota({
126
+ accountId: resolvedAccountId,
127
+ msgId: replyToId,
128
+ scope,
129
+ log,
130
+ });
131
+
132
+ const sendTextFn = injectedSendText || await getSendText();
133
+ const result = await sendTextFn({
134
+ to,
135
+ text,
136
+ accountId: resolvedAccountId,
137
+ replyToId: canReply ? replyToId : undefined,
138
+ account,
139
+ quotaReserved: canReply,
140
+ });
141
+
142
+ // 发送失败时回滚配额
143
+ if (canReply && replyToId && result.error) {
144
+ rollback();
145
+ log?.debug?.(`[${resolvedAccountId}] rollback quota: send failed`);
146
+ } else if (!canReply) {
147
+ log?.info?.(`[${resolvedAccountId}] [quota] fallback to proactive send: quota exhausted or no msgId`);
148
+ }
149
+
150
+ return result;
151
+ },
152
+
153
+ sendMediaWithQuota: async ({ to, source, text, accountId, replyToId, account, agentId, log }) => {
154
+ const scope = inferQQBotScope(to);
155
+ const resolvedAccountId = accountId || account.accountId;
156
+
157
+ // 使用原子操作检查并消耗配额
158
+ const { canReply, rollback } = await checkAndConsumePassiveReplyQuota({
159
+ accountId: resolvedAccountId,
160
+ msgId: replyToId,
161
+ scope,
162
+ log,
163
+ });
164
+
165
+ const sendMediaFn = injectedSendMedia || await getSendMedia();
166
+ const result = await sendMediaFn({
167
+ to,
168
+ source,
169
+ text,
170
+ replyToId: canReply ? replyToId : undefined,
171
+ accountId: resolvedAccountId,
172
+ agentId,
173
+ log,
174
+ quotaReserved: canReply,
175
+ });
176
+
177
+ // 发送失败时回滚配额
178
+ if (canReply && replyToId && result.error) {
179
+ rollback();
180
+ log?.debug?.(`[${resolvedAccountId}] rollback quota: send failed`);
181
+ }
182
+
183
+ return result;
184
+ },
185
+
186
+ canSendTyping: async ({ to, accountId, replyToId, log }) => {
187
+ const scope = inferQQBotScope(to);
188
+
189
+ if (scope !== 'c2c' || !replyToId) {
190
+ return false;
191
+ }
192
+
193
+ const canPassiveReply = await checkPassiveReplyQuota({
194
+ accountId,
195
+ msgId: replyToId,
196
+ scope: 'c2c',
197
+ });
198
+
199
+ if (!canPassiveReply) {
200
+ return false;
201
+ }
202
+
203
+ return true;
204
+ },
205
+
206
+ shouldSuppressLocalPayloadPrompt,
207
+ shouldTreatDeliveredTextAsVisible,
208
+ preferFinalAssistantVisibleText,
209
+ };
210
+ }
211
+
212
+ // ── 框架契约入口(openclaw ChannelOutboundAdapter)──
213
+
214
+ /** 框架 sendText / sendMedia 上下文(openclaw ChannelOutboundContext 子集) */
215
+ export interface ChannelOutboundSendContext {
216
+ to: string;
217
+ text?: string;
218
+ mediaUrl?: string;
219
+ accountId?: string | null;
220
+ replyToId?: string | null;
221
+ cfg: OpenClawConfig;
222
+ }
223
+
224
+ export interface ChannelOutboundDeliveryResult {
225
+ channel: 'qqbot';
226
+ messageId: string;
227
+ }
228
+
229
+ /** 恢复 MCP agentId 路由:media-send 用它解析相对路径的工作区 */
230
+ function resolveMCPAgentId(
231
+ to: string,
232
+ accountId: string,
233
+ cfg: unknown,
234
+ log?: PluginLogger,
235
+ ): string | undefined {
236
+ try {
237
+ const parts = to.split(':');
238
+ const scope = parts[1];
239
+ const peerId = parts[2];
240
+ if (!scope || !peerId) return undefined;
241
+ const rt = tryGetQQBotRuntime();
242
+ if (!rt) return undefined;
243
+ const route = getAdapters(rt).resolveAgentRoute?.({
244
+ cfg, channel: 'qqbot', accountId,
245
+ peer: { kind: scope === 'group' ? 'group' : 'direct', id: peerId },
246
+ });
247
+ log?.debug?.(`resolveMCPAgentId to=${to} => agentId=${route?.agentId ?? 'none'}`);
248
+ return route?.agentId;
249
+ } catch { return undefined; }
250
+ }
251
+
252
+ /**
253
+ * 被动优先的 replyToId 解析:框架未显式给 replyToId 时(message 工具 / 框架
254
+ * 直发路径),从 msgid-cache 取该会话最新未过期 msg_id,让发送走配额感知的
255
+ * 被动通道(保每日主动预算)。无缓存(静群超 TTL)→ undefined → 主动发送。
256
+ */
257
+ function resolvePassiveFirstReplyToId(to: string, explicitReplyToId?: string | null): string | undefined {
258
+ if (explicitReplyToId) return explicitReplyToId;
259
+ try {
260
+ const { scope, targetId } = parseTarget(to);
261
+ if (!targetId) return undefined;
262
+ return getCachedMsgId(scope, targetId);
263
+ } catch {
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * 创建框架 ChannelOutboundAdapter 契约入口。
270
+ *
271
+ * 框架可用性判定要求 outbound.sendText 存在(channel-resolution.ts /
272
+ * channel-bootstrap.runtime.ts),sendText/sendMedia 内部走配额感知包装,
273
+ * 配额耗尽时自动降级主动消息。
274
+ */
275
+ export function createQQBotChannelOutbound(params: QQBotOutboundAdapterParams = {}) {
276
+ const quotaAdapter = createQQBotOutboundAdapter(params);
277
+
278
+ return {
279
+ ...quotaAdapter,
280
+ deliveryMode: 'direct' as const,
281
+
282
+ sendText: async (ctx: ChannelOutboundSendContext): Promise<ChannelOutboundDeliveryResult> => {
283
+ const account = resolveQQBotAccount(ctx.cfg, ctx.accountId ?? undefined);
284
+ const result = await quotaAdapter.sendTextWithQuota({
285
+ to: ctx.to,
286
+ text: ctx.text ?? '',
287
+ accountId: ctx.accountId ?? undefined,
288
+ replyToId: resolvePassiveFirstReplyToId(ctx.to, ctx.replyToId),
289
+ account,
290
+ });
291
+ if (result.error) throw new Error(result.error);
292
+ return { channel: 'qqbot', messageId: result.messageId ?? '' };
293
+ },
294
+
295
+ sendMedia: async (ctx: ChannelOutboundSendContext): Promise<ChannelOutboundDeliveryResult> => {
296
+ const account = resolveQQBotAccount(ctx.cfg, ctx.accountId ?? undefined);
297
+ const resolvedAccountId = ctx.accountId ?? account.accountId;
298
+ const result = await quotaAdapter.sendMediaWithQuota({
299
+ to: ctx.to,
300
+ source: ctx.mediaUrl ?? '',
301
+ text: ctx.text,
302
+ accountId: resolvedAccountId,
303
+ replyToId: resolvePassiveFirstReplyToId(ctx.to, ctx.replyToId),
304
+ account,
305
+ agentId: resolveMCPAgentId(ctx.to, resolvedAccountId, ctx.cfg),
306
+ });
307
+ if (result.error) throw new Error(result.error);
308
+ return { channel: 'qqbot', messageId: result.messageId ?? '' };
309
+ },
310
+
311
+ sanitizeText: ({ text }: { text: string }) => sanitizeQQBotText(text),
312
+ chunker: (text: string, limit: number) => chunkQQBotMarkdown(text, limit),
313
+ chunkerMode: 'markdown' as const,
314
+ textChunkLimit: TEXT_CHUNK_LIMIT,
315
+ shouldSuppressLocalPayloadPrompt: ({ payload }: { payload: unknown }) => isApprovalPayload(payload),
316
+ };
317
+ }
318
+
319
+ export const qqbotChannelOutbound = createQQBotChannelOutbound({
320
+ shouldSuppressLocalPayloadPrompt: () => false,
321
+ shouldTreatDeliveredTextAsVisible: () => true,
322
+ preferFinalAssistantVisibleText: true,
323
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * QQBot 插件基础配置
3
+ *
4
+ * 参考 Telegram 插件的 createTelegramPluginBase
5
+ */
6
+
7
+ import { qqbotSetupWizard } from './setup/surface.js';
8
+
9
+ export interface QQBotPluginBaseParams {
10
+ setupWizard?: typeof qqbotSetupWizard;
11
+ }
12
+
13
+ export function createQQBotPluginBase(params: QQBotPluginBaseParams = {}) {
14
+ const { setupWizard = qqbotSetupWizard } = params;
15
+
16
+ return {
17
+ id: 'qqbot' as const,
18
+ meta: {
19
+ id: 'qqbot',
20
+ label: 'QQ Bot',
21
+ selectionLabel: 'QQ Bot',
22
+ docsPath: '/docs/channels/qqbot',
23
+ blurb: 'Connect to QQ via official QQ Bot API',
24
+ order: 50,
25
+ },
26
+ capabilities: {
27
+ chatTypes: ['direct', 'group'] as Array<'direct' | 'group'>,
28
+ media: true,
29
+ reactions: false,
30
+ threads: false,
31
+ },
32
+ setupWizard: setupWizard as any,
33
+ reload: {
34
+ configPrefixes: ['channels.qqbot'],
35
+ },
36
+ defaults: {
37
+ queue: {
38
+ debounceMs: 1000,
39
+ },
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 请求级上下文(基于 AsyncLocalStorage)
3
+ *
4
+ * 解决并发消息下工具获取当前会话信息的竞态问题。
5
+ * gateway 在处理每条入站消息时通过 runWithRequestContext() 建立作用域,
6
+ * 作用域内的所有异步代码(包括 AI agent 调用、tool execute)
7
+ * 都能通过 getRequestContext() 安全地拿到当前请求的上下文。
8
+ */
9
+ import { AsyncLocalStorage } from 'node:async_hooks';
10
+
11
+ export interface RequestContext {
12
+ /** 投递目标地址,如 qqbot:c2c:xxx 或 qqbot:group:xxx */
13
+ target: string;
14
+ /** 当前请求的 QQBot 账户 ID(多账户场景) */
15
+ accountId?: string;
16
+ /** 当前消息 ID */
17
+ messageId?: string;
18
+ /** 发送者 open_id */
19
+ openId?: string;
20
+ }
21
+
22
+ const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
23
+
24
+ /**
25
+ * 在请求级作用域中执行回调
26
+ */
27
+ export function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T {
28
+ return asyncLocalStorage.run(ctx, fn);
29
+ }
30
+
31
+ /**
32
+ * 获取当前请求的上下文
33
+ */
34
+ export function getRequestContext(): RequestContext | undefined {
35
+ return asyncLocalStorage.getStore();
36
+ }
37
+
38
+ /**
39
+ * 获取当前请求的投递目标地址
40
+ */
41
+ export function getRequestTarget(): string | undefined {
42
+ return asyncLocalStorage.getStore()?.target;
43
+ }
44
+
45
+ /**
46
+ * 获取当前请求的账户 ID
47
+ */
48
+ export function getRequestAccountId(): string | undefined {
49
+ return asyncLocalStorage.getStore()?.accountId;
50
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * QQBot 插件运行时管理。
3
+ *
4
+ * 日志统一由 `utils/plugin-logger.ts` 提供,此处只管理 runtime 实例。
5
+ */
6
+
7
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
8
+ import { setOpenClawVersion } from "./bot-instance.js";
9
+ import { flushAllRefIndexStores } from "./features/ref-index-store.js";
10
+ import { getOpenclawVersion } from "./utils/pkg-version.js";
11
+
12
+ let runtime: PluginRuntime | null = null;
13
+ let exitHooksInstalled = false;
14
+
15
+ export function setQQBotRuntime(next: PluginRuntime) {
16
+ runtime = next;
17
+ const version = getOpenclawVersion(next.version);
18
+ setOpenClawVersion(version);
19
+ installExitHooksOnce();
20
+ }
21
+
22
+ function installExitHooksOnce(): void {
23
+ if (exitHooksInstalled) return;
24
+ exitHooksInstalled = true;
25
+
26
+ const flush = () => {
27
+ try { flushAllRefIndexStores(); } catch {}
28
+ };
29
+
30
+ process.on('beforeExit', flush);
31
+ process.on('SIGINT', () => { flush(); process.exit(0); });
32
+ process.on('SIGTERM', () => { flush(); process.exit(0); });
33
+ }
34
+
35
+ export function getQQBotRuntime(): PluginRuntime {
36
+ if (!runtime) throw new Error("QQBot runtime not initialized");
37
+ return runtime;
38
+ }
39
+
40
+ export function tryGetQQBotRuntime(): PluginRuntime | null {
41
+ return runtime;
42
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 账户键解析 — setup / login 共用
3
+ *
4
+ * 统一确定扫码/绑定凭据应写入哪个账户键。
5
+ */
6
+
7
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk';
8
+ import { listQQBotAccountIds, resolveQQBotAccount } from '../config.js';
9
+
10
+ /**
11
+ * 解析凭据写入的目标账户键。
12
+ *
13
+ * @param cfg 当前配置
14
+ * @param appId 扫码或输入的 AppID
15
+ * @param resolvedId 用户显式指定的账户名(如 --account / setup wizard),可选
16
+ * @returns 账户键
17
+ *
18
+ * 优先级:
19
+ * 1. resolvedId 指定 → 直接使用
20
+ * 2. 已有同 appId 的账户 → 刷新凭据(复用该账户键)
21
+ * 3. 零账户 → 'default'(首次配置)
22
+ * 4. 已有其他账户 → appId(新增独立账户)
23
+ */
24
+ export function resolveAccountKey(
25
+ cfg: OpenClawConfig,
26
+ appId: string,
27
+ resolvedId?: string | null,
28
+ ): string {
29
+ if (resolvedId) return resolvedId;
30
+
31
+ // 同 appId → 刷新
32
+ for (const id of listQQBotAccountIds(cfg)) {
33
+ if (resolveQQBotAccount(cfg, id).appId === appId) return id;
34
+ }
35
+
36
+ // 零账户 → default
37
+ if (listQQBotAccountIds(cfg).length === 0) return 'default';
38
+
39
+ // 新增账户
40
+ return appId;
41
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * openclaw setup 引导 — QQ Bot 扫码/手动绑定
3
+ */
4
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk';
5
+ import { formatDocsLink } from '../adapter/setup.js';
6
+ import { qrConnect } from '@tencent-connect/qqbot-connector';
7
+ import { DEFAULT_ACCOUNT_ID, applyQQBotAccountConfig, resolveQQBotAccount } from '../config.js';
8
+ import { resolveAccountKey } from './account-key.js';
9
+
10
+ type Prompter = {
11
+ select: (opts: { message: string; options: Array<{ value: string; label: string; hint?: string }> }) => Promise<string>;
12
+ text: (opts: { message: string; validate?: (v: string) => string | undefined }) => Promise<string>;
13
+ note: (msg: string, title?: string) => Promise<void>;
14
+ };
15
+
16
+ type Runtime = {
17
+ log: (msg: string) => void;
18
+ error: (msg: string) => void;
19
+ };
20
+
21
+ function isConfigured(cfg: OpenClawConfig, accountId: string): boolean {
22
+ const account = resolveQQBotAccount(cfg, accountId);
23
+ return Boolean(account.appId && account.clientSecret);
24
+ }
25
+
26
+ async function linkViaQrCode(cfg: OpenClawConfig, _accountId: string, prompter: Prompter, rt: Runtime): Promise<OpenClawConfig> {
27
+ try {
28
+ const accounts: Array<{ appId: string; appSecret: string; userOpenid?: string }> = await qrConnect({ source: 'openclaw' });
29
+
30
+ if (accounts.length === 0) {
31
+ await prompter.note('未获取到任何 QQ Bot 账号信息。', 'QQ Bot');
32
+ return cfg;
33
+ }
34
+
35
+ let next = cfg;
36
+ for (const { appId, appSecret, userOpenid } of accounts) {
37
+ // 同 appId 刷新;零账户 default;已有账户新增 appId
38
+ const key = resolveAccountKey(cfg, appId);
39
+ next = applyQQBotAccountConfig(next, key, { appId, clientSecret: appSecret });
40
+ next = applyAccountDefaults(next, key, userOpenid);
41
+ rt.log(`绑定成功!账户: ${key} (AppID: ${appId})`);
42
+ }
43
+
44
+ return next;
45
+ } catch (err) {
46
+ rt.error(`QQ Bot 绑定失败: ${String(err)}`);
47
+ await prompter.note(`绑定失败,您可以稍后手动配置。\n文档: ${formatDocsLink('/channels/qqbot', 'qqbot')}`, 'QQ Bot');
48
+ return cfg;
49
+ }
50
+ }
51
+
52
+ async function linkViaManual(cfg: OpenClawConfig, _accountId: string, prompter: Prompter): Promise<OpenClawConfig> {
53
+ const appIdInput = await prompter.text({ message: '请输入 QQ Bot AppID', validate: (v) => v.trim() ? undefined : 'AppID 不能为空' });
54
+ const secret = await prompter.text({ message: '请输入 QQ Bot AppSecret', validate: (v) => v.trim() ? undefined : 'AppSecret 不能为空' });
55
+ const appId = appIdInput.trim();
56
+ const key = resolveAccountKey(cfg, appId);
57
+ let next = applyQQBotAccountConfig(cfg, key, { appId, clientSecret: secret.trim() });
58
+ next = applyAccountDefaults(next, key);
59
+ await prompter.note('✔ QQ Bot 配置完成!', 'QQ Bot');
60
+ return next;
61
+ }
62
+
63
+ export async function finalizeQQBotSetup(params: {
64
+ cfg: OpenClawConfig;
65
+ accountId: string;
66
+ prompter: Prompter;
67
+ runtime: Runtime;
68
+ }): Promise<{ cfg: OpenClawConfig }> {
69
+ const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
70
+ const configured = isConfigured(params.cfg, accountId);
71
+
72
+ const mode = await params.prompter.select({
73
+ message: configured ? 'QQ 已绑定,选择操作' : '选择 QQ 绑定方式',
74
+ options: [
75
+ { value: 'qr', label: '扫码绑定(推荐)', hint: '使用 QQ 扫描二维码自动完成绑定' },
76
+ { value: 'manual', label: '手动输入 QQ Bot AppID 和 AppSecret', hint: '需到 QQ 开放平台 q.qq.com 查看' },
77
+ { value: 'skip', label: configured ? '保持当前配置' : '稍后配置' },
78
+ ],
79
+ });
80
+
81
+ let next = params.cfg;
82
+ if (mode === 'qr') {
83
+ next = await linkViaQrCode(next, accountId, params.prompter, params.runtime);
84
+ } else if (mode === 'manual') {
85
+ next = await linkViaManual(next, accountId, params.prompter);
86
+ } else if (!configured) {
87
+ await params.prompter.note('您可以稍后运行以下命令重新配置:\n openclaw channels add', 'QQ Bot');
88
+ }
89
+
90
+ return { cfg: next };
91
+ }
92
+
93
+ export function applyAccountDefaults(cfg: OpenClawConfig, accountId: string, userOpenid?: string): OpenClawConfig {
94
+ const next = { ...cfg, channels: { ...cfg.channels } };
95
+ const qqbot = { ...(next.channels?.qqbot as Record<string, unknown> ?? {}) } as Record<string, unknown>;
96
+
97
+ const defaults: Record<string, unknown> = { streaming: { mode: 'partial' }, dmPolicy: 'allowlist', mediaMaxMb: 200 };
98
+ if (userOpenid) defaults.allowFrom = [userOpenid];
99
+
100
+ if (accountId === DEFAULT_ACCOUNT_ID) {
101
+ Object.assign(qqbot, defaults);
102
+ } else {
103
+ const accounts = { ...(qqbot.accounts as Record<string, unknown> ?? {}) };
104
+ accounts[accountId] = { ...(accounts[accountId] as Record<string, unknown> ?? {}), ...defaults };
105
+ qqbot.accounts = accounts;
106
+ }
107
+
108
+ next.channels = { ...next.channels, qqbot };
109
+ return next;
110
+ }