@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.
- package/LICENSE +22 -0
- package/README.md +967 -0
- package/README.zh.md +790 -0
- package/dist/index.cjs +18072 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1222 -0
- package/index.ts +92 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +69 -0
- package/preload.cjs +19 -0
- package/scripts/link-sdk-core.cjs +268 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/skills/qqbot-channel/SKILL.md +285 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-remind/SKILL.md +159 -0
- package/skills/qqbot-upgrade/SKILL.md +56 -0
- package/src/adapter/contract.ts +63 -0
- package/src/adapter/lint.ts +144 -0
- package/src/adapter/media.ts +40 -0
- package/src/adapter/pairing.ts +95 -0
- package/src/adapter/resolve.ts +255 -0
- package/src/adapter/setup.ts +13 -0
- package/src/adapter/webhook.ts +248 -0
- package/src/adapter/workspace.ts +21 -0
- package/src/agent-prompt-adapter.ts +26 -0
- package/src/bot-instance.ts +60 -0
- package/src/channel.ts +230 -0
- package/src/commands/bot-approve.ts +143 -0
- package/src/commands/bot-clear-storage.ts +114 -0
- package/src/commands/bot-group-always.ts +62 -0
- package/src/commands/bot-group-info.ts +48 -0
- package/src/commands/bot-help.ts +40 -0
- package/src/commands/bot-logs.ts +248 -0
- package/src/commands/bot-me.ts +18 -0
- package/src/commands/bot-pairing.ts +50 -0
- package/src/commands/bot-ping.ts +33 -0
- package/src/commands/bot-streaming.ts +55 -0
- package/src/commands/bot-upgrade.ts +56 -0
- package/src/commands/bot-version.ts +41 -0
- package/src/commands/config-util.ts +96 -0
- package/src/commands/index.ts +51 -0
- package/src/config.ts +403 -0
- package/src/constants.ts +6 -0
- package/src/dispatch/body-assembler.ts +308 -0
- package/src/dispatch/ctx-builder.ts +127 -0
- package/src/dispatch/dispatch.ts +667 -0
- package/src/dispatch/envelope-builder.ts +112 -0
- package/src/dispatch/index.ts +2 -0
- package/src/features/approval-capability.ts +302 -0
- package/src/features/approval-helpers.ts +271 -0
- package/src/features/approval-utils.ts +21 -0
- package/src/features/command-panel.ts +301 -0
- package/src/features/credential-backup.ts +74 -0
- package/src/features/group-mode-store.ts +79 -0
- package/src/features/history-store.ts +75 -0
- package/src/features/msgid-cache.ts +55 -0
- package/src/features/outbound-echo-store.ts +46 -0
- package/src/features/proactive-budget.ts +57 -0
- package/src/features/proactive.ts +549 -0
- package/src/features/question-helpers.ts +771 -0
- package/src/features/quota-manager.ts +173 -0
- package/src/features/ref-index-store.ts +289 -0
- package/src/features/secret-input-store.ts +118 -0
- package/src/features/secret-store-cli.ts +324 -0
- package/src/features/typing-refresh.ts +51 -0
- package/src/features/update-checker.ts +166 -0
- package/src/gateway/event-handlers.ts +456 -0
- package/src/gateway/index.ts +3 -0
- package/src/gateway/lifecycle.ts +236 -0
- package/src/gateway/middleware-setup.ts +173 -0
- package/src/gateway/qqbot-gateway.ts +458 -0
- package/src/gateway-adapter.ts +44 -0
- package/src/heartbeat-adapter.ts +57 -0
- package/src/message-adapter.ts +40 -0
- package/src/messaging-adapter.ts +78 -0
- package/src/middleware/access-control.ts +125 -0
- package/src/middleware/attachment.ts +373 -0
- package/src/middleware/inbound-guard.ts +102 -0
- package/src/middleware/policy-injector.ts +71 -0
- package/src/middleware/secret-capture.ts +161 -0
- package/src/middleware/typing.ts +110 -0
- package/src/openclaw-plugin-sdk.d.ts +543 -0
- package/src/outbound/chunker.ts +80 -0
- package/src/outbound/debounce.ts +102 -0
- package/src/outbound/deliver-pipeline.ts +235 -0
- package/src/outbound/index.ts +3 -0
- package/src/outbound/local-file-router.ts +145 -0
- package/src/outbound/media-send.ts +408 -0
- package/src/outbound/outbound-service.ts +298 -0
- package/src/outbound/reply-limiter.ts +139 -0
- package/src/outbound/sanitize.ts +32 -0
- package/src/outbound/streaming-controller.ts +332 -0
- package/src/outbound/target.ts +109 -0
- package/src/outbound-adapter.ts +323 -0
- package/src/plugin-base.ts +42 -0
- package/src/request-context.ts +50 -0
- package/src/runtime.ts +42 -0
- package/src/setup/account-key.ts +41 -0
- package/src/setup/finalize.ts +110 -0
- package/src/setup/login.ts +197 -0
- package/src/setup/surface.ts +40 -0
- package/src/status-adapter.ts +56 -0
- package/src/tools/platform.ts +149 -0
- package/src/tools/remind.ts +308 -0
- package/src/tools/secret-input.ts +185 -0
- package/src/types-augment.d.ts +54 -0
- package/src/types-plugin.ts +82 -0
- package/src/types.ts +620 -0
- package/src/typing-lifecycle.ts +182 -0
- package/src/utils/mention.ts +52 -0
- package/src/utils/pkg-version.ts +23 -0
- package/src/utils/platform.ts +459 -0
- package/src/utils/plugin-logger.ts +104 -0
- package/src/utils/ssrf-guard.ts +132 -0
- package/src/utils/stt.ts +150 -0
- package/src/utils/voice-text.ts +61 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +64 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBotGateway 事件处理
|
|
3
|
+
*
|
|
4
|
+
* 处理 SDK 的 message / interaction 事件:
|
|
5
|
+
* - message: 中间件处理完毕后,将消息转发到 OpenClaw AI
|
|
6
|
+
* - interaction: 配置更新 / 审批按钮 / ask_user 按钮
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MiddlewareContext, QQBotInboundMessage, InteractionEvent } from '@tencent-connect/qqbot-nodejs';
|
|
10
|
+
import type { PluginRuntime } from 'openclaw/plugin-sdk';
|
|
11
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
12
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
13
|
+
import { dispatchToOpenClaw } from '../dispatch/index.js';
|
|
14
|
+
import { runWithRequestContext } from '../request-context.js';
|
|
15
|
+
import { authorizeQQBotApprovalAction } from '../features/approval-capability.js';
|
|
16
|
+
import { parseApprovalButtonData } from '../features/approval-helpers.js';
|
|
17
|
+
import {
|
|
18
|
+
getQuestionGatewayRuntime,
|
|
19
|
+
parseQuestionButtonData,
|
|
20
|
+
parseMultiQuestionButtonData,
|
|
21
|
+
recordMultiQuestionTap,
|
|
22
|
+
markMultiQuestionResolveFailed,
|
|
23
|
+
getPendingMultiQuestions,
|
|
24
|
+
buildMultiQuestionAnswerText,
|
|
25
|
+
formatMultiQuestionConfirmCard,
|
|
26
|
+
buildMultiQuestionConfirmKeyboard,
|
|
27
|
+
type MultiQuestionAnswer,
|
|
28
|
+
} from '../features/question-helpers.js';
|
|
29
|
+
import { recordKnownUser } from '../features/proactive.js';
|
|
30
|
+
import { cacheMsgId } from '../features/msgid-cache.js';
|
|
31
|
+
import { recordGroupEvent, getGroupModeFacts } from '../features/group-mode-store.js';
|
|
32
|
+
import { getAdapters } from '../adapter/resolve.js';
|
|
33
|
+
import { resolveGroupConfigFromAccount, resolveGroupPolicy, resolveMentionPatterns } from '../config.js';
|
|
34
|
+
import { getPackageVersion } from '../utils/pkg-version.js';
|
|
35
|
+
import { getOpenClawVersion, tryGetBotForAccount } from '../bot-instance.js';
|
|
36
|
+
import type { ParsedMultiQuestionAction } from '../features/question-helpers.js';
|
|
37
|
+
|
|
38
|
+
export async function handleMessage(
|
|
39
|
+
ctx: MiddlewareContext,
|
|
40
|
+
msg: QQBotInboundMessage,
|
|
41
|
+
account: ResolvedQQBotAccount,
|
|
42
|
+
runtime: PluginRuntime,
|
|
43
|
+
log: PluginLogger,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const hlog = log.child('handle');
|
|
46
|
+
const scope = msg.replyTarget.scope;
|
|
47
|
+
const targetId = scope === 'group'
|
|
48
|
+
? `qqbot:group:${msg.replyTarget.targetId}`
|
|
49
|
+
: `qqbot:c2c:${msg.replyTarget.targetId}`;
|
|
50
|
+
|
|
51
|
+
hlog.debug(`enter msgId=${msg.messageId} scope=${scope} contentLen=${(msg.content ?? '').length}`);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// 群推送模式推断(AT 系 vs 全量):按事件类型记录;首次观测与模式变化都打 INFO
|
|
55
|
+
// 留痕(模式由群主设置、可随时变,事后排障靠这两条日志定位「为什么收得到/收不到消息」)
|
|
56
|
+
if (scope === 'group') {
|
|
57
|
+
const seenBefore = getGroupModeFacts(account.accountId, msg.replyTarget.targetId);
|
|
58
|
+
const modeChange = recordGroupEvent(
|
|
59
|
+
account.accountId,
|
|
60
|
+
msg.replyTarget.targetId,
|
|
61
|
+
(msg as { rawEventType?: string }).rawEventType ?? '',
|
|
62
|
+
Array.isArray((msg as { msgElements?: unknown[] }).msgElements),
|
|
63
|
+
);
|
|
64
|
+
const current = getGroupModeFacts(account.accountId, msg.replyTarget.targetId);
|
|
65
|
+
if (modeChange) {
|
|
66
|
+
hlog.info(`[group-mode] push mode changed to ${modeChange} for group=${msg.replyTarget.targetId}`);
|
|
67
|
+
} else if (!seenBefore && current) {
|
|
68
|
+
hlog.info(`[group-mode] first observation mode=${current.mode} group=${msg.replyTarget.targetId}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cacheMsgId(scope, msg.replyTarget.targetId, msg.messageId);
|
|
73
|
+
|
|
74
|
+
recordKnownUser({
|
|
75
|
+
type: scope === 'group' ? 'group' : 'c2c',
|
|
76
|
+
openid: scope === 'group' ? msg.replyTarget.targetId : msg.senderId,
|
|
77
|
+
accountId: account.accountId,
|
|
78
|
+
nickname: msg.senderName,
|
|
79
|
+
lastInteractionAt: Date.now(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── 多问题 ask_user 的最终答案不在此拦截 ──
|
|
83
|
+
// 确认卡的指令按钮代发(或用户手打)的 "1: 3\n2: 1" 是真实用户消息,
|
|
84
|
+
// 必须原样放行走框架原生 keyed 文本认领;任何拦截都会破坏认领链路。
|
|
85
|
+
|
|
86
|
+
await runWithRequestContext(
|
|
87
|
+
{
|
|
88
|
+
accountId: account.accountId,
|
|
89
|
+
messageId: msg.messageId,
|
|
90
|
+
openId: msg.senderId,
|
|
91
|
+
target: targetId,
|
|
92
|
+
},
|
|
93
|
+
() => dispatchToOpenClaw(ctx, msg, account, runtime, log),
|
|
94
|
+
);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
hlog.error(`dispatch error: ${err}`);
|
|
97
|
+
}
|
|
98
|
+
hlog.debug(`done msgId=${msg.messageId}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const INTERACTION_QUERY = 2001;
|
|
102
|
+
const INTERACTION_UPDATE = 2002;
|
|
103
|
+
|
|
104
|
+
export async function handleInteraction(
|
|
105
|
+
event: InteractionEvent,
|
|
106
|
+
account: ResolvedQQBotAccount,
|
|
107
|
+
runtime: PluginRuntime,
|
|
108
|
+
log: PluginLogger,
|
|
109
|
+
acknowledgeInteraction: (id: string, code?: number, data?: Record<string, unknown>) => Promise<void>,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
if (event.data?.type === INTERACTION_QUERY) {
|
|
112
|
+
await handleConfigQuery(event, account, runtime, log, acknowledgeInteraction);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (event.data?.type === INTERACTION_UPDATE) {
|
|
116
|
+
await handleConfigUpdate(event, account, runtime, log);
|
|
117
|
+
try {
|
|
118
|
+
const adapters = getAdapters(runtime);
|
|
119
|
+
const cfg = adapters.getConfig?.() ?? {};
|
|
120
|
+
const groupOpenid = (event as any).group_openid ?? '';
|
|
121
|
+
const updatedCfg = groupOpenid ? resolveGroupConfigFromAccount(account, groupOpenid) : null;
|
|
122
|
+
const requireMention = updatedCfg?.requireMention ?? true;
|
|
123
|
+
const clawCfg = buildClawCfg(requireMention, [], resolveGroupPolicy(cfg, account.accountId));
|
|
124
|
+
await acknowledgeInteraction(event.id, 0, { claw_cfg: clawCfg });
|
|
125
|
+
} catch {
|
|
126
|
+
try { await acknowledgeInteraction(event.id); } catch { /* ignore */ }
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// question 按钮(ask_user,含单问题 qqbot:q: 与多问题 qqbot:qm:)优先于审批按钮
|
|
132
|
+
const buttonData = event.data?.resolved?.button_data;
|
|
133
|
+
if (buttonData?.startsWith('qqbot:q:') || buttonData?.startsWith('qqbot:qm:')) {
|
|
134
|
+
await handleQuestion(event, account, runtime, log, acknowledgeInteraction);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await handleApproval(event, account, runtime, log, acknowledgeInteraction);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Interaction 子处理 ──
|
|
142
|
+
|
|
143
|
+
async function handleConfigQuery(
|
|
144
|
+
event: InteractionEvent,
|
|
145
|
+
account: ResolvedQQBotAccount,
|
|
146
|
+
runtime: PluginRuntime,
|
|
147
|
+
log: PluginLogger,
|
|
148
|
+
ack: (id: string, code?: number, data?: Record<string, unknown>) => Promise<void>,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const groupOpenid = (event as any).group_openid ?? '';
|
|
151
|
+
try {
|
|
152
|
+
const adapters = getAdapters(runtime);
|
|
153
|
+
const cfg = adapters.getConfig?.() ?? {};
|
|
154
|
+
const groupCfg = groupOpenid ? resolveGroupConfigFromAccount(account, groupOpenid) : null;
|
|
155
|
+
const requireMention = groupCfg?.requireMention ?? true;
|
|
156
|
+
const agentId = groupOpenid
|
|
157
|
+
? adapters.resolveAgentRoute?.({ cfg, channel: 'qqbot', accountId: account.accountId, peer: { kind: 'group', id: groupOpenid } })?.agentId
|
|
158
|
+
: undefined;
|
|
159
|
+
const mentionPatterns = resolveMentionPatterns(cfg, agentId);
|
|
160
|
+
const clawCfg = buildClawCfg(requireMention, mentionPatterns, resolveGroupPolicy(cfg, account.accountId));
|
|
161
|
+
log.info(`interaction query: group=${groupOpenid} requireMention=${requireMention}`);
|
|
162
|
+
await ack(event.id, 0, { claw_cfg: clawCfg });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log.warn(`interaction query failed: ${(err as Error)?.message ?? err}, ack without data`);
|
|
165
|
+
try { await ack(event.id); } catch { /* ignore */ }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function handleConfigUpdate(
|
|
170
|
+
event: InteractionEvent,
|
|
171
|
+
account: ResolvedQQBotAccount,
|
|
172
|
+
runtime: PluginRuntime,
|
|
173
|
+
log: PluginLogger,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const resolved = (event.data as any)?.resolved;
|
|
176
|
+
const update = resolved?.claw_cfg;
|
|
177
|
+
const groupOpenid = (event as any).group_openid ?? '';
|
|
178
|
+
|
|
179
|
+
if (update?.require_mention !== undefined && groupOpenid) {
|
|
180
|
+
try {
|
|
181
|
+
await setGroupRequireMention(runtime, account.accountId, groupOpenid, update.require_mention === 'mention');
|
|
182
|
+
log.info(`interaction: group=${groupOpenid} requireMention=${update.require_mention}`);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
log.error(`interaction update failed: ${err}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleApproval(
|
|
190
|
+
event: InteractionEvent,
|
|
191
|
+
account: ResolvedQQBotAccount,
|
|
192
|
+
runtime: PluginRuntime,
|
|
193
|
+
log: PluginLogger,
|
|
194
|
+
ack: (id: string) => Promise<void>,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
try { await ack(event.id); } catch { /* ignore */ }
|
|
197
|
+
|
|
198
|
+
const buttonData = event.data?.resolved?.button_data;
|
|
199
|
+
if (!buttonData?.startsWith('approve:')) return;
|
|
200
|
+
|
|
201
|
+
const parsed = parseApprovalButtonData(buttonData);
|
|
202
|
+
if (!parsed) return;
|
|
203
|
+
|
|
204
|
+
// Live config snapshot so allowFrom changes apply without a restart.
|
|
205
|
+
// getConfig returns `any` — keep it untyped here to avoid crossing the
|
|
206
|
+
// project's hand-written ambient SDK types with the real SDK types.
|
|
207
|
+
const cfg = getAdapters(runtime).getConfig?.() ?? {};
|
|
208
|
+
|
|
209
|
+
// 身份授权校验:操作者需在 allowFrom 白名单中
|
|
210
|
+
const operatorId = resolveOperatorId(event);
|
|
211
|
+
const authorization = authorizeQQBotApprovalAction({
|
|
212
|
+
cfg,
|
|
213
|
+
accountId: account.accountId,
|
|
214
|
+
senderId: operatorId,
|
|
215
|
+
});
|
|
216
|
+
if (!authorization.authorized) {
|
|
217
|
+
log.warn(`[approval] unauthorized operator=${operatorId ?? 'unknown'} account=${account.accountId}${authorization.reason ? ` reason=${authorization.reason}` : ''}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const { resolveApprovalOverGateway } = await import('openclaw/plugin-sdk/approval-gateway-runtime');
|
|
223
|
+
await resolveApprovalOverGateway({
|
|
224
|
+
cfg,
|
|
225
|
+
approvalId: parsed.approvalId,
|
|
226
|
+
approvalKind: parsed.approvalKind,
|
|
227
|
+
decision: parsed.decision,
|
|
228
|
+
senderId: operatorId,
|
|
229
|
+
clientDisplayName: 'QQBot Approval Handler',
|
|
230
|
+
});
|
|
231
|
+
} catch (err) {
|
|
232
|
+
log.error(`interaction approve error: ${err instanceof Error ? err.message : String(err)}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Question 按钮处理(ask_user)──
|
|
237
|
+
|
|
238
|
+
async function handleQuestion(
|
|
239
|
+
event: InteractionEvent,
|
|
240
|
+
account: ResolvedQQBotAccount,
|
|
241
|
+
runtime: PluginRuntime,
|
|
242
|
+
log: PluginLogger,
|
|
243
|
+
ack: (id: string) => Promise<void>,
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
try { await ack(event.id); } catch { /* ignore */ }
|
|
246
|
+
|
|
247
|
+
const buttonData = event.data?.resolved?.button_data;
|
|
248
|
+
if (!buttonData) return;
|
|
249
|
+
|
|
250
|
+
const parsed = parseQuestionButtonData(buttonData);
|
|
251
|
+
if (!parsed) {
|
|
252
|
+
const multiParsed = parseMultiQuestionButtonData(buttonData);
|
|
253
|
+
if (multiParsed) {
|
|
254
|
+
await handleMultiQuestionTap(multiParsed, event, account, runtime, log);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 无授权检查:ask_user 问题面向所有参与者(与审批不同,question 不是安全敏感操作)。
|
|
260
|
+
// 框架侧 questionGatewayRuntime.resolveOption 已处理过期/重复提交等边界情况。
|
|
261
|
+
const operatorId = resolveOperatorId(event);
|
|
262
|
+
const cfg = getAdapters(runtime).getConfig?.() ?? {};
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const questionGatewayRuntime = await getQuestionGatewayRuntime();
|
|
266
|
+
const result = await questionGatewayRuntime.resolveOption({
|
|
267
|
+
cfg,
|
|
268
|
+
questionId: parsed.questionId,
|
|
269
|
+
optionIndex: parsed.optionIndex,
|
|
270
|
+
senderId: operatorId,
|
|
271
|
+
clientDisplayName: 'QQBot question',
|
|
272
|
+
});
|
|
273
|
+
log.debug(`[question] resolved questionId=${parsed.questionId} optionIndex=${parsed.optionIndex} status=${result.status}`);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
log.error(`[question] resolve error: ${err instanceof Error ? err.message : String(err)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 多问题按钮处理(ask_user questions >= 2)──
|
|
280
|
+
// 框架对多问题记录没有单题按钮解析通道(resolveOption 仅支持单问题),
|
|
281
|
+
// 这里按题缓冲点选,集齐后发一张"确认卡":指令按钮(action.type=2,
|
|
282
|
+
// enter=true)携带完整答案文本 "1: 3\n2: 1",由 QQ 客户端以真实用户
|
|
283
|
+
// 消息发出,走框架原生的 keyed 文本认领通道 resolve 挂起的 ask_user。
|
|
284
|
+
// 注意两条红线(都出过生产事故,勿回退):
|
|
285
|
+
// 1. 不要程序化提交:官方 openclaw 无多问题提交 API,改官方代码要背
|
|
286
|
+
// fork 维护成本;
|
|
287
|
+
// 2. 不要合成入站消息"假装用户回答":合成消息会被框架当成 steering
|
|
288
|
+
// 注入挂起的 run,claim 不触发,模型等答案直到超时(2026-08-24 事故)。
|
|
289
|
+
// 只有真实用户消息(客户端代发或手打)能被框架认领。
|
|
290
|
+
|
|
291
|
+
async function handleMultiQuestionTap(
|
|
292
|
+
parsed: ParsedMultiQuestionAction,
|
|
293
|
+
event: InteractionEvent,
|
|
294
|
+
account: ResolvedQQBotAccount,
|
|
295
|
+
runtime: PluginRuntime,
|
|
296
|
+
log: PluginLogger,
|
|
297
|
+
): Promise<void> {
|
|
298
|
+
const operatorId = resolveOperatorId(event);
|
|
299
|
+
const scope: 'group' | 'c2c' = event.group_openid ? 'group' : 'c2c';
|
|
300
|
+
const peerId = (scope === 'group' ? event.group_openid : event.user_openid) ?? '';
|
|
301
|
+
if (!peerId) return; // 缺少会话定位信息,无法回执或发确认卡
|
|
302
|
+
|
|
303
|
+
log.info(
|
|
304
|
+
`[question] multi tap id=${parsed.questionId} q=${parsed.questionIndex + 1} opt=${parsed.optionIndex + 1} operator=${operatorId ?? 'unknown'}`,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const tap = recordMultiQuestionTap(parsed.questionId, parsed.questionIndex, parsed.optionIndex);
|
|
308
|
+
|
|
309
|
+
if (tap.status === 'unknown' || tap.status === 'terminal') {
|
|
310
|
+
log.info(`[question] multi tap ignored id=${parsed.questionId} status=${tap.status}`);
|
|
311
|
+
await sendMultiQuestionFeedback(event, account, '该问题已提交或已过期');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (tap.status === 'resolving') {
|
|
315
|
+
log.debug(`[question] multi tap dropped while resolving id=${parsed.questionId}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (tap.status === 'buffered') {
|
|
319
|
+
const pendingTitles = tap.pendingQuestions.map((q) => q.header);
|
|
320
|
+
log.info(`[question] multi buffered id=${parsed.questionId} answered=${tap.answeredCount}/${tap.total}`);
|
|
321
|
+
await sendMultiQuestionFeedback(
|
|
322
|
+
event,
|
|
323
|
+
account,
|
|
324
|
+
`✅ 已记录 ${tap.answeredCount}/${tap.total}\n还剩:${pendingTitles.join('、')}`,
|
|
325
|
+
);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// 按钮点选路径不会产生 nomatch(选项序号来自键盘本身)
|
|
329
|
+
if (tap.status !== 'complete') return;
|
|
330
|
+
|
|
331
|
+
// complete:发确认卡,由用户一键代发真实答案消息
|
|
332
|
+
await sendMultiQuestionConfirmCard(
|
|
333
|
+
parsed.questionId,
|
|
334
|
+
tap.answers,
|
|
335
|
+
scope,
|
|
336
|
+
peerId,
|
|
337
|
+
account,
|
|
338
|
+
log,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 集齐后发确认卡。正文按题展示已选内容,键盘带两个指令按钮:
|
|
344
|
+
* 「✅ 提交」(enter=true,客户端自动以用户身份发出答案文本)与
|
|
345
|
+
* 「✏️ 改一改」(填入输入框,用户编辑后手动发送)。
|
|
346
|
+
* 发出后复位 resolving 锁——用户可以继续点选修改答案,再拿到新确认卡。
|
|
347
|
+
*/
|
|
348
|
+
async function sendMultiQuestionConfirmCard(
|
|
349
|
+
questionId: string,
|
|
350
|
+
answers: ReadonlyMap<number, MultiQuestionAnswer>,
|
|
351
|
+
scope: 'c2c' | 'group',
|
|
352
|
+
peerId: string,
|
|
353
|
+
account: ResolvedQQBotAccount,
|
|
354
|
+
log: PluginLogger,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
const answerText = buildMultiQuestionAnswerText(answers);
|
|
357
|
+
const questions = getPendingMultiQuestions(questionId);
|
|
358
|
+
const cardText = questions
|
|
359
|
+
? formatMultiQuestionConfirmCard(questions, answers)
|
|
360
|
+
: `**✅ 答案确认**\n\n${answerText}`;
|
|
361
|
+
|
|
362
|
+
const bot = tryGetBotForAccount(account.accountId);
|
|
363
|
+
if (!bot) {
|
|
364
|
+
markMultiQuestionResolveFailed(questionId);
|
|
365
|
+
log.error(`[question] confirm card send failed: bot unavailable id=${questionId}`);
|
|
366
|
+
await sendMultiQuestionFeedbackText(scope, peerId, account, '⚠️ 提交卡发送失败,请直接文字回复答案');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
await bot.sendTextWithKeyboard({ scope, targetId: peerId }, cardText, buildMultiQuestionConfirmKeyboard(answerText) as never);
|
|
371
|
+
markMultiQuestionResolveFailed(questionId); // 复位锁,允许继续改答案
|
|
372
|
+
log.info(`[question] confirm card sent id=${questionId} answers=${JSON.stringify(answerText)}`);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
markMultiQuestionResolveFailed(questionId);
|
|
375
|
+
log.error(`[question] confirm card send error id=${questionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
376
|
+
await sendMultiQuestionFeedbackText(scope, peerId, account, '⚠️ 提交卡发送失败,请直接文字回复答案');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 尽力而为的状态回执(主动消息),失败仅记日志不影响主流程 */
|
|
381
|
+
async function sendMultiQuestionFeedback(
|
|
382
|
+
event: InteractionEvent,
|
|
383
|
+
account: ResolvedQQBotAccount,
|
|
384
|
+
text: string,
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
const scope = event.group_openid ? 'group' as const : 'c2c' as const;
|
|
387
|
+
const targetId = event.group_openid ?? event.user_openid;
|
|
388
|
+
if (!targetId) return;
|
|
389
|
+
await sendMultiQuestionFeedbackText(scope, targetId, account, text);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function sendMultiQuestionFeedbackText(
|
|
393
|
+
scope: 'c2c' | 'group',
|
|
394
|
+
targetId: string,
|
|
395
|
+
account: ResolvedQQBotAccount,
|
|
396
|
+
text: string,
|
|
397
|
+
): Promise<void> {
|
|
398
|
+
const bot = tryGetBotForAccount(account.accountId);
|
|
399
|
+
if (!bot) return;
|
|
400
|
+
try {
|
|
401
|
+
await bot.sendText({ scope, targetId }, text);
|
|
402
|
+
} catch {
|
|
403
|
+
// 主动消息可能触发平台限频;回执是锦上添花,吞掉即可
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const CHANNEL_VER = getPackageVersion();
|
|
408
|
+
|
|
409
|
+
// ── 审批授权校验 ──
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 从交互事件中提取操作者身份标识。
|
|
413
|
+
* QQ Bot 按钮回调事件中,操作者 openid 通常在 `user_openid` 或 `data.resolved.user_id` 字段。
|
|
414
|
+
*/
|
|
415
|
+
function resolveOperatorId(event: InteractionEvent): string | undefined {
|
|
416
|
+
const evt = event as any;
|
|
417
|
+
return evt.user_openid
|
|
418
|
+
?? evt.data?.resolved?.user_id
|
|
419
|
+
?? evt.data?.resolved?.user_openid
|
|
420
|
+
?? evt.openid;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function buildClawCfg(
|
|
424
|
+
requireMention: boolean,
|
|
425
|
+
mentionPatterns: string[],
|
|
426
|
+
groupPolicy: string,
|
|
427
|
+
): Record<string, unknown> {
|
|
428
|
+
return {
|
|
429
|
+
channel_type: 'qqbot',
|
|
430
|
+
channel_ver: CHANNEL_VER,
|
|
431
|
+
claw_type: 'openclaw',
|
|
432
|
+
claw_ver: getOpenClawVersion(),
|
|
433
|
+
require_mention: requireMention ? 'mention' : 'always',
|
|
434
|
+
group_policy: groupPolicy,
|
|
435
|
+
mention_patterns: mentionPatterns.join(','),
|
|
436
|
+
online_state: 'online',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── Config 写入 ──
|
|
441
|
+
|
|
442
|
+
function setGroupRequireMention(
|
|
443
|
+
runtime: PluginRuntime,
|
|
444
|
+
accountId: string,
|
|
445
|
+
groupOpenid: string,
|
|
446
|
+
requireMention: boolean,
|
|
447
|
+
): Promise<void> {
|
|
448
|
+
const adapters = getAdapters(runtime);
|
|
449
|
+
return adapters.persistConfig?.((cfg: any) => {
|
|
450
|
+
const qqbot = (cfg.channels ?? {})?.qqbot ?? {};
|
|
451
|
+
const groups = accountId !== 'default' && qqbot.accounts?.[accountId]
|
|
452
|
+
? (qqbot.accounts[accountId].groups = { ...qqbot.accounts[accountId].groups })
|
|
453
|
+
: (qqbot.groups = { ...qqbot.groups });
|
|
454
|
+
groups[groupOpenid] = { ...groups[groupOpenid], requireMention };
|
|
455
|
+
}) ?? Promise.resolve();
|
|
456
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway 生命周期管理
|
|
3
|
+
*
|
|
4
|
+
* 封装 startAccount / logoutAccount 的业务逻辑:
|
|
5
|
+
* - 凭证恢复
|
|
6
|
+
* - QQBotGateway 实例创建与注册
|
|
7
|
+
* - 注册 "approval.native" 运行时上下文(框架据此自动引导 native 审批 handler)
|
|
8
|
+
* - 登出时凭证清除
|
|
9
|
+
*/
|
|
10
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/config-contracts';
|
|
11
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_ACCOUNT_ID,
|
|
14
|
+
resolveQQBotAccount,
|
|
15
|
+
applyQQBotAccountConfig,
|
|
16
|
+
resolveQQBotEnvClientSecret,
|
|
17
|
+
} from '../config.js';
|
|
18
|
+
import { getQQBotRuntime } from '../runtime.js';
|
|
19
|
+
import { getAdapters } from '../adapter/resolve.js';
|
|
20
|
+
import { QQBotGateway } from './qqbot-gateway.js';
|
|
21
|
+
import { createPluginLogger } from '../utils/plugin-logger.js';
|
|
22
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
23
|
+
import { registerGateway, unregisterGateway, getGateway } from '../outbound/outbound-service.js';
|
|
24
|
+
import { saveCredentialBackup, loadCredentialBackup } from '../features/credential-backup.js';
|
|
25
|
+
import { triggerUpdateCheck } from '../features/update-checker.js';
|
|
26
|
+
import { syncCommandPanels } from '../features/command-panel.js';
|
|
27
|
+
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from '../features/approval-capability.js';
|
|
28
|
+
import { cleanupTypingByAccount } from '../typing-lifecycle.js';
|
|
29
|
+
|
|
30
|
+
export interface StartAccountContext {
|
|
31
|
+
account: ResolvedQQBotAccount;
|
|
32
|
+
abortSignal?: AbortSignal;
|
|
33
|
+
cfg: any;
|
|
34
|
+
/**
|
|
35
|
+
* 框架传入的基础 logger(无 child 方法)。内部会自动包装为 PluginLogger。
|
|
36
|
+
* 写日志的优先级:info > warn > error > debug,方法均为必选。
|
|
37
|
+
*/
|
|
38
|
+
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug: (msg: string) => void };
|
|
39
|
+
getStatus: () => Record<string, unknown>;
|
|
40
|
+
setStatus: (s: Record<string, unknown>) => void;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 启动账户(含凭证恢复 + features 初始化)
|
|
46
|
+
*/
|
|
47
|
+
export async function startAccountWithCredentialRecovery(ctx: StartAccountContext): Promise<void> {
|
|
48
|
+
let { account } = ctx;
|
|
49
|
+
const { abortSignal, cfg } = ctx;
|
|
50
|
+
const log: PluginLogger = createPluginLogger({
|
|
51
|
+
prefix: `[${account.accountId}]`,
|
|
52
|
+
...(ctx.log?.info ? { output: ctx.log as PluginLogger } : {}),
|
|
53
|
+
});
|
|
54
|
+
const runtime = getQQBotRuntime();
|
|
55
|
+
|
|
56
|
+
// 凭证恢复:配置中 appId/secret 为空时尝试从暂存文件恢复
|
|
57
|
+
if (!account.appId || !account.clientSecret) {
|
|
58
|
+
const backup = loadCredentialBackup(account.accountId);
|
|
59
|
+
if (backup) {
|
|
60
|
+
log?.info(`[qqbot:${account.accountId}] 从暂存文件恢复凭证 (appId=${backup.appId})`);
|
|
61
|
+
try {
|
|
62
|
+
const restoredCfg = applyQQBotAccountConfig(cfg, account.accountId, {
|
|
63
|
+
appId: backup.appId,
|
|
64
|
+
clientSecret: backup.clientSecret,
|
|
65
|
+
});
|
|
66
|
+
const adapters = getAdapters(runtime);
|
|
67
|
+
if (adapters.persistConfig) {
|
|
68
|
+
await adapters.persistConfig(() => restoredCfg);
|
|
69
|
+
}
|
|
70
|
+
account = resolveQQBotAccount(restoredCfg, account.accountId);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
log?.error(`[qqbot:${account.accountId}] 凭证恢复失败: ${e}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 创建 gateway 实例并注册
|
|
78
|
+
const gw = new QQBotGateway(account, runtime, log);
|
|
79
|
+
registerGateway(account.accountId, gw);
|
|
80
|
+
|
|
81
|
+
await gw.start(
|
|
82
|
+
{
|
|
83
|
+
onReady: () => {
|
|
84
|
+
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
|
|
85
|
+
ctx.setStatus({
|
|
86
|
+
...ctx.getStatus(),
|
|
87
|
+
running: true,
|
|
88
|
+
connected: true,
|
|
89
|
+
lastConnectedAt: Date.now(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── Features 初始化(gateway ready 后触发)──
|
|
93
|
+
initFeatures(account, ctx, log).catch((e) => {
|
|
94
|
+
log?.error(`[qqbot:${account.accountId}] initFeatures error: ${e}`);
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
onError: (error) => {
|
|
98
|
+
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
|
|
99
|
+
ctx.setStatus({ ...ctx.getStatus(), lastError: error.message });
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
abortSignal,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Gateway ready 后初始化各 feature 模块
|
|
108
|
+
*/
|
|
109
|
+
async function initFeatures(
|
|
110
|
+
account: ResolvedQQBotAccount,
|
|
111
|
+
ctx: StartAccountContext,
|
|
112
|
+
log: PluginLogger,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
// 1. 版本更新检测(后台预热,fire-and-forget)
|
|
115
|
+
triggerUpdateCheck(log);
|
|
116
|
+
|
|
117
|
+
// 2. 注册 "approval.native" 运行时上下文。框架的 approval bootstrap 监听此上下文,
|
|
118
|
+
// 据此为该账户自动 spawn native 审批 handler(消费 exec/plugin approval 事件,
|
|
119
|
+
// 回调 qqbotPlugin.approvalCapability.nativeRuntime 的 availability/presentation/transport)。
|
|
120
|
+
// 账户停止时 abortSignal 触发,上下文自动注销。
|
|
121
|
+
registerApprovalNativeContext(account.accountId, ctx);
|
|
122
|
+
|
|
123
|
+
// 3. 指令面板同步:把 openclaw essential 原生指令注册到 QQ Bot 指令面板(/v2/panels)。
|
|
124
|
+
// 每进程每账号仅一次;失败只记日志并由 once-guard 释放等待重连重试,不影响消息收发。
|
|
125
|
+
syncCommandPanels(account, ctx.cfg, log).catch((e) => {
|
|
126
|
+
log?.error(`[qqbot:${account.accountId}] command panel sync error: ${e}`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 注册 qqbot 的 native approval 运行时上下文(若框架已提供 channelRuntime)。
|
|
132
|
+
*/
|
|
133
|
+
function registerApprovalNativeContext(accountId: string, ctx: StartAccountContext): void {
|
|
134
|
+
const channelRuntime = (ctx as { channelRuntime?: { runtimeContexts?: any } }).channelRuntime;
|
|
135
|
+
const registry = channelRuntime?.runtimeContexts;
|
|
136
|
+
if (!registry) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
registry.register({
|
|
141
|
+
channelId: 'qqbot',
|
|
142
|
+
accountId,
|
|
143
|
+
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
|
144
|
+
context: { accountId },
|
|
145
|
+
abortSignal: ctx.abortSignal,
|
|
146
|
+
});
|
|
147
|
+
} catch {
|
|
148
|
+
// 重复注册或 registry 异常时静默 — 框架会按 accountId 去重。
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 停止账户 — 主动调用,与 abort 信号双保险。
|
|
154
|
+
*
|
|
155
|
+
* 框架先 abort.abort() 让 startAccount promise 自然 resolve,
|
|
156
|
+
* 再调用 stopAccount 给插件机会做主动清理(关 WebSocket、注销处理器、刷新持久化)。
|
|
157
|
+
*
|
|
158
|
+
* 实现策略:
|
|
159
|
+
* 1. 主动调用 bot.stop() — 比 abort 信号更立刻、不依赖事件循环时机
|
|
160
|
+
* 2. 注销 gateway(native 审批上下文由 abortSignal 自动注销,无需手动清理)
|
|
161
|
+
* 3. 立即返回;剩余资源(typing keepalive 定时器等)由 abort 信号触发收尾
|
|
162
|
+
*/
|
|
163
|
+
export async function stopAccountGracefully(params: {
|
|
164
|
+
accountId: string;
|
|
165
|
+
log?: PluginLogger;
|
|
166
|
+
}): Promise<void> {
|
|
167
|
+
const { accountId, log } = params;
|
|
168
|
+
const gw = getGateway(accountId);
|
|
169
|
+
|
|
170
|
+
// 1. 清理 typing session(防止账户重启后旧 session 阻塞新 session)
|
|
171
|
+
cleanupTypingByAccount(accountId);
|
|
172
|
+
|
|
173
|
+
// 2. 主动停止 bot
|
|
174
|
+
if (gw) {
|
|
175
|
+
try {
|
|
176
|
+
await gw.stop();
|
|
177
|
+
log?.info(`[qqbot:${accountId}] gateway stopped`);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log?.error(`[qqbot:${accountId}] gateway stop error: ${err instanceof Error ? err.message : String(err)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
unregisterGateway(accountId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 登出账户(清除凭证)
|
|
188
|
+
*/
|
|
189
|
+
export async function logoutAndClearCredentials(params: {
|
|
190
|
+
accountId: string;
|
|
191
|
+
cfg: any;
|
|
192
|
+
}): Promise<{ ok: boolean; cleared: boolean; envToken: boolean; loggedOut: boolean }> {
|
|
193
|
+
const { accountId, cfg } = params;
|
|
194
|
+
unregisterGateway(accountId);
|
|
195
|
+
|
|
196
|
+
const nextCfg = { ...cfg } as OpenClawConfig;
|
|
197
|
+
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined;
|
|
198
|
+
let cleared = false;
|
|
199
|
+
let changed = false;
|
|
200
|
+
|
|
201
|
+
if (nextQQBot) {
|
|
202
|
+
const qqbot = nextQQBot as Record<string, unknown>;
|
|
203
|
+
if (accountId === DEFAULT_ACCOUNT_ID && qqbot.clientSecret) {
|
|
204
|
+
delete qqbot.clientSecret;
|
|
205
|
+
cleared = true;
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
|
|
209
|
+
if (accounts && accountId in accounts) {
|
|
210
|
+
const entry = accounts[accountId];
|
|
211
|
+
if (entry && 'clientSecret' in entry) {
|
|
212
|
+
delete entry.clientSecret;
|
|
213
|
+
cleared = true;
|
|
214
|
+
changed = true;
|
|
215
|
+
}
|
|
216
|
+
if (entry && Object.keys(entry).length === 0) {
|
|
217
|
+
delete accounts[accountId];
|
|
218
|
+
changed = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (changed && nextQQBot) {
|
|
224
|
+
nextCfg.channels = { ...nextCfg.channels, qqbot: nextQQBot };
|
|
225
|
+
const runtime = getQQBotRuntime();
|
|
226
|
+
const adapters = getAdapters(runtime);
|
|
227
|
+
if (adapters.persistConfig) {
|
|
228
|
+
await adapters.persistConfig(() => nextCfg);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId);
|
|
233
|
+
const loggedOut = resolved.secretSource === 'none';
|
|
234
|
+
const envToken = Boolean(resolveQQBotEnvClientSecret());
|
|
235
|
+
return { ok: true, cleared, envToken, loggedOut };
|
|
236
|
+
}
|