@cxyhhhhh/openclaw-qqbot 2.0.0-dev.202607101321 → 2.0.0-dev.202607101939
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.
- package/dist/index.cjs +1476 -1158
- package/dist/index.cjs.map +1 -1
- package/package.json +2 -2
- package/src/adapter/contract.ts +1 -1
- package/src/adapter/resolve.ts +38 -0
- package/src/channel.ts +15 -0
- package/src/dispatch/body-assembler.ts +2 -2
- package/src/features/approval-utils.ts +0 -4
- package/src/gateway/middleware-setup.ts +17 -11
- package/src/openclaw-plugin-sdk.d.ts +16 -0
- package/src/outbound/media-send.ts +48 -9
- package/src/setup/account-key.ts +41 -0
- package/src/setup/finalize.ts +15 -14
- package/src/setup/login.ts +197 -0
- package/src/setup/surface.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cxyhhhhh/openclaw-qqbot",
|
|
3
|
-
"version": "2.0.0-dev.
|
|
3
|
+
"version": "2.0.0-dev.202607101939",
|
|
4
4
|
"description": "QQ Bot channel plugin for OpenClaw",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@tencent-connect/qqbot-connector": "1.2.0",
|
|
45
|
-
"@tencent-connect/qqbot-nodejs": "npm:@cxyhhhhh/qqbot-nodejs@1.0.2-dev.
|
|
45
|
+
"@tencent-connect/qqbot-nodejs": "npm:@cxyhhhhh/qqbot-nodejs@1.0.2-dev.202607101530"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^20.19.43",
|
package/src/adapter/contract.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { PluginRuntime } from 'openclaw/plugin-sdk';
|
|
9
9
|
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
10
10
|
|
|
11
|
-
const log = createPluginLogger({ prefix: '[contract]', forceConsole:
|
|
11
|
+
const log = createPluginLogger({ prefix: '[contract]', forceConsole: false });
|
|
12
12
|
|
|
13
13
|
interface ApiProbe {
|
|
14
14
|
name: string;
|
package/src/adapter/resolve.ts
CHANGED
|
@@ -285,6 +285,44 @@ export function getAdapters(
|
|
|
285
285
|
return _cachedAdapters;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/**
|
|
289
|
+
* 持久化配置(auth.login 场景,兼容高低版本)。
|
|
290
|
+
*
|
|
291
|
+
* 探测顺序:
|
|
292
|
+
* 1. config.mutateConfigFile — 新版原子操作
|
|
293
|
+
* 2. config.writeConfigFile — 旧版整体覆盖
|
|
294
|
+
* 3. writeConfigFile — 更旧的顶层 API
|
|
295
|
+
*/
|
|
296
|
+
export async function persistAuthConfig(
|
|
297
|
+
runtime: Record<string, unknown>,
|
|
298
|
+
cfg: Record<string, unknown>,
|
|
299
|
+
afterWrite = 'restart',
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const config: Record<string, unknown> | undefined = runtime.config as any;
|
|
302
|
+
|
|
303
|
+
if (typeof config?.mutateConfigFile === 'function') {
|
|
304
|
+
await (config.mutateConfigFile as Function)({
|
|
305
|
+
mutate: () => cfg,
|
|
306
|
+
afterWrite,
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (typeof config?.writeConfigFile === 'function') {
|
|
311
|
+
await (config.writeConfigFile as Function)(cfg);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (typeof runtime.writeConfigFile === 'function') {
|
|
315
|
+
await (runtime.writeConfigFile as Function)(cfg);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 最后兜底:裸写文件
|
|
320
|
+
const { homedir } = await import('node:os');
|
|
321
|
+
const { join } = await import('node:path');
|
|
322
|
+
const { writeFileSync } = await import('node:fs');
|
|
323
|
+
writeFileSync(join(homedir(), '.openclaw', 'openclaw.json'), JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
324
|
+
}
|
|
325
|
+
|
|
288
326
|
/**
|
|
289
327
|
* 强制清除 adapter 缓存(仅测试用)
|
|
290
328
|
*/
|
package/src/channel.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { sendText, getGateway } from './outbound/outbound-service.js';
|
|
|
29
29
|
import { sendMedia } from './outbound/media-send.js';
|
|
30
30
|
import type { PluginLogger } from './utils/plugin-logger.js';
|
|
31
31
|
import { qqbotSetupWizard } from './setup/surface.js';
|
|
32
|
+
import { qqbotLogin, startQrLogin, waitQrLogin } from './setup/login.js';
|
|
32
33
|
import { normalizeTarget, isQQBotTarget } from './outbound/target.js';
|
|
33
34
|
import { sanitizeQQBotText } from './outbound/sanitize.js';
|
|
34
35
|
import { startAccountWithCredentialRecovery, logoutAndClearCredentials, stopAccountGracefully } from './gateway/lifecycle.js';
|
|
@@ -72,6 +73,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
|
72
73
|
threads: false,
|
|
73
74
|
blockStreaming: false,
|
|
74
75
|
},
|
|
76
|
+
gatewayMethods: ['web.login.start', 'web.login.wait'],
|
|
75
77
|
reload: { configPrefixes: ['channels.qqbot'] },
|
|
76
78
|
|
|
77
79
|
// ── 群消息策略 ──
|
|
@@ -266,6 +268,19 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
|
266
268
|
});
|
|
267
269
|
},
|
|
268
270
|
logoutAccount: (params) => logoutAndClearCredentials(params),
|
|
271
|
+
loginWithQrStart: async ({ accountId }: { accountId?: string }) => startQrLogin(accountId),
|
|
272
|
+
loginWithQrWait: async ({ accountId }: { accountId?: string }) => {
|
|
273
|
+
const result = await waitQrLogin(accountId);
|
|
274
|
+
return { connected: result.connected, message: result.message };
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// ── 登录认证 ──
|
|
279
|
+
auth: {
|
|
280
|
+
login: qqbotLogin as any,
|
|
281
|
+
// 审批权限(从 approvalStubs 迁移)
|
|
282
|
+
authorizeActorAction: () => ({ authorized: true } as const),
|
|
283
|
+
getActionAvailabilityState: () => ({ kind: 'enabled' as const } as const),
|
|
269
284
|
},
|
|
270
285
|
|
|
271
286
|
// ── 状态 ──
|
|
@@ -298,8 +298,8 @@ function buildAgentBody(input: {
|
|
|
298
298
|
}): string {
|
|
299
299
|
const { userContent, base, isGroup, wasMentioned, history } = input;
|
|
300
300
|
|
|
301
|
-
//
|
|
302
|
-
if (userContent.startsWith('/')) {
|
|
301
|
+
// 斜杠命令直通:去除一切装饰及首尾空白
|
|
302
|
+
if (userContent.trim().startsWith('/')) {
|
|
303
303
|
return userContent;
|
|
304
304
|
}
|
|
305
305
|
|
|
@@ -28,10 +28,6 @@ export const approvalStubs = {
|
|
|
28
28
|
buildPendingPayload: () => null,
|
|
29
29
|
buildResolvedPayload: () => null,
|
|
30
30
|
},
|
|
31
|
-
auth: {
|
|
32
|
-
authorizeActorAction: () => ({ authorized: true }),
|
|
33
|
-
getActionAvailabilityState: () => ({ kind: 'enabled' as const }),
|
|
34
|
-
},
|
|
35
31
|
approvals: {
|
|
36
32
|
delivery: {
|
|
37
33
|
hasConfiguredDmRoute: () => true,
|
|
@@ -27,6 +27,7 @@ import { getPersistedRefIndexStore } from '../features/ref-index-store.js';
|
|
|
27
27
|
import { createPolicyInjector } from '../middleware/policy-injector.js';
|
|
28
28
|
import { getHistoryStore, historyGroupKey } from '../features/history-store.js';
|
|
29
29
|
import { dynamicAccessControl } from '../middleware/access-control.js';
|
|
30
|
+
import { stripMentionText } from '../utils/mention.js';
|
|
30
31
|
|
|
31
32
|
export interface MiddlewareSetupOptions {
|
|
32
33
|
/** 获取 runtime */
|
|
@@ -66,14 +67,23 @@ export function setupMiddlewares(bot: QQBot, account: ResolvedQQBotAccount, opts
|
|
|
66
67
|
|
|
67
68
|
// 6. 群聊 @bot 门控(从 ctx.state.policy.group 读取动态配置)
|
|
68
69
|
bot.use(mentionGate());
|
|
69
|
-
|
|
70
70
|
// 7. 内容清洗(去 @marker、表情标签、多余空白)
|
|
71
|
-
|
|
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
|
+
}));
|
|
72
76
|
|
|
73
77
|
// 8. 三层限流(sender / group / global)
|
|
74
78
|
bot.use(rateLimiter());
|
|
75
79
|
|
|
76
|
-
// 9.
|
|
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. 并发串行+合并(在副作用中间件之前)
|
|
77
87
|
// - 同 peer 串行处理,避免平台 session conflict
|
|
78
88
|
// - 处理中消息暂存 buffer;完成后合并为一条,继续走完剩余中间件链
|
|
79
89
|
// (typingIndicator/quoteRef/attachmentProcessor/... 直到 bot.on("message"))
|
|
@@ -107,18 +117,18 @@ export function setupMiddlewares(bot: QQBot, account: ResolvedQQBotAccount, opts
|
|
|
107
117
|
},
|
|
108
118
|
}));
|
|
109
119
|
|
|
110
|
-
//
|
|
120
|
+
// 11. C2C 输入状态指示器
|
|
111
121
|
bot.use(typingIndicator());
|
|
112
122
|
|
|
113
|
-
//
|
|
123
|
+
// 12. 引用消息解析(默认优先 msg_elements 获取文件名等丰富信息)
|
|
114
124
|
bot.use(quoteRef({
|
|
115
125
|
store: getPersistedRefIndexStore(account.accountId),
|
|
116
126
|
}));
|
|
117
127
|
|
|
118
|
-
//
|
|
128
|
+
// 13. 附件处理(语音 STT 转录 + 图片/文件下载)
|
|
119
129
|
bot.use(attachmentProcessor({ getRuntime: opts.getRuntime }));
|
|
120
130
|
|
|
121
|
-
//
|
|
131
|
+
// 14. 上下文组装(构建框架规约的 body)
|
|
122
132
|
bot.use(envelopeFormatter({
|
|
123
133
|
format: (ctx) => {
|
|
124
134
|
const assembled = assembleBody(ctx, ctx.message as never, account, opts.getRuntime);
|
|
@@ -126,8 +136,4 @@ export function setupMiddlewares(bot: QQBot, account: ResolvedQQBotAccount, opts
|
|
|
126
136
|
return assembled.agentBody;
|
|
127
137
|
},
|
|
128
138
|
}));
|
|
129
|
-
|
|
130
|
-
// 14. 斜杠命令(命令被匹配后直接回复,不再向下传递)
|
|
131
|
-
const slash = slashCommand({ commands: buildCommandList(account, { getRuntime: opts.getRuntime }) });
|
|
132
|
-
bot.use(slash.middleware);
|
|
133
139
|
}
|
|
@@ -691,7 +691,23 @@ declare module "openclaw/plugin-sdk/setup" {
|
|
|
691
691
|
export interface ChannelSetupWizard {
|
|
692
692
|
channel: string;
|
|
693
693
|
status: ChannelSetupWizardStatus;
|
|
694
|
+
/** 解析配置时使用哪个账户 ID:无账户时默认 'default' */
|
|
695
|
+
resolveAccountIdForConfigure?: (params: {
|
|
696
|
+
cfg: unknown;
|
|
697
|
+
prompter: unknown;
|
|
698
|
+
options?: unknown;
|
|
699
|
+
accountOverride?: string;
|
|
700
|
+
shouldPromptAccountIds: boolean;
|
|
701
|
+
listAccountIds: (cfg: unknown) => string[];
|
|
702
|
+
defaultAccountId: string;
|
|
703
|
+
}) => string | Promise<string>;
|
|
704
|
+
resolveShouldPromptAccountIds?: (params: {
|
|
705
|
+
cfg: unknown;
|
|
706
|
+
options?: unknown;
|
|
707
|
+
shouldPromptAccountIds: boolean;
|
|
708
|
+
}) => boolean;
|
|
694
709
|
credentials?: Array<{ id: string; label: string }>;
|
|
710
|
+
textInputs?: Array<{ id: string; label: string; placeholder?: string }>;
|
|
695
711
|
onStatusChange?: (params: { cfg: unknown }) => void | Promise<void>;
|
|
696
712
|
configure?: (params: { cfg: unknown; accountId: string; prompter: unknown; runtime: unknown }) => Promise<unknown>;
|
|
697
713
|
finalize: (params: { cfg: unknown; accountId: string; prompter: unknown; runtime: unknown }) => Promise<unknown>;
|
|
@@ -78,14 +78,51 @@ function resolveTempRoots(): string[] {
|
|
|
78
78
|
return [...roots];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
/**
|
|
82
|
+
* 构建动态白名单目录列表。
|
|
83
|
+
*
|
|
84
|
+
* 从 openclaw 核心已解析的 workspaceDir 反向推导配置目录,
|
|
85
|
+
* 同时保留已知配置目录名以向后兼容。
|
|
86
|
+
*/
|
|
87
|
+
function buildDynamicAllowedRoots(workspaceDir?: string): string[] {
|
|
88
|
+
const home = os.homedir();
|
|
89
|
+
const derivedBase = workspaceDir ? path.dirname(workspaceDir) : path.join(home, '.openclaw');
|
|
90
|
+
const roots: string[] = [];
|
|
91
|
+
const added = new Set<string>();
|
|
92
|
+
|
|
93
|
+
const addRoot = (p: string) => {
|
|
94
|
+
try {
|
|
95
|
+
const real = fs.existsSync(p) ? fs.realpathSync(p) : p;
|
|
96
|
+
if (!added.has(real)) {
|
|
97
|
+
added.add(real);
|
|
98
|
+
roots.push(real);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* skip */ }
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 已知配置目录名(包括默认和开发模式)
|
|
104
|
+
const knownBases = [path.join(home, '.openclaw'), path.join(home, '.openclaw-dev')];
|
|
105
|
+
|
|
106
|
+
// 当前配置目录优先(从 workspaceDir 反推)
|
|
107
|
+
if (workspaceDir) {
|
|
108
|
+
addRoot(path.join(derivedBase, 'media'));
|
|
109
|
+
addRoot(path.join(derivedBase, 'workspace'));
|
|
110
|
+
addRoot(path.join(derivedBase, 'outbound'));
|
|
111
|
+
addRoot(workspaceDir);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 所有已知配置目录(去重,确保向后兼容)
|
|
115
|
+
for (const base of knownBases) {
|
|
116
|
+
addRoot(path.join(base, 'media'));
|
|
117
|
+
addRoot(path.join(base, 'workspace'));
|
|
118
|
+
addRoot(path.join(base, 'outbound'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 临时目录
|
|
122
|
+
for (const t of resolveTempRoots()) addRoot(t);
|
|
123
|
+
|
|
124
|
+
return roots;
|
|
125
|
+
}
|
|
89
126
|
|
|
90
127
|
/** 入站 Base64 / Data URL 最大字节数(10MB) */
|
|
91
128
|
const MAX_DATA_URL_BYTES = 10 * 1024 * 1024;
|
|
@@ -198,7 +235,9 @@ async function resolveMediaPath(source: string, log?: SendMediaParams['log'], wo
|
|
|
198
235
|
return { ok: false, error: `Cannot resolve path: ${resolved}` };
|
|
199
236
|
}
|
|
200
237
|
|
|
201
|
-
|
|
238
|
+
// 动态白名单:静态根目录 + 当前 agent 工作区
|
|
239
|
+
const dynamicRoots = buildDynamicAllowedRoots(workspaceDir);
|
|
240
|
+
const allowed = isPathInAllowedRoots(real, dynamicRoots);
|
|
202
241
|
|
|
203
242
|
if (!allowed) {
|
|
204
243
|
log?.warn(`path blocked — not in allowed directory: ${real}`);
|
|
@@ -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
|
+
}
|
package/src/setup/finalize.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
|
5
5
|
import { DEFAULT_ACCOUNT_ID, formatDocsLink } from '../adapter/setup.js';
|
|
6
6
|
import { qrConnect } from '@tencent-connect/qqbot-connector';
|
|
7
7
|
import { applyQQBotAccountConfig, resolveQQBotAccount } from '../config.js';
|
|
8
|
+
import { resolveAccountKey } from './account-key.js';
|
|
8
9
|
|
|
9
10
|
type Prompter = {
|
|
10
11
|
select: (opts: { message: string; options: Array<{ value: string; label: string; hint?: string }> }) => Promise<string>;
|
|
@@ -22,7 +23,7 @@ function isConfigured(cfg: OpenClawConfig, accountId: string): boolean {
|
|
|
22
23
|
return Boolean(account.appId && account.clientSecret);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
async function linkViaQrCode(cfg: OpenClawConfig,
|
|
26
|
+
async function linkViaQrCode(cfg: OpenClawConfig, _accountId: string, prompter: Prompter, rt: Runtime): Promise<OpenClawConfig> {
|
|
26
27
|
try {
|
|
27
28
|
const accounts: Array<{ appId: string; appSecret: string; userOpenid?: string }> = await qrConnect({ source: 'openclaw' });
|
|
28
29
|
|
|
@@ -33,15 +34,13 @@ async function linkViaQrCode(cfg: OpenClawConfig, accountId: string, prompter: P
|
|
|
33
34
|
|
|
34
35
|
let next = cfg;
|
|
35
36
|
for (const { appId, appSecret, userOpenid } of accounts) {
|
|
36
|
-
|
|
37
|
-
|
|
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})`);
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
if (accounts.length === 1) {
|
|
41
|
-
rt.log(`✔ QQ Bot 绑定成功!(AppID: ${accounts[0].appId})`);
|
|
42
|
-
} else {
|
|
43
|
-
rt.log(`✔ ${accounts.length} 个 QQ Bot 绑定成功!(AppID: ${accounts.map((a) => a.appId).join(', ')})`);
|
|
44
|
-
}
|
|
45
44
|
return next;
|
|
46
45
|
} catch (err) {
|
|
47
46
|
rt.error(`QQ Bot 绑定失败: ${String(err)}`);
|
|
@@ -50,11 +49,13 @@ async function linkViaQrCode(cfg: OpenClawConfig, accountId: string, prompter: P
|
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
async function linkViaManual(cfg: OpenClawConfig, prompter: Prompter): Promise<OpenClawConfig> {
|
|
54
|
-
const
|
|
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 不能为空' });
|
|
55
54
|
const secret = await prompter.text({ message: '请输入 QQ Bot AppSecret', validate: (v) => v.trim() ? undefined : 'AppSecret 不能为空' });
|
|
56
|
-
|
|
57
|
-
|
|
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);
|
|
58
59
|
await prompter.note('✔ QQ Bot 配置完成!', 'QQ Bot');
|
|
59
60
|
return next;
|
|
60
61
|
}
|
|
@@ -81,7 +82,7 @@ export async function finalizeQQBotSetup(params: {
|
|
|
81
82
|
if (mode === 'qr') {
|
|
82
83
|
next = await linkViaQrCode(next, accountId, params.prompter, params.runtime);
|
|
83
84
|
} else if (mode === 'manual') {
|
|
84
|
-
next = await linkViaManual(next, params.prompter);
|
|
85
|
+
next = await linkViaManual(next, accountId, params.prompter);
|
|
85
86
|
} else if (!configured) {
|
|
86
87
|
await params.prompter.note('您可以稍后运行以下命令重新配置:\n openclaw channels add', 'QQ Bot');
|
|
87
88
|
}
|
|
@@ -89,7 +90,7 @@ export async function finalizeQQBotSetup(params: {
|
|
|
89
90
|
return { cfg: next };
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
function applyAccountDefaults(cfg: OpenClawConfig, accountId: string, userOpenid?: string): OpenClawConfig {
|
|
93
|
+
export function applyAccountDefaults(cfg: OpenClawConfig, accountId: string, userOpenid?: string): OpenClawConfig {
|
|
93
94
|
const next = { ...cfg, channels: { ...cfg.channels } };
|
|
94
95
|
const qqbot = { ...(next.channels?.qqbot as Record<string, unknown> ?? {}) } as Record<string, unknown>;
|
|
95
96
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot QR 登录 — 两阶段接口适配
|
|
3
|
+
*
|
|
4
|
+
* 使用 @tencent-connect/qqbot-connector 的 startQrConnect 回调 API,
|
|
5
|
+
* 将扫码绑定流程拆分为 start / wait 两个阶段,对接 ChannelPlugin 的
|
|
6
|
+
* gateway.loginWithQrStart / loginWithQrWait。
|
|
7
|
+
*/
|
|
8
|
+
import { startQrConnect, type QrConnectCredentials } from '@tencent-connect/qqbot-connector';
|
|
9
|
+
|
|
10
|
+
// ── 类型 ──
|
|
11
|
+
|
|
12
|
+
export interface QrLoginStartResult {
|
|
13
|
+
qrDataUrl?: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface QrLoginWaitResult {
|
|
18
|
+
connected: boolean;
|
|
19
|
+
message: string;
|
|
20
|
+
credentials?: QrConnectCredentials[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── 挂起的登录会话 ──
|
|
24
|
+
|
|
25
|
+
interface PendingSession {
|
|
26
|
+
/** 等待扫码结果的 Promise(onSuccess resolve, onFailure reject) */
|
|
27
|
+
credentialsPromise: Promise<QrConnectCredentials[]>;
|
|
28
|
+
/** 取消当前流程 */
|
|
29
|
+
dispose: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pendingSessions = new Map<string, PendingSession>();
|
|
33
|
+
|
|
34
|
+
// ── 启动 QR 登录 ──
|
|
35
|
+
|
|
36
|
+
export function startQrLogin(accountId?: string, source = 'openclaw'): Promise<QrLoginStartResult> {
|
|
37
|
+
const key = accountId ?? 'default';
|
|
38
|
+
|
|
39
|
+
// 清理旧会话
|
|
40
|
+
pendingSessions.get(key)?.dispose();
|
|
41
|
+
pendingSessions.delete(key);
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let credentialsResolve: (creds: QrConnectCredentials[]) => void;
|
|
45
|
+
let credentialsReject: (err: Error) => void;
|
|
46
|
+
|
|
47
|
+
// 凭据 Promise — 在 onSuccess/onFailure 中 settle
|
|
48
|
+
const credentialsPromise = new Promise<QrConnectCredentials[]>((res, rej) => {
|
|
49
|
+
credentialsResolve = res;
|
|
50
|
+
credentialsReject = rej;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const dispose = startQrConnect(
|
|
54
|
+
{
|
|
55
|
+
onQrDisplayed(url) {
|
|
56
|
+
resolve({
|
|
57
|
+
qrDataUrl: url,
|
|
58
|
+
message: '请使用手机 QQ 扫描二维码完成绑定',
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
onSuccess: (creds) => credentialsResolve!(creds),
|
|
62
|
+
onFailure: (err) => credentialsReject!(err),
|
|
63
|
+
},
|
|
64
|
+
{ displayQrCodeToConsole: true, source },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
pendingSessions.set(key, { dispose, credentialsPromise });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── 等待扫码结果 ──
|
|
72
|
+
|
|
73
|
+
export async function waitQrLogin(accountId?: string): Promise<QrLoginWaitResult> {
|
|
74
|
+
const key = accountId ?? 'default';
|
|
75
|
+
const session = pendingSessions.get(key);
|
|
76
|
+
|
|
77
|
+
if (!session) {
|
|
78
|
+
return { connected: false, message: '没有正在进行的登录会话,请先运行 login 命令。' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const credentials = await session.credentialsPromise;
|
|
83
|
+
pendingSessions.delete(key);
|
|
84
|
+
|
|
85
|
+
if (credentials.length === 0) {
|
|
86
|
+
return { connected: false, message: '未获取到 QQ Bot 凭据。' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
connected: true,
|
|
91
|
+
message: `绑定成功!AppID: ${credentials.map((c) => c.appId).join(', ')}`,
|
|
92
|
+
credentials,
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
pendingSessions.delete(key);
|
|
96
|
+
return {
|
|
97
|
+
connected: false,
|
|
98
|
+
message: `绑定失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 主动取消 ──
|
|
104
|
+
|
|
105
|
+
export function cancelQrLogin(accountId?: string): void {
|
|
106
|
+
const key = accountId ?? 'default';
|
|
107
|
+
pendingSessions.get(key)?.dispose();
|
|
108
|
+
pendingSessions.delete(key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 解析 channelInput (appId:clientSecret) ──
|
|
112
|
+
|
|
113
|
+
export function parseChannelInput(channelInput?: string | null): { appId: string; clientSecret: string } | null {
|
|
114
|
+
if (!channelInput) return null;
|
|
115
|
+
const parts = channelInput.trim().split(':');
|
|
116
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
117
|
+
return { appId: parts[0], clientSecret: parts[1] };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── auth.login 入口 ──
|
|
123
|
+
|
|
124
|
+
export interface QqbotLoginParams {
|
|
125
|
+
cfg: Record<string, unknown>;
|
|
126
|
+
accountId?: string | null;
|
|
127
|
+
channelInput?: string | null;
|
|
128
|
+
verbose?: boolean;
|
|
129
|
+
/** 框架注入的 runtime(含 config 持久化 API) */
|
|
130
|
+
[key: string]: unknown;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* auth.login 完整实现。
|
|
135
|
+
*
|
|
136
|
+
* 账号策略:
|
|
137
|
+
* --account <id> → 写入指定账户
|
|
138
|
+
* channelInput → 用 appId 作为账户键
|
|
139
|
+
* QR 扫码 → 用扫码返回的 appId 作为新账户键
|
|
140
|
+
*/
|
|
141
|
+
export async function qqbotLogin({
|
|
142
|
+
cfg,
|
|
143
|
+
accountId,
|
|
144
|
+
channelInput,
|
|
145
|
+
verbose,
|
|
146
|
+
...rest
|
|
147
|
+
}: QqbotLoginParams): Promise<void> {
|
|
148
|
+
// 延迟导入,避免循环依赖
|
|
149
|
+
const { applyQQBotAccountConfig } = await import('../config.js');
|
|
150
|
+
const { persistAuthConfig } = await import('../adapter/resolve.js');
|
|
151
|
+
const { applyAccountDefaults } = await import('./finalize.js');
|
|
152
|
+
const { resolveAccountKey } = await import('./account-key.js');
|
|
153
|
+
|
|
154
|
+
const runtime: Record<string, unknown> = rest as any;
|
|
155
|
+
const resolvedId = accountId ? accountId.trim().toLowerCase() : null;
|
|
156
|
+
|
|
157
|
+
// 如果传了 appId:clientSecret,直接写入配置
|
|
158
|
+
const parsed = parseChannelInput(channelInput);
|
|
159
|
+
if (parsed) {
|
|
160
|
+
const key = resolveAccountKey(cfg as any, parsed.appId, resolvedId);
|
|
161
|
+
const result = applyQQBotAccountConfig(cfg as any, key, {
|
|
162
|
+
appId: parsed.appId,
|
|
163
|
+
clientSecret: parsed.clientSecret,
|
|
164
|
+
});
|
|
165
|
+
Object.assign(cfg, result);
|
|
166
|
+
// 对齐 setup:写入默认 streaming/dmPolicy
|
|
167
|
+
const withDefaults = applyAccountDefaults(cfg as any, key);
|
|
168
|
+
Object.assign(cfg, withDefaults);
|
|
169
|
+
await persistAuthConfig(runtime, cfg, 'restart');
|
|
170
|
+
if (verbose) console.log(`QQ Bot 已配置 (${key}) AppID: ${parsed.appId}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// QR 扫码登录
|
|
175
|
+
const { qrDataUrl, message } = await startQrLogin(resolvedId || 'pending');
|
|
176
|
+
console.log(`\n${message}`);
|
|
177
|
+
if (qrDataUrl) console.log(`QR 链接: ${qrDataUrl}`);
|
|
178
|
+
|
|
179
|
+
const result = await waitQrLogin(resolvedId || 'pending');
|
|
180
|
+
if (!result.connected || !result.credentials) {
|
|
181
|
+
throw new Error(result.message);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const cred of result.credentials) {
|
|
185
|
+
const key = resolveAccountKey(cfg as any, cred.appId, resolvedId);
|
|
186
|
+
const next = applyQQBotAccountConfig(cfg as any, key, {
|
|
187
|
+
appId: cred.appId,
|
|
188
|
+
clientSecret: cred.appSecret,
|
|
189
|
+
});
|
|
190
|
+
Object.assign(cfg, next);
|
|
191
|
+
// 对齐 setup:写入默认 streaming/dmPolicy,扫码用户加入白名单
|
|
192
|
+
const withDefaults = applyAccountDefaults(cfg as any, key, cred.userOpenid);
|
|
193
|
+
Object.assign(cfg, withDefaults);
|
|
194
|
+
}
|
|
195
|
+
await persistAuthConfig(runtime, cfg, 'restart');
|
|
196
|
+
console.log(`QQ Bot 登录成功!`);
|
|
197
|
+
}
|
package/src/setup/surface.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ChannelSetupWizard } from '../adapter/setup.js';
|
|
5
5
|
import { createStandardChannelSetupStatus, setSetupChannelEnabled } from '../adapter/setup.js';
|
|
6
|
-
import { listQQBotAccountIds, resolveQQBotAccount } from '../config.js';
|
|
6
|
+
import { listQQBotAccountIds, resolveQQBotAccount, resolveDefaultQQBotAccountId } from '../config.js';
|
|
7
7
|
import { finalizeQQBotSetup } from './finalize.js';
|
|
8
8
|
|
|
9
9
|
const CHANNEL = 'qqbot' as const;
|
|
@@ -24,6 +24,14 @@ export const qqbotSetupWizard: ChannelSetupWizard = {
|
|
|
24
24
|
return Boolean(account.appId && account.clientSecret);
|
|
25
25
|
}),
|
|
26
26
|
}),
|
|
27
|
+
// 未配置时默认使用 default 账号,有账户时框架会提示选择
|
|
28
|
+
resolveAccountIdForConfigure: async ({ cfg, shouldPromptAccountIds, accountOverride, defaultAccountId }) => {
|
|
29
|
+
if (accountOverride) return accountOverride;
|
|
30
|
+
const ids = listQQBotAccountIds(cfg as any);
|
|
31
|
+
if (ids.length === 0) return 'default';
|
|
32
|
+
if (!shouldPromptAccountIds) return ids[0];
|
|
33
|
+
return defaultAccountId || resolveDefaultQQBotAccountId(cfg as any);
|
|
34
|
+
},
|
|
27
35
|
credentials: [],
|
|
28
36
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
37
|
finalize: (async ({ cfg, accountId, prompter, runtime }: any) =>
|