@cxyhhhhh/openclaw-qqbot 2.0.0-dev.202607101541 → 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 +1139 -835
- package/dist/index.cjs.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/adapter/contract.ts +1 -1
- package/src/adapter/resolve.ts +38 -0
- package/src/channel.ts +15 -0
- package/src/features/approval-utils.ts +0 -4
- 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/skills/qqbot-group/SKILL.md +0 -75
- package/skills/qqbot-group/references/api_reference.md +0 -94
package/openclaw.plugin.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging",
|
|
5
5
|
"channels": ["qqbot"],
|
|
6
6
|
"extensions": ["./preload.cjs"],
|
|
7
|
-
"skills": ["skills/qqbot-channel", "skills/qqbot-
|
|
7
|
+
"skills": ["skills/qqbot-channel", "skills/qqbot-remind", "skills/qqbot-upgrade"],
|
|
8
8
|
"contracts": {
|
|
9
9
|
"tools": ["qqbot_platform_api", "qqbot_remind"]
|
|
10
10
|
},
|
package/package.json
CHANGED
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
|
// ── 状态 ──
|
|
@@ -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,
|
|
@@ -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) =>
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: qqbot-group
|
|
3
|
-
description: QQ 群管理技能。查询群基础信息、群成员信息、机器人群内状态。使用 qqbot_platform_api 工具调用接口,自动鉴权。当用户需要查询群信息、查看群成员、检查机器人群内状态时使用此技能。
|
|
4
|
-
metadata: {"openclaw":{"emoji":"👥","requires":{"config":["channels.qqbot"]}}}
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# QQ 群 API 请求指导
|
|
8
|
-
|
|
9
|
-
通过 `qqbot_platform_api` 工具调用群相关接口,鉴权由工具自动处理。
|
|
10
|
-
|
|
11
|
-
详细字段说明见 `references/api_reference.md`。
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## ⭐ 接口速查
|
|
16
|
-
|
|
17
|
-
| 操作 | 方法 | 路径 |
|
|
18
|
-
|------|------|------|
|
|
19
|
-
| 获取机器人群内状态 | `GET` | `/v2/groups/{group_id}/bot_state` |
|
|
20
|
-
| 获取群成员信息 | `GET` | `/v2/groups/{group_id}/members/{member_id}` |
|
|
21
|
-
| 获取群基础信息 | `GET` | `/v2/groups/{group_id}/info` |
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## 💡 调用示例
|
|
26
|
-
|
|
27
|
-
### 获取机器人群内状态
|
|
28
|
-
|
|
29
|
-
```json
|
|
30
|
-
{
|
|
31
|
-
"method": "GET",
|
|
32
|
-
"path": "/v2/groups/G_123456789/bot_state"
|
|
33
|
-
}
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
返回字段:`joined_at`(入群时间)、`allow_proactive_msg`(是否允许主动推送)、`recv_msg_setting`(`all`/`only_mention`/`mention_and_context`)、`member_role`(`member`/`owner`/`admin`)。
|
|
37
|
-
|
|
38
|
-
### 获取群成员信息
|
|
39
|
-
|
|
40
|
-
```json
|
|
41
|
-
{
|
|
42
|
-
"method": "GET",
|
|
43
|
-
"path": "/v2/groups/G_123456789/members/o_xxxxxxxx"
|
|
44
|
-
}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
返回字段:`username`(昵称)、`member_role`、`joined_at`、`bot` 等。昵称仅在单个查询时返回。
|
|
48
|
-
|
|
49
|
-
### 获取群基础信息
|
|
50
|
-
|
|
51
|
-
```json
|
|
52
|
-
{
|
|
53
|
-
"method": "GET",
|
|
54
|
-
"path": "/v2/groups/G_123456789/info"
|
|
55
|
-
}
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
返回字段:`group_name`、`group_finger_memo`(简介)、`group_class_text`(分类)、`group_tags`(标签)、`group_member_num`(成员数)。
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## 💬 @群成员
|
|
63
|
-
|
|
64
|
-
发送消息时使用 `<@member_id>` 语法 @指定群成员:
|
|
65
|
-
|
|
66
|
-
- 格式:`<@member_id>`,例如 `<@o_xxxxxxxx>`
|
|
67
|
-
- 文本消息中直接拼接即可:`你好 <@o_xxxxxxxx>,欢迎入群!`
|
|
68
|
-
|
|
69
|
-
---
|
|
70
|
-
|
|
71
|
-
## ⚠️ 注意事项
|
|
72
|
-
|
|
73
|
-
1. 路径占位符 `{group_id}`、`{member_id}` 需替换为实际值
|
|
74
|
-
2. 返回时间格式为 RFC3339(如 `2026-06-23T22:03:09+08:00`)
|
|
75
|
-
3. 群 openid 与频道 guild_id 是不同的概念,不要混淆
|