@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,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 审批 payload 判断逻辑。
|
|
3
|
+
*
|
|
4
|
+
* 仅保留 isApprovalPayload —— 用于在出站链路中识别审批类 payload,
|
|
5
|
+
* 从而抑制框架侧重复的审批 prompt(native 审批由 approval-capability 处理)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 检查 payload 是否为审批消息(execApproval / plugin approval) */
|
|
9
|
+
export function isApprovalPayload(payload: unknown): boolean {
|
|
10
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
11
|
+
const p = payload as Record<string, unknown>;
|
|
12
|
+
const cd = p.channelData;
|
|
13
|
+
if (cd && typeof cd === 'object' && !Array.isArray(cd)) {
|
|
14
|
+
const execApproval = (cd as Record<string, unknown>).execApproval;
|
|
15
|
+
if (execApproval && typeof execApproval === 'object' && !Array.isArray(execApproval)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const text = typeof p.text === 'string' ? p.text : '';
|
|
20
|
+
return /(?:Plugin|Exec) approval (?:required|allowed|denied|expired)/i.test(text);
|
|
21
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 指令面板自动同步(QQ Bot 指令面板 API,/v2/panels,2026-08-12 上线)
|
|
3
|
+
*
|
|
4
|
+
* gateway ready 后(initFeatures 触发)把 openclaw tier=essential 的原生指令
|
|
5
|
+
* 注册到 QQ Bot 指令面板,c2c / group 各一个(target_type=all)。
|
|
6
|
+
* 用户点击面板项 → 指令文本填入输入框 → 用户发送 → 以普通文本进入框架 →
|
|
7
|
+
* 框架文本斜杠指令路由执行(本插件不声明 capabilities.nativeCommands,
|
|
8
|
+
* 避免与 commands.text=false 配置冲突)。
|
|
9
|
+
*
|
|
10
|
+
* 平台约束(官方文档):
|
|
11
|
+
* - 面板项 name ≤14 字符、desc ≤30 字符、每面板 ≤20 项、每 bot ≤20 个面板
|
|
12
|
+
* - POST /v2/panels 限 10 QPM — 常规同步 2 GET(c2c+group 各一页)+ 2 POST,余量充足
|
|
13
|
+
* - GET /v2/panels 限 30 QPM,**scope 为必填查询参数**(缺失报 40030011 生效场景不合法),
|
|
14
|
+
* 列表分页(next_cursor / is_end)
|
|
15
|
+
*
|
|
16
|
+
* 幂等识别:自家面板用 panel.remark = "openclaw-qqbot:auto:<scope>" 打标;
|
|
17
|
+
* remark 不匹配的面板(用户在开放平台手动创建的)绝不修改或删除。
|
|
18
|
+
*
|
|
19
|
+
* 同步时机:每进程每账号仅一次(防 WebSocket resumed 重连时 onReady 重复触发);
|
|
20
|
+
* 同步失败会释放 once-guard,下次重连可重试。全程错误只记日志,绝不阻断启动。
|
|
21
|
+
*/
|
|
22
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/config-contracts';
|
|
23
|
+
import {
|
|
24
|
+
findCommandByNativeName,
|
|
25
|
+
listNativeCommandSpecsForConfig,
|
|
26
|
+
} from 'openclaw/plugin-sdk/native-command-registry';
|
|
27
|
+
import type { NativeCommandSpec } from 'openclaw/plugin-sdk/native-command-registry';
|
|
28
|
+
import { tryGetBotForAccount } from '../bot-instance.js';
|
|
29
|
+
import type { ResolvedQQBotAccount } from '../types.js';
|
|
30
|
+
import type { PluginLogger } from '../utils/plugin-logger.js';
|
|
31
|
+
|
|
32
|
+
/** 自家面板的 remark 前缀(后接 scope:c2c / group) */
|
|
33
|
+
const PANEL_REMARK_PREFIX = 'openclaw-qqbot:auto:';
|
|
34
|
+
/** 同步的面板 scope(channel/dm 属于频道产品,本插件不涉及) */
|
|
35
|
+
const PANEL_SCOPES = ['c2c', 'group'] as const;
|
|
36
|
+
/** 平台限制:面板项 name 最大长度(约 7 个汉字) */
|
|
37
|
+
const PANEL_ITEM_NAME_MAX = 14;
|
|
38
|
+
/** 平台限制:面板项 desc 最大长度(约 15 个汉字) */
|
|
39
|
+
const PANEL_ITEM_DESC_MAX = 30;
|
|
40
|
+
/** 平台限制:每面板最大项数 */
|
|
41
|
+
const PANEL_ITEM_LIMIT = 20;
|
|
42
|
+
/** 列表分页:每页条数(官方上限 50);循环上限防意外无限翻页 */
|
|
43
|
+
const PANEL_LIST_PAGE_SIZE = 50;
|
|
44
|
+
const PANEL_LIST_MAX_PAGES = 5;
|
|
45
|
+
/** openclaw 命令注册表的 provider 标识 */
|
|
46
|
+
const QQBOT_PROVIDER = 'qqbot';
|
|
47
|
+
|
|
48
|
+
/** 面板项(type=command:点击后 name 文本填入聊天输入框) */
|
|
49
|
+
export interface CommandPanelItem {
|
|
50
|
+
type: 'command';
|
|
51
|
+
name: string;
|
|
52
|
+
desc: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** SDK ApiGateway 的结构子集(便于测试注入 fake) */
|
|
56
|
+
export interface CommandPanelApi {
|
|
57
|
+
get<T = unknown>(path: string, query?: Record<string, string | number | boolean>): Promise<T>;
|
|
58
|
+
post<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
59
|
+
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
60
|
+
delete<T = unknown>(path: string): Promise<T>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** GET /v2/panels 返回的面板记录(字段位置防御式兼容两种嵌套) */
|
|
64
|
+
interface PanelRecord {
|
|
65
|
+
panel_id?: string;
|
|
66
|
+
id?: string;
|
|
67
|
+
remark?: string;
|
|
68
|
+
panel?: { remark?: string; version?: number } | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 单个 scope 的同步结果 */
|
|
72
|
+
export interface CommandPanelSyncResult {
|
|
73
|
+
scope: string;
|
|
74
|
+
action: 'created' | 'updated';
|
|
75
|
+
itemCount: number;
|
|
76
|
+
duplicatesRemoved: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── 纯函数:指令选取 ──
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 从原生指令 spec 列表选取 essential 指令并映射为面板项。
|
|
83
|
+
* tierOf 为指令名 → tier 的回查函数(NativeCommandSpec 不携带 tier,
|
|
84
|
+
* 需经 findCommandByNativeName 回查 ChatCommandDefinition;undefined 视为 standard)。
|
|
85
|
+
*/
|
|
86
|
+
export function selectEssentialCommands(
|
|
87
|
+
specs: readonly NativeCommandSpec[],
|
|
88
|
+
tierOf: (nativeName: string) => string | undefined,
|
|
89
|
+
log?: PluginLogger,
|
|
90
|
+
): CommandPanelItem[] {
|
|
91
|
+
const items: CommandPanelItem[] = [];
|
|
92
|
+
for (const spec of specs) {
|
|
93
|
+
if (spec.isAlias) continue;
|
|
94
|
+
if (tierOf(spec.name) !== 'essential') continue;
|
|
95
|
+
const name = `/${spec.name}`;
|
|
96
|
+
if (name.length > PANEL_ITEM_NAME_MAX) {
|
|
97
|
+
log?.warn(
|
|
98
|
+
`[command-panel] 指令 ${name} 超过面板 name 上限 ${PANEL_ITEM_NAME_MAX} 字符,未注册(用户仍可手动输入)`,
|
|
99
|
+
);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
items.push({ type: 'command', name, desc: truncateDesc(spec.description) });
|
|
103
|
+
if (items.length >= PANEL_ITEM_LIMIT) break;
|
|
104
|
+
}
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** desc 归一为单行并截断到平台上限 */
|
|
109
|
+
function truncateDesc(desc: string): string {
|
|
110
|
+
const cleaned = (desc ?? '').replace(/\s+/g, ' ').trim();
|
|
111
|
+
return cleaned.length > PANEL_ITEM_DESC_MAX ? cleaned.slice(0, PANEL_ITEM_DESC_MAX) : cleaned;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 同步核心(可独立测试) ──
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 把 items 整包同步到 c2c / group 两个面板(幂等):
|
|
118
|
+
* 无自家面板 → POST 创建;有一个 → PUT 覆盖;有重复 → PUT 第一个并 DELETE 其余。
|
|
119
|
+
* remark 不匹配的面板不触碰。
|
|
120
|
+
*/
|
|
121
|
+
export async function syncAccountCommandPanels(
|
|
122
|
+
api: CommandPanelApi,
|
|
123
|
+
items: readonly CommandPanelItem[],
|
|
124
|
+
log?: PluginLogger,
|
|
125
|
+
): Promise<CommandPanelSyncResult[]> {
|
|
126
|
+
const byScope = await listPanels(api, log);
|
|
127
|
+
const results: CommandPanelSyncResult[] = [];
|
|
128
|
+
for (const scope of PANEL_SCOPES) {
|
|
129
|
+
const remark = panelRemark(scope);
|
|
130
|
+
const mine = byScope[scope].filter((p) => panelRemarkOf(p) === remark);
|
|
131
|
+
if (mine.length === 0) {
|
|
132
|
+
await api.post('/v2/panels', {
|
|
133
|
+
scope,
|
|
134
|
+
target_type: 'all',
|
|
135
|
+
panel: { items, remark },
|
|
136
|
+
});
|
|
137
|
+
results.push({ scope, action: 'created', itemCount: items.length, duplicatesRemoved: 0 });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const [keep, ...duplicates] = mine;
|
|
141
|
+
await api.put(`/v2/panels/${panelIdOf(keep)}`, {
|
|
142
|
+
panel: { items, remark },
|
|
143
|
+
});
|
|
144
|
+
let removed = 0;
|
|
145
|
+
for (const dup of duplicates) {
|
|
146
|
+
const id = panelIdOf(dup);
|
|
147
|
+
if (!id) continue;
|
|
148
|
+
await api.delete(`/v2/panels/${id}`);
|
|
149
|
+
removed++;
|
|
150
|
+
}
|
|
151
|
+
results.push({ scope, action: 'updated', itemCount: items.length, duplicatesRemoved: removed });
|
|
152
|
+
}
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 列表按 scope 分桶:GET 必传 scope,每个 scope 的同步只看自己场景的面板 */
|
|
157
|
+
type PanelsByScope = Record<(typeof PANEL_SCOPES)[number], PanelRecord[]>;
|
|
158
|
+
|
|
159
|
+
async function listPanels(api: CommandPanelApi, log?: PluginLogger): Promise<PanelsByScope> {
|
|
160
|
+
// 官方文档(bot.q.qq.com GET /v2/panels):scope 为必填查询参数,缺失时服务端报
|
|
161
|
+
// 40030011「生效场景不合法」;列表按 next_cursor / is_end 分页。故按 scope 各查一遍。
|
|
162
|
+
const byScope: PanelsByScope = { c2c: [], group: [] };
|
|
163
|
+
for (const scope of PANEL_SCOPES) {
|
|
164
|
+
let cursor = '';
|
|
165
|
+
for (let page = 0; page < PANEL_LIST_MAX_PAGES; page++) {
|
|
166
|
+
const query: Record<string, string | number | boolean> = { scope, limit: PANEL_LIST_PAGE_SIZE };
|
|
167
|
+
if (cursor) query.cursor = cursor;
|
|
168
|
+
const raw: unknown = await api.get('/v2/panels', query);
|
|
169
|
+
const records = extractPanelRecords(raw);
|
|
170
|
+
byScope[scope].push(...records);
|
|
171
|
+
if (records.length === 0 && page === 0) {
|
|
172
|
+
log?.debug(
|
|
173
|
+
`[command-panel] GET /v2/panels?scope=${scope} 拉到 0 条记录,响应形状: ${JSON.stringify(raw).slice(0, 500)}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
cursor = extractNextCursor(raw);
|
|
177
|
+
if (!cursor || extractIsEnd(raw)) break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return byScope;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 官方列表响应形如 { records, next_cursor, is_end };旧/未知形状无分页字段时返回空串 */
|
|
184
|
+
function extractNextCursor(raw: unknown): string {
|
|
185
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
186
|
+
const v = (raw as Record<string, unknown>).next_cursor;
|
|
187
|
+
if (typeof v === 'string') return v;
|
|
188
|
+
}
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractIsEnd(raw: unknown): boolean {
|
|
193
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
194
|
+
const v = (raw as Record<string, unknown>).is_end;
|
|
195
|
+
if (typeof v === 'boolean') return v;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractPanelRecords(raw: unknown): PanelRecord[] {
|
|
201
|
+
if (Array.isArray(raw)) return raw.filter(isPanelRecord);
|
|
202
|
+
if (raw && typeof raw === 'object') {
|
|
203
|
+
for (const key of ['records', 'panels', 'data', 'list', 'items'] as const) {
|
|
204
|
+
const value = (raw as Record<string, unknown>)[key];
|
|
205
|
+
if (Array.isArray(value)) return value.filter(isPanelRecord);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isPanelRecord(value: unknown): value is PanelRecord {
|
|
212
|
+
return Boolean(value) && typeof value === 'object';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function panelRemark(scope: string): string {
|
|
216
|
+
return PANEL_REMARK_PREFIX + scope;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** remark 可能扁平在记录上,也可能嵌在 panel 对象里 */
|
|
220
|
+
function panelRemarkOf(record: PanelRecord): string | undefined {
|
|
221
|
+
return record.remark ?? record.panel?.remark ?? undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function panelIdOf(record: PanelRecord): string | undefined {
|
|
225
|
+
return record.panel_id ?? record.id ?? undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 编排入口(initFeatures 调用) ──
|
|
229
|
+
|
|
230
|
+
/** 每进程每账号只同步一次(防 resumed 重连重复触发) */
|
|
231
|
+
const syncedAccounts = new Set<string>();
|
|
232
|
+
|
|
233
|
+
/** 测试钩子:清除 once-guard */
|
|
234
|
+
export function clearCommandPanelSynced(accountId?: string): void {
|
|
235
|
+
if (accountId === undefined) syncedAccounts.clear();
|
|
236
|
+
else syncedAccounts.delete(accountId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 同步账号的指令面板(fire-and-forget,错误只记日志)。
|
|
241
|
+
* 任何异常都不抛出,绝不影响消息收发。
|
|
242
|
+
*/
|
|
243
|
+
export async function syncCommandPanels(
|
|
244
|
+
account: ResolvedQQBotAccount,
|
|
245
|
+
cfg: unknown,
|
|
246
|
+
log?: PluginLogger,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
if (!account.commandPanelNative) return;
|
|
249
|
+
if (syncedAccounts.has(account.accountId)) return;
|
|
250
|
+
|
|
251
|
+
const bot = tryGetBotForAccount(account.accountId);
|
|
252
|
+
if (!bot) {
|
|
253
|
+
log?.debug(`[command-panel] bot 未就绪,跳过本次面板同步: ${account.accountId}`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
syncedAccounts.add(account.accountId);
|
|
258
|
+
try {
|
|
259
|
+
const items = buildEssentialItems(cfg, log);
|
|
260
|
+
if (items.length === 0) {
|
|
261
|
+
log?.warn('[command-panel] 未选取到任何 essential 指令,跳过面板同步(避免清空现有面板)');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const results = await syncAccountCommandPanels(bot.api, items, log);
|
|
265
|
+
for (const r of results) {
|
|
266
|
+
log?.info(
|
|
267
|
+
`[command-panel] ${r.scope} 面板已${r.action === 'created' ? '创建' : '更新'}:${r.itemCount} 项指令` +
|
|
268
|
+
(r.duplicatesRemoved > 0 ? `,清理重复面板 ${r.duplicatesRemoved} 个` : ''),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
// 失败释放 once-guard:resumed 重连会再次触发 initFeatures,提供一次重试机会
|
|
273
|
+
syncedAccounts.delete(account.accountId);
|
|
274
|
+
log?.warn(`[command-panel] 指令面板同步失败(不影响消息收发): ${formatError(err)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 枚举 openclaw 原生指令并选取 essential 子集 */
|
|
279
|
+
function buildEssentialItems(cfg: unknown, log?: PluginLogger): CommandPanelItem[] {
|
|
280
|
+
let specs: NativeCommandSpec[];
|
|
281
|
+
try {
|
|
282
|
+
specs = listNativeCommandSpecsForConfig(cfg as OpenClawConfig, {
|
|
283
|
+
provider: QQBOT_PROVIDER,
|
|
284
|
+
includeBundledChannelFallback: false,
|
|
285
|
+
});
|
|
286
|
+
} catch (err) {
|
|
287
|
+
log?.warn(`[command-panel] 枚举 openclaw 原生指令失败: ${formatError(err)}`);
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
return selectEssentialCommands(
|
|
291
|
+
specs,
|
|
292
|
+
(name) =>
|
|
293
|
+
findCommandByNativeName(name, QQBOT_PROVIDER, { includeBundledChannelFallback: false })?.tier,
|
|
294
|
+
log,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatError(err: unknown): string {
|
|
299
|
+
if (err instanceof Error) return err.message;
|
|
300
|
+
return String(err);
|
|
301
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 凭证暂存与恢复
|
|
3
|
+
*
|
|
4
|
+
* 解决热更新被打断时 openclaw.json 中 appId/secret 丢失的问题。
|
|
5
|
+
*
|
|
6
|
+
* 存储路径:~/.openclaw/qqbot/data/credential-backup/current.json
|
|
7
|
+
* 使用子目录避免被框架 SQLite 迁移扫描(迁移只匹配 data/credential-backup.json)。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { getQQBotDataDir } from '../utils/platform.js';
|
|
12
|
+
|
|
13
|
+
const BACKUP_DIR = 'credential-backup';
|
|
14
|
+
const BACKUP_FILENAME = 'current.json';
|
|
15
|
+
|
|
16
|
+
interface CredentialBackup {
|
|
17
|
+
accountId: string;
|
|
18
|
+
appId: string;
|
|
19
|
+
clientSecret: string;
|
|
20
|
+
savedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getBackupPath(): string {
|
|
24
|
+
return path.join(getQQBotDataDir('data'), BACKUP_DIR, BACKUP_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 保存凭证快照到暂存文件(gateway 成功启动后调用)
|
|
29
|
+
*/
|
|
30
|
+
export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void {
|
|
31
|
+
if (!appId || !clientSecret) return;
|
|
32
|
+
try {
|
|
33
|
+
const backupPath = getBackupPath();
|
|
34
|
+
const dir = path.dirname(backupPath);
|
|
35
|
+
if (!fs.existsSync(dir)) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
const data: CredentialBackup = {
|
|
39
|
+
accountId,
|
|
40
|
+
appId,
|
|
41
|
+
clientSecret,
|
|
42
|
+
savedAt: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
const tmpPath = backupPath + '.tmp';
|
|
45
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
46
|
+
fs.renameSync(tmpPath, backupPath);
|
|
47
|
+
} catch {
|
|
48
|
+
// 非关键操作,静默忽略
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 从暂存文件读取凭证(仅在配置为空时调用)
|
|
54
|
+
* 返回 null 表示无可用备份
|
|
55
|
+
*/
|
|
56
|
+
export function loadCredentialBackup(accountId?: string): CredentialBackup | null {
|
|
57
|
+
try {
|
|
58
|
+
const backupPath = getBackupPath();
|
|
59
|
+
if (fs.existsSync(backupPath)) {
|
|
60
|
+
return readBackupFile(backupPath, accountId);
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readBackupFile(filePath: string, accountId?: string): CredentialBackup | null {
|
|
69
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
70
|
+
const data: CredentialBackup = JSON.parse(raw);
|
|
71
|
+
if (!data.appId || !data.clientSecret) return null;
|
|
72
|
+
if (accountId && data.accountId !== accountId) return null;
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 群推送模式推断存储(内存,跨重启不保留)
|
|
3
|
+
*
|
|
4
|
+
* 群主把 bot 拉进群时会选推送模式(AT 纯 / AT+最近N≤10 / 全量),这是群主侧
|
|
5
|
+
* 的设置,插件不可控、同账号各群混合存在。平台只通过事件类型暴露这个事实:
|
|
6
|
+
* - GROUP_AT_MESSAGE_CREATE → AT 系(纯 AT 或 AT+最近N,两者无法区分)
|
|
7
|
+
* - GROUP_MESSAGE_CREATE → 全量模式(bot 像普通群成员收到所有消息)
|
|
8
|
+
*
|
|
9
|
+
* 推断结果供 /bot-group-info 展示与排障("这个群为什么没上下文"——群主设的
|
|
10
|
+
* 是纯 AT 模式)。模式变化打 INFO 日志留痕。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 平台推送模式(按事件类型推断) */
|
|
14
|
+
export type GroupPushMode = 'at' | 'full';
|
|
15
|
+
|
|
16
|
+
export interface GroupModeFacts {
|
|
17
|
+
/** 推断的推送模式(按最近一次事件类型) */
|
|
18
|
+
mode: GroupPushMode;
|
|
19
|
+
/** 最近一次见到的事件类型(原文保留) */
|
|
20
|
+
lastEventType: string;
|
|
21
|
+
/** 曾见过 msg_elements(AT+最近N 的上下文载体,或用户引用/转发消息) */
|
|
22
|
+
sawMsgElements: boolean;
|
|
23
|
+
/** 首次/最近观测时间(epoch ms) */
|
|
24
|
+
firstSeenAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_TRACKED_GROUPS = 1000;
|
|
29
|
+
|
|
30
|
+
const store = new Map<string, GroupModeFacts>();
|
|
31
|
+
|
|
32
|
+
function storeKey(accountId: string, groupOpenid: string): string {
|
|
33
|
+
return `${accountId}:${groupOpenid}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 记录一次群事件观测;推断模式变化时返回新模式(供调用方打日志),否则 null */
|
|
37
|
+
export function recordGroupEvent(
|
|
38
|
+
accountId: string,
|
|
39
|
+
groupOpenid: string,
|
|
40
|
+
eventType: string,
|
|
41
|
+
hasMsgElements: boolean,
|
|
42
|
+
): GroupPushMode | null {
|
|
43
|
+
const key = storeKey(accountId, groupOpenid);
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const mode: GroupPushMode = eventType === 'GROUP_MESSAGE_CREATE' ? 'full' : 'at';
|
|
46
|
+
const prev = store.get(key);
|
|
47
|
+
|
|
48
|
+
if (prev) {
|
|
49
|
+
const modeChanged = prev.mode !== mode;
|
|
50
|
+
prev.lastEventType = eventType;
|
|
51
|
+
prev.mode = mode;
|
|
52
|
+
prev.sawMsgElements = prev.sawMsgElements || hasMsgElements;
|
|
53
|
+
prev.updatedAt = now;
|
|
54
|
+
return modeChanged ? mode : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (store.size >= MAX_TRACKED_GROUPS) {
|
|
58
|
+
// 简单防膨胀:丢弃最早观测的一半(推断数据可随时重建,不值得引入 LRU 复杂度)
|
|
59
|
+
const keys = [...store.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
60
|
+
for (const [k] of keys.slice(0, Math.floor(keys.length / 2))) store.delete(k);
|
|
61
|
+
}
|
|
62
|
+
store.set(key, {
|
|
63
|
+
mode,
|
|
64
|
+
lastEventType: eventType,
|
|
65
|
+
sawMsgElements: hasMsgElements,
|
|
66
|
+
firstSeenAt: now,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
});
|
|
69
|
+
return null; // 首次观测不视为「变化」
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getGroupModeFacts(accountId: string, groupOpenid: string): GroupModeFacts | undefined {
|
|
73
|
+
return store.get(storeKey(accountId, groupOpenid));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 测试用:清空存储 */
|
|
77
|
+
export function _resetGroupModeStore(): void {
|
|
78
|
+
store.clear();
|
|
79
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { MemoryHistoryStore } from '@tencent-connect/qqbot-nodejs';
|
|
2
|
+
import type { HistoryStore, HistoryEntry } from '@tencent-connect/qqbot-nodejs';
|
|
3
|
+
|
|
4
|
+
/** 出站历史条目(在 HistoryEntry 上附加 isBot 标记,rolling 模式裁剪用) */
|
|
5
|
+
export type OutboundHistoryEntry = HistoryEntry & { isBot: true };
|
|
6
|
+
|
|
7
|
+
let _store: HistoryStore | null = null;
|
|
8
|
+
|
|
9
|
+
export function getHistoryStore(): HistoryStore {
|
|
10
|
+
if (!_store) _store = new MemoryHistoryStore();
|
|
11
|
+
return _store;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 用 accountId 前缀隔离多账号,避免同群历史串用 */
|
|
15
|
+
export function historyGroupKey(accountId: string, groupId: string): string {
|
|
16
|
+
return `${accountId}:${groupId}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 清空群历史(dispatch 完成后调用,historyMode=clear 语义) */
|
|
20
|
+
export function clearGroupHistory(accountId: string, groupId: string): void {
|
|
21
|
+
_store?.clear?.(historyGroupKey(accountId, groupId));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* bot 出站消息记入群历史(historyMode=rolling 语义)。
|
|
26
|
+
* 仅群消息回复需要;条目带 isBot=true,供裁剪定位"bot 最后一条发言"。
|
|
27
|
+
*/
|
|
28
|
+
export function recordOutboundToGroupHistory(
|
|
29
|
+
accountId: string,
|
|
30
|
+
groupOpenid: string,
|
|
31
|
+
entry: { messageId: string; senderId: string; senderName?: string; content: string },
|
|
32
|
+
limit: number,
|
|
33
|
+
): void {
|
|
34
|
+
if (limit <= 0) return;
|
|
35
|
+
const historyEntry: OutboundHistoryEntry = {
|
|
36
|
+
messageId: entry.messageId,
|
|
37
|
+
senderId: entry.senderId,
|
|
38
|
+
senderName: entry.senderName,
|
|
39
|
+
content: entry.content,
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
isBot: true,
|
|
42
|
+
};
|
|
43
|
+
void _store?.append?.(historyGroupKey(accountId, groupOpenid), historyEntry, limit);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* rolling 模式裁剪:删除最后一条 bot 出站(含)之前的全部条目,
|
|
48
|
+
* 保留其后的人类消息(下次 @ 时组包,AI 由此知道自己上次说到哪)。
|
|
49
|
+
* 无 bot 出站记录时不动(保守:等价于不清)。返回保留的条数(供日志)。
|
|
50
|
+
*/
|
|
51
|
+
export function trimGroupHistoryAfterLastBot(accountId: string, groupId: string, limit: number): number {
|
|
52
|
+
const store = _store;
|
|
53
|
+
if (!store?.list || !store.clear || !store.append) return 0;
|
|
54
|
+
const key = historyGroupKey(accountId, groupId);
|
|
55
|
+
const listed = store.list(key, 1_000_000);
|
|
56
|
+
if (listed instanceof Promise) return 0; // 异步后端不支持同步裁剪(当前为 MemoryHistoryStore 同步实现)
|
|
57
|
+
const entries: HistoryEntry[] = listed;
|
|
58
|
+
if (entries.length === 0) return 0;
|
|
59
|
+
|
|
60
|
+
let lastBotIdx = -1;
|
|
61
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
62
|
+
if ((entries[i] as { isBot?: boolean }).isBot === true) {
|
|
63
|
+
lastBotIdx = i;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (lastBotIdx < 0) return 0;
|
|
68
|
+
|
|
69
|
+
const kept = entries.slice(lastBotIdx + 1);
|
|
70
|
+
store.clear(key);
|
|
71
|
+
for (const e of kept) {
|
|
72
|
+
store.append(key, e, Math.max(limit, kept.length));
|
|
73
|
+
}
|
|
74
|
+
return kept.length;
|
|
75
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息 ID 缓存 — 记录最近处理的 msgId,供被动回复使用。
|
|
3
|
+
*
|
|
4
|
+
* 场景:外部调用发送消息时没有上下文,
|
|
5
|
+
* 从缓存获取最近一条未过期的 msgId 作为被动回复目标。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface CachedMsgId {
|
|
9
|
+
msgId: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MAX_PER_TARGET = 10;
|
|
14
|
+
const TTL_GROUP = 5 * 60 * 1000; // 5 分钟
|
|
15
|
+
const TTL_C2C = 30 * 60 * 1000; // 30 分钟
|
|
16
|
+
const MAX_TARGETS = 200;
|
|
17
|
+
|
|
18
|
+
const cache = new Map<string, CachedMsgId[]>();
|
|
19
|
+
|
|
20
|
+
export function cacheMsgId(scope: string, targetId: string, msgId: string): void {
|
|
21
|
+
if (!scope || !targetId || !msgId) return;
|
|
22
|
+
const key = `${scope}:${targetId}`;
|
|
23
|
+
const existing = cache.get(key);
|
|
24
|
+
if (existing) {
|
|
25
|
+
// LRU: 删除再重新插入
|
|
26
|
+
cache.delete(key);
|
|
27
|
+
existing.push({ msgId, timestamp: Date.now() });
|
|
28
|
+
if (existing.length > MAX_PER_TARGET) existing.shift();
|
|
29
|
+
cache.set(key, existing);
|
|
30
|
+
} else {
|
|
31
|
+
cache.set(key, [{ msgId, timestamp: Date.now() }]);
|
|
32
|
+
if (cache.size > MAX_TARGETS) {
|
|
33
|
+
const oldest = cache.keys().next().value;
|
|
34
|
+
if (oldest) cache.delete(oldest);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getCachedMsgId(scope: string, targetId: string): string | undefined {
|
|
40
|
+
const key = `${scope}:${targetId}`;
|
|
41
|
+
const list = cache.get(key);
|
|
42
|
+
if (!list || list.length === 0) return undefined;
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const ttl = scope === 'group' ? TTL_GROUP : TTL_C2C;
|
|
45
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
46
|
+
if (now - list[i].timestamp < ttl) {
|
|
47
|
+
return list[i].msgId;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clearMsgIdCache(scope: string, targetId: string): void {
|
|
54
|
+
cache.delete(`${scope}:${targetId}`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站消息回声登记
|
|
3
|
+
*
|
|
4
|
+
* SDK message-filter 文档注明:QQ 可能把机器人自己的出站消息回投为入站
|
|
5
|
+
* 事件(群聊尤其常见)。c2c 事件不映射 author.bot,SDK 的 skipSelfEcho
|
|
6
|
+
* 过滤对私聊回声无效 —— 这里按「近期出站消息 id」自行比对。
|
|
7
|
+
*
|
|
8
|
+
* 出站 id 来源:wrapBotSendForRefIndex(sendText / sendMedia / 流式
|
|
9
|
+
* complete 的返回值 msg.id)。若平台回声携带新 id 而非原 id,比对会
|
|
10
|
+
* miss —— 由 inbound-guard 的空内容/去重检查兜底。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const TTL_MS = 30 * 60 * 1000; // 与 msgid-cache 的 c2c TTL 对齐
|
|
14
|
+
const MAX_ENTRIES = 2000;
|
|
15
|
+
|
|
16
|
+
const seen = new Map<string, number>(); // `${accountId}:${messageId}` -> 记录时间
|
|
17
|
+
|
|
18
|
+
function prune(now: number): void {
|
|
19
|
+
for (const [key, ts] of seen) {
|
|
20
|
+
if (seen.size <= MAX_ENTRIES && now - ts <= TTL_MS) break;
|
|
21
|
+
seen.delete(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function recordOutboundMessageId(accountId: string, messageId: string | undefined | null): void {
|
|
26
|
+
if (!messageId) return;
|
|
27
|
+
seen.set(`${accountId}:${messageId}`, Date.now());
|
|
28
|
+
prune(Date.now());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isOutboundEcho(accountId: string, messageId: string | undefined | null): boolean {
|
|
32
|
+
if (!messageId) return false;
|
|
33
|
+
const key = `${accountId}:${messageId}`;
|
|
34
|
+
const ts = seen.get(key);
|
|
35
|
+
if (ts === undefined) return false;
|
|
36
|
+
if (Date.now() - ts > TTL_MS) {
|
|
37
|
+
seen.delete(key);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 测试用:清空登记表 */
|
|
44
|
+
export function clearOutboundEchoStore(): void {
|
|
45
|
+
seen.clear();
|
|
46
|
+
}
|