@zaofan/dsh-qqbot 1.1.7 → 1.3.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/README.md +15 -5
- package/README_EN.md +3 -1
- package/client/qqbot-settings.js +132 -26
- package/dist/api/group-admin.d.ts +13 -3
- package/dist/api/group-admin.d.ts.map +1 -1
- package/dist/api/group-admin.js +49 -5
- package/dist/api/group-admin.js.map +1 -1
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +544 -29
- package/dist/channel-tools.js.map +1 -1
- package/dist/commands/answer.d.ts +29 -0
- package/dist/commands/answer.d.ts.map +1 -0
- package/dist/commands/answer.js +29 -0
- package/dist/commands/answer.js.map +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +5 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/misc.d.ts.map +1 -1
- package/dist/commands/misc.js +15 -1
- package/dist/commands/misc.js.map +1 -1
- package/dist/commands/outmode.d.ts.map +1 -1
- package/dist/commands/outmode.js +3 -2
- package/dist/commands/outmode.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/features/broadcast.d.ts.map +1 -1
- package/dist/features/broadcast.js +3 -1
- package/dist/features/broadcast.js.map +1 -1
- package/dist/features/group-hub.d.ts +3 -1
- package/dist/features/group-hub.d.ts.map +1 -1
- package/dist/features/group-hub.js +5 -3
- package/dist/features/group-hub.js.map +1 -1
- package/dist/features/group-join-request.d.ts +2 -2
- package/dist/features/group-join-request.d.ts.map +1 -1
- package/dist/features/group-join-request.js +25 -10
- package/dist/features/group-join-request.js.map +1 -1
- package/dist/features/outbound-mode-switch.d.ts +8 -2
- package/dist/features/outbound-mode-switch.d.ts.map +1 -1
- package/dist/features/outbound-mode-switch.js +2 -2
- package/dist/features/outbound-mode-switch.js.map +1 -1
- package/dist/features/poll-join-requests.js +2 -2
- package/dist/features/poll-join-requests.js.map +1 -1
- package/dist/features/qq-user-questions.d.ts +127 -4
- package/dist/features/qq-user-questions.d.ts.map +1 -1
- package/dist/features/qq-user-questions.js +314 -74
- package/dist/features/qq-user-questions.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +17 -0
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/data-root.d.ts +13 -4
- package/dist/gateway/data-root.d.ts.map +1 -1
- package/dist/gateway/data-root.js +22 -12
- package/dist/gateway/data-root.js.map +1 -1
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +5 -1
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/media-history.d.ts.map +1 -1
- package/dist/middleware/media-history.js +3 -0
- package/dist/middleware/media-history.js.map +1 -1
- package/dist/transport/inbound.js +4 -1
- package/dist/transport/inbound.js.map +1 -1
- package/dist/transport/outbound.d.ts.map +1 -1
- package/dist/transport/outbound.js +60 -5
- package/dist/transport/outbound.js.map +1 -1
- package/package.json +1 -1
- package/settings-host.js +80 -8
package/dist/channel-tools.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 复制进共享 node_modules,也不得在 dependencies 里自带 dsh-tools(避免双包)。
|
|
9
9
|
*/
|
|
10
10
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
11
|
-
import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
12
13
|
import { getStickerStore } from './features/sticker-store.js';
|
|
13
14
|
import { autoTagImage } from './features/sticker-tagger.js';
|
|
14
15
|
import { isStickerGateDenied } from './features/sticker-gate.js';
|
|
@@ -16,6 +17,7 @@ import { getScheduleStore } from './features/schedule-store.js';
|
|
|
16
17
|
import { switchOutboundMode } from './features/outbound-mode-switch.js';
|
|
17
18
|
import { loadExtensionTools } from './features/extension-store.js';
|
|
18
19
|
import { verifyHuman, groupRegistryPath } from './api/group-admin.js';
|
|
20
|
+
import * as broadcastQueue from './features/broadcast.js';
|
|
19
21
|
import { wakeSessionAgent, safeAppendUserMessage } from './features/group-hub.js';
|
|
20
22
|
import { managersOf, findManagerByPeer, findManagerBySessionId } from './features/session-registry.js';
|
|
21
23
|
/** 诊断日志路径: 默认关闭; 需要排查时设环境变量 QQBOT_DIAG_FILE 指向日志文件 */
|
|
@@ -320,11 +322,11 @@ export async function apply(ctx) {
|
|
|
320
322
|
});
|
|
321
323
|
const listStickersTool = defineTool({
|
|
322
324
|
name: 'list_stickers',
|
|
323
|
-
description: '(
|
|
325
|
+
description: '(聊天加分项,气氛对就大胆用!)按情绪关键词搜本地表情包库, 挑最贴切的一张用 send_media(kind=image, source=路径) 发出去(一次最多一张); 实在查不到贴切的才回纯文字。',
|
|
324
326
|
parameters: {
|
|
325
|
-
query: { type: 'string', description: '语义关键词(可空=最近收藏;
|
|
327
|
+
query: { type: 'string', description: '语义关键词(可空=最近收藏; 匹配标签与描述)' },
|
|
326
328
|
tag: { type: 'string', description: '标签过滤(可空)' },
|
|
327
|
-
limit: { type: 'integer', description: '
|
|
329
|
+
limit: { type: 'integer', description: '返回条数 1~20(默认 3)' },
|
|
328
330
|
},
|
|
329
331
|
output: {
|
|
330
332
|
schema: {
|
|
@@ -364,7 +366,8 @@ export async function apply(ctx) {
|
|
|
364
366
|
try {
|
|
365
367
|
const store = stickerStoreOf(exec);
|
|
366
368
|
const limitRaw = args.limit;
|
|
367
|
-
|
|
369
|
+
// 默认 3 条(2026-09-12 token 瘦身: 每条含描述+路径, 5 条≈白烧几十 token, 3 条足够挑图)
|
|
370
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, Math.floor(limitRaw ?? 3))) : 3;
|
|
368
371
|
const hits = store.search({
|
|
369
372
|
q: args.query ?? '',
|
|
370
373
|
tag: args.tag ?? '',
|
|
@@ -414,7 +417,7 @@ export async function apply(ctx) {
|
|
|
414
417
|
};
|
|
415
418
|
const tagStickerTool = defineTool({
|
|
416
419
|
name: 'sticker_tag',
|
|
417
|
-
description: '(
|
|
420
|
+
description: '(主动维护)看到值得留的图就收藏+打标(群友发的有梗/好图, 不管这轮发不发): tags=逗号分隔中文短标签(如 爆笑,元气), desc=一句"画面+情绪+适用场合"; 缺标签的图(见 sticker_untagged)也用它补。想省视觉额度可传 vision=false(只对已在库的图打标)。',
|
|
418
421
|
parameters: {
|
|
419
422
|
sticker: { type: 'string', required: true, description: '图片URL 或 本地绝对路径(也可传 list_stickers 返回的路径)' },
|
|
420
423
|
tags: { type: 'string', description: '逗号分隔中文标签,如 开心,元气,打招呼' },
|
|
@@ -803,9 +806,10 @@ export async function apply(ctx) {
|
|
|
803
806
|
// 切换走 outbound-mode-switch 注册表 → live 热生效 + settings 持久化 → dock/设置一致。
|
|
804
807
|
const outboundModeTool = defineTool({
|
|
805
808
|
name: 'outbound_mode',
|
|
806
|
-
description: '出站模式开关(
|
|
809
|
+
description: '出站模式开关(自己决定, 热更新不用重启): adaptive=适配主动(默认: 真人消息前5条带引用、连发自动转独立消息不被QQ吞); detail=详细主动(聊天同 adaptive, 额外把工具调用/工具结果也推到QQ —— 主人要看进度时用, 消息会变多, 用完切回); passive=被动(始终回最后一条, 连发约4~5条后被QQ吞); silent=完全不出站(照常思考但不发, 潜水观察)。nothink 那档只能主人在设置页配。' +
|
|
810
|
+
'\n怎么选: 正常聊天/被@→adaptive · 主人要看工具进度→detail · 想保持引用感→passive · 判断不该在群里说话→silent',
|
|
807
811
|
parameters: {
|
|
808
|
-
mode: { type: 'string', required: true, enum: ['adaptive', 'passive', 'silent'], description: '目标模式: adaptive(默认推荐) / passive / silent' },
|
|
812
|
+
mode: { type: 'string', required: true, enum: ['adaptive', 'detail', 'passive', 'silent'], description: '目标模式: adaptive(默认推荐) / detail(详细主动: 连工具调用一起推) / passive / silent' },
|
|
809
813
|
reason: { type: 'string', required: true, description: '为什么切到这档(简短理由)' },
|
|
810
814
|
},
|
|
811
815
|
output: {
|
|
@@ -818,8 +822,8 @@ export async function apply(ctx) {
|
|
|
818
822
|
],
|
|
819
823
|
},
|
|
820
824
|
async execute(args, exec) {
|
|
821
|
-
if (args.mode !== 'adaptive' && args.mode !== 'passive' && args.mode !== 'silent') {
|
|
822
|
-
return { ok: false, msg: '只允许 adaptive/passive/silent(nothink 需主人在设置页配置)', mode: String(args.mode ?? '') };
|
|
825
|
+
if (args.mode !== 'adaptive' && args.mode !== 'detail' && args.mode !== 'passive' && args.mode !== 'silent') {
|
|
826
|
+
return { ok: false, msg: '只允许 adaptive/detail/passive/silent(nothink 需主人在设置页配置)', mode: String(args.mode ?? '') };
|
|
823
827
|
}
|
|
824
828
|
// 多实例修复(2026-09-11): 按当前会话所属实例(ns)切换 —— 原全局单例 writer 会被多实例覆盖,
|
|
825
829
|
// 导致切到别的实例的 config(dock 显示与实际不符)。
|
|
@@ -836,6 +840,7 @@ export async function apply(ctx) {
|
|
|
836
840
|
// 主人也一定能收到"模式已切换"的通知(与 send_media 同款工具直发通道, 不受 outboundMode 拦截)。
|
|
837
841
|
const MODE_LABEL = {
|
|
838
842
|
adaptive: '适配主动(推荐默认): 真人消息前5条带引用回你, 连发自动转独立消息',
|
|
843
|
+
detail: '详细主动: 聊天同适配主动, 额外把工具调用/工具结果也推给你(看进度用)',
|
|
839
844
|
passive: '被动: 始终回复你那条(连发约4~5条后被QQ吞)',
|
|
840
845
|
silent: '完全不出站: 照常思考但这条回复不发出(潜水观察用; web上仍可对话)',
|
|
841
846
|
};
|
|
@@ -893,9 +898,12 @@ export async function apply(ctx) {
|
|
|
893
898
|
}
|
|
894
899
|
const listJoinRequestsTool = defineTool({
|
|
895
900
|
name: 'group_join_requests',
|
|
896
|
-
description: '群管理(读):
|
|
901
|
+
description: '群管理(读): 看待审批入群申请(**默认只给本页 5 条**, 用 page 翻页; 同一人重复申请只留最新)。传 gid 可查指定群, 不填=当前群/manageGroup。仅主人要求时调用; 需开启"QQ群管理"且机器人为群管理员。跨群审批请显式带 gid。' +
|
|
902
|
+
'\n示例: {gid:"<群openid>"} · {gid:"<群openid>", page:2} · {limit:20 看全}',
|
|
897
903
|
parameters: {
|
|
898
|
-
gid: { type: 'string', description: '目标群 openid(可选)。不填=当前会话群或 manageGroup
|
|
904
|
+
gid: { type: 'string', description: '目标群 openid(可选)。不填=当前会话群或 manageGroup' },
|
|
905
|
+
page: { type: 'integer', description: '第几页(默认 1)' },
|
|
906
|
+
limit: { type: 'integer', description: '本页条数 1~20(默认 5; 要一次看全可传 20)' },
|
|
899
907
|
},
|
|
900
908
|
output: {
|
|
901
909
|
schema: {
|
|
@@ -922,16 +930,34 @@ export async function apply(ctx) {
|
|
|
922
930
|
gname = gi.data.group_name;
|
|
923
931
|
}
|
|
924
932
|
catch { /* 群名失败不影响 */ }
|
|
925
|
-
const
|
|
926
|
-
if (
|
|
933
|
+
const raw = r.data.list;
|
|
934
|
+
if (raw.length === 0)
|
|
927
935
|
return { ok: true, msg: `当前没有待审批的入群申请 ✓(群 ${gname ? `「${gname}」` : ''}${gid})` };
|
|
928
|
-
|
|
929
|
-
|
|
936
|
+
// 去重(2026-09-12 主人要求): 同一人反复申请只留**最新**一条 —— 官方列表按时间倒序, 故首次出现即最新
|
|
937
|
+
const seen = new Set();
|
|
938
|
+
const list = raw.filter((j) => {
|
|
939
|
+
const key = String(j.member_openid ?? '');
|
|
940
|
+
if (key === '' || seen.has(key))
|
|
941
|
+
return false;
|
|
942
|
+
seen.add(key);
|
|
943
|
+
return true;
|
|
944
|
+
});
|
|
945
|
+
// 分页(2026-09-12 token 瘦身): 默认只回 5 条, 其余用 page 翻
|
|
946
|
+
const lim = Math.max(1, Math.min(20, Math.round(Number(args.limit)) || 5));
|
|
947
|
+
const totalPages = Math.max(1, Math.ceil(list.length / lim));
|
|
948
|
+
const page = Math.min(Math.max(1, Math.round(Number(args.page)) || 1), totalPages);
|
|
949
|
+
const start = (page - 1) * lim;
|
|
950
|
+
const slice = list.slice(start, start + lim);
|
|
951
|
+
const head = `入群申请 共 ${raw.length} 条(去重后 ${list.length} 条 · 第 ${page}/${totalPages} 页 · 每页 ${lim} 条) 群 ${gname ? `「${gname}」` : ''}${gid}:`;
|
|
952
|
+
const lines = slice.map((j, i) => `${start + i + 1}. ${j.username ?? '?'} (${j.member_openid}) 来源:${j.apply_source ?? '?'} 验证:${verifyHuman(j.verify_info) || '-'}${j.risk_tips ? ` ⚠️${j.risk_tips}` : ''}`);
|
|
953
|
+
const more = list.length > start + slice.length ? `\n(还有 ${list.length - start - slice.length} 条 → 再调本工具传 page=${page + 1})` : '';
|
|
954
|
+
return { ok: true, msg: `${head}\n${lines.join('\n')}${more}` };
|
|
930
955
|
},
|
|
931
956
|
});
|
|
932
957
|
const approveJoinTool = defineTool({
|
|
933
958
|
name: 'group_approve_join',
|
|
934
|
-
description: '群管理(写,危险):
|
|
959
|
+
description: '群管理(写,危险): 审批入群申请(approve 放行 / decline 拒绝可带理由), 支持批量 member_openids。仅主人明确要求时调用; 调用前先用 group_join_requests 核对申请人。⚠️跨群审批务必显式传 gid(不传=当前会话所在群, 会批错群)。' +
|
|
960
|
+
'\n示例: {gid:"<群openid>", member_openids:["<id1>","<id2>"], op:"approve", reason:"-"} · 拒绝: op:"decline", reason:"答非所问"',
|
|
935
961
|
parameters: {
|
|
936
962
|
gid: { type: 'string', description: '目标群 openid(可选)。不填=当前会话群或 manageGroup; 填了则审批指定群的申请' },
|
|
937
963
|
member_openids: { type: 'array', description: '批量审批: 申请人 member_openid 数组(来自 group_join_requests), 一次批多人' },
|
|
@@ -988,10 +1014,121 @@ export async function apply(ctx) {
|
|
|
988
1014
|
return { ok: failed === targets.length ? false : true, msg: `${targets.length - failed}/${targets.length} 成功:\n${results.join('\n')}` };
|
|
989
1015
|
},
|
|
990
1016
|
});
|
|
1017
|
+
/**
|
|
1018
|
+
* 入群申请「自动审批」(2026-09-12 主人要求): 给一组关键词 —— **验证消息命中的程序直接放行, 不命中的直接拒绝**。
|
|
1019
|
+
* ⚠️ 基于**官方待审列表**(`join_request_list`), **不是**本地 pending 流水 —— 后者含已处理/重复事件, 照着批会报错。
|
|
1020
|
+
* ⚠️ 拒绝不可逆: 默认直接执行(主人风格), 想先看结果传 `dry_run=true`。
|
|
1021
|
+
* 拒绝理由一律中性(默认"答非所问"), **绝不写暗号/答案/内部判据**。
|
|
1022
|
+
*/
|
|
1023
|
+
const autoApproveJoinTool = defineTool({
|
|
1024
|
+
name: 'group_join_auto',
|
|
1025
|
+
description: '群管理(写,危险,**仅主人明确要求时调用**): 按关键词批量审批入群申请 —— 验证消息**命中任一关键词的放行**, 其余直接拒绝。基于官方待审列表(不含本地历史流水)。拒绝不可逆; 想先看结果传 dry_run=true。' +
|
|
1026
|
+
'\n示例: {gid:"<群openid>", keywords:["早饭"], rejectReason:"答非所问"} · 只放行不拒: {..., rejectUnmatched:false} · 预览: {..., dry_run:true}',
|
|
1027
|
+
parameters: {
|
|
1028
|
+
gid: { type: 'string', description: '目标群 openid(可选; 不填=当前会话群/manageGroup)' },
|
|
1029
|
+
keywords: { type: 'array', items: { type: 'string' }, required: true, description: '放行关键词(任一命中即放行; 不区分大小写)' },
|
|
1030
|
+
rejectUnmatched: { type: 'boolean', description: 'true(默认)=未命中的一律拒绝; false=只放行命中的, 其余不动' },
|
|
1031
|
+
rejectReason: { type: 'string', description: '拒绝理由(默认"答非所问"; 不要写暗号/答案)' },
|
|
1032
|
+
dry_run: { type: 'boolean', description: 'true=只返回"将放行/拒绝哪些人", 不执行' },
|
|
1033
|
+
},
|
|
1034
|
+
output: {
|
|
1035
|
+
schema: {
|
|
1036
|
+
type: 'object', additionalProperties: false,
|
|
1037
|
+
properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
|
|
1038
|
+
},
|
|
1039
|
+
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1040
|
+
},
|
|
1041
|
+
async execute(args, exec) {
|
|
1042
|
+
const ga = groupAdminOf(exec);
|
|
1043
|
+
if (!ga)
|
|
1044
|
+
return { ok: false, msg: '群管理未开启(设置→QQ群管理)或非群会话' };
|
|
1045
|
+
const gid = String(args.gid || ga.gid || '');
|
|
1046
|
+
if (!gid)
|
|
1047
|
+
return { ok: false, msg: '当前不是群会话且未指定 gid, 无法确定目标群' };
|
|
1048
|
+
const kws = (Array.isArray(args.keywords) ? args.keywords : [])
|
|
1049
|
+
.map((k) => String(k).trim().toLowerCase())
|
|
1050
|
+
.filter(Boolean);
|
|
1051
|
+
if (kws.length === 0)
|
|
1052
|
+
return { ok: false, msg: 'keywords 至少给一个(放行关键词)' };
|
|
1053
|
+
const r = await ga.client.listJoinRequests(gid);
|
|
1054
|
+
if (!r.ok)
|
|
1055
|
+
return { ok: false, msg: r.err.human };
|
|
1056
|
+
const raw = r.data.list;
|
|
1057
|
+
if (raw.length === 0)
|
|
1058
|
+
return { ok: true, msg: '当前没有待审批的入群申请 ✓(无需自动审批)' };
|
|
1059
|
+
// 同一人重复申请只留最新(官方列表按时间倒序 → 首次出现即最新)
|
|
1060
|
+
const seen = new Set();
|
|
1061
|
+
const list = raw.filter((j) => {
|
|
1062
|
+
const k = String(j.member_openid ?? '');
|
|
1063
|
+
if (k === '' || seen.has(k))
|
|
1064
|
+
return false;
|
|
1065
|
+
seen.add(k);
|
|
1066
|
+
return true;
|
|
1067
|
+
});
|
|
1068
|
+
const hitList = [];
|
|
1069
|
+
const missList = [];
|
|
1070
|
+
for (const j of list) {
|
|
1071
|
+
const text = verifyHuman(j.verify_info).toLowerCase();
|
|
1072
|
+
(kws.some((k) => text.includes(k)) ? hitList : missList).push(j);
|
|
1073
|
+
}
|
|
1074
|
+
const rejectUnmatched = args.rejectUnmatched !== false;
|
|
1075
|
+
const nameOf = (j) => `${j.username ?? '?'}(…${String(j.member_openid).slice(-4)})`;
|
|
1076
|
+
if (args.dry_run === true) {
|
|
1077
|
+
return {
|
|
1078
|
+
ok: true,
|
|
1079
|
+
msg: `【dry-run, 未执行】待审 ${list.length} 条(去重后):\n` +
|
|
1080
|
+
`将放行 ${hitList.length} 人: ${hitList.slice(0, 5).map(nameOf).join('、') || '(无)'}\n` +
|
|
1081
|
+
`将拒绝 ${rejectUnmatched ? missList.length : 0} 人: ${rejectUnmatched ? (missList.slice(0, 5).map(nameOf).join('、') || '(无)') : '(已关闭)'}`,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
let okA = 0;
|
|
1085
|
+
let failA = 0;
|
|
1086
|
+
let okD = 0;
|
|
1087
|
+
let failD = 0;
|
|
1088
|
+
const errs = [];
|
|
1089
|
+
for (const j of hitList) {
|
|
1090
|
+
const rr = await ga.client.approveJoinRequest(gid, String(j.member_openid), 'approve', {
|
|
1091
|
+
join_request_id: String(j.join_request_id ?? ''),
|
|
1092
|
+
});
|
|
1093
|
+
if (rr.ok)
|
|
1094
|
+
okA++;
|
|
1095
|
+
else {
|
|
1096
|
+
failA++;
|
|
1097
|
+
errs.push(`${nameOf(j)} 放行失败: ${rr.err.human}`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (rejectUnmatched) {
|
|
1101
|
+
const reason = String(args.rejectReason ?? '').trim() || '答非所问';
|
|
1102
|
+
for (const j of missList) {
|
|
1103
|
+
const rr = await ga.client.approveJoinRequest(gid, String(j.member_openid), 'decline', {
|
|
1104
|
+
join_request_id: String(j.join_request_id ?? ''),
|
|
1105
|
+
reject_reason: reason,
|
|
1106
|
+
});
|
|
1107
|
+
if (rr.ok)
|
|
1108
|
+
okD++;
|
|
1109
|
+
else {
|
|
1110
|
+
failD++;
|
|
1111
|
+
errs.push(`${nameOf(j)} 拒绝失败: ${rr.err.human}`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return {
|
|
1116
|
+
ok: true,
|
|
1117
|
+
msg: [
|
|
1118
|
+
`✅ 自动审批完成: 放行 ${okA}/${hitList.length}${rejectUnmatched ? ` · 拒绝 ${okD}/${missList.length}` : ''}(待审共 ${list.length} 条)`,
|
|
1119
|
+
hitList.length ? `放行: ${hitList.slice(0, 5).map(nameOf).join('、')}${hitList.length > 5 ? ` …等 ${hitList.length} 人` : ''}` : '',
|
|
1120
|
+
rejectUnmatched && missList.length ? `拒绝: ${missList.slice(0, 5).map(nameOf).join('、')}${missList.length > 5 ? ` …等 ${missList.length} 人` : ''}` : '',
|
|
1121
|
+
errs.length ? `⚠️ 失败 ${failA + failD} 条: ${errs.slice(0, 3).join('; ')}` : '',
|
|
1122
|
+
].filter(Boolean).join('\n'),
|
|
1123
|
+
};
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
991
1126
|
const sessionListTool = defineTool({
|
|
992
1127
|
name: 'session_list',
|
|
993
|
-
description: '会话(读):
|
|
994
|
-
parameters: {
|
|
1128
|
+
description: '会话(读): 列出全部活跃会话(范围/peer/群名/活跃时间), 用于跨会话寻址。**默认 peer/sender 只给尾号**(省 token); 需要完整 openid 时传 full=true。',
|
|
1129
|
+
parameters: {
|
|
1130
|
+
full: { type: 'boolean', description: 'true=给完整 openid; 默认 false(只给尾号, 寻址用群名/备注即可)' },
|
|
1131
|
+
},
|
|
995
1132
|
output: {
|
|
996
1133
|
schema: {
|
|
997
1134
|
type: 'object', additionalProperties: false,
|
|
@@ -999,8 +1136,8 @@ export async function apply(ctx) {
|
|
|
999
1136
|
},
|
|
1000
1137
|
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1001
1138
|
},
|
|
1002
|
-
async execute(
|
|
1003
|
-
const
|
|
1139
|
+
async execute(args, exec) {
|
|
1140
|
+
const full = args?.full === true;
|
|
1004
1141
|
const lines = [];
|
|
1005
1142
|
// 遍历全部已注册实例(module 级注册表, 重启后仍可枚举; 不依赖 channelBridges)
|
|
1006
1143
|
const managers = managersOf();
|
|
@@ -1016,28 +1153,398 @@ export async function apply(ctx) {
|
|
|
1016
1153
|
for (const manager of managers) {
|
|
1017
1154
|
const list = manager.listSessions();
|
|
1018
1155
|
const ns = manager.settingsNs;
|
|
1156
|
+
// 群名映射(注册表 {cwd}/.qqbot/groups.json: gid → {name}) —— 让 AI 能用"群名"寻址, 不必抄 openid
|
|
1157
|
+
// 群名取自**数据根**注册表({dataRoot}/.qqbot/groups.json, 本机=cwd\dshqqbot\.qqbot) —— 2026-09-12 修正:
|
|
1158
|
+
// 以前用 manager.cwd(=**工作目录** <工作目录>) 读, 拿到的是旧位置那份, 群名过时
|
|
1159
|
+
// (「群C」显示成「AI(旧备注)」)、新群(群B)整个缺席。
|
|
1160
|
+
let reg = {};
|
|
1161
|
+
try {
|
|
1162
|
+
reg = JSON.parse(readFileSync(groupRegistryPath(manager.dataRoot), 'utf8'));
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
reg = {};
|
|
1166
|
+
}
|
|
1019
1167
|
for (const s of list) {
|
|
1020
1168
|
n++;
|
|
1021
|
-
|
|
1169
|
+
const label = s.scope === 'group' && reg[s.peerId]?.name ? ` "${reg[s.peerId].name}"` : '';
|
|
1170
|
+
// 2026-09-12 token 瘦身(主人定): **默认只给 peer/sender 尾号** —— 完整 32 位 openid 每行 20+ token,
|
|
1171
|
+
// 而寻址用"群名/备注"就够(broadcast_send / target_group 都支持按名解析); 要完整 id 传 full=true。
|
|
1172
|
+
const peerShown = full ? s.peerId : '…' + String(s.peerId).slice(-4);
|
|
1173
|
+
const senderShown = full ? s.senderId : '…' + String(s.senderId).slice(-4);
|
|
1174
|
+
lines.push(`${n}. [${s.scope}]${ns !== 'im-qqbot' ? `(${ns})` : ''} peer=${peerShown}${label} sender=${senderShown} id=${s.sessionId}${s.agentPreset ? ` (${s.agentPreset})` : ''} 活跃=${new Date(s.lastActivity).toLocaleTimeString()}`);
|
|
1022
1175
|
}
|
|
1023
1176
|
// 潜在会话: 群注册表里的群可能还没 getOrCreate(无活跃记录), 但 sessionId 可确定性算出
|
|
1024
1177
|
try {
|
|
1025
|
-
const raw = readFileSync(groupRegistryPath(manager.cwd), 'utf8');
|
|
1026
|
-
const reg = JSON.parse(raw);
|
|
1027
1178
|
const ids = new Set(list.map((s) => s.sessionId));
|
|
1028
1179
|
for (const gid of Object.keys(reg ?? {})) {
|
|
1029
1180
|
const sid = manager.sessionIdFor('group', gid);
|
|
1030
1181
|
if (!ids.has(sid))
|
|
1031
|
-
lines.push(`[潜在群] ${reg[gid]?.name ?? ''}
|
|
1182
|
+
lines.push(`[潜在群] ${reg[gid]?.name ?? ''} peer=${gid} id=${sid} 活跃=未创建`);
|
|
1032
1183
|
}
|
|
1033
1184
|
}
|
|
1034
|
-
catch { /*
|
|
1185
|
+
catch { /* 注册表异常则跳过 */ }
|
|
1035
1186
|
}
|
|
1036
1187
|
if (lines.length === 0)
|
|
1037
1188
|
return { ok: true, msg: '当前无活跃会话, 也无已注册群' };
|
|
1038
1189
|
return { ok: true, msg: `会话 ${lines.length} 个:\n${lines.join('\n')}` };
|
|
1039
1190
|
},
|
|
1040
1191
|
});
|
|
1192
|
+
/**
|
|
1193
|
+
* 群发目标解析(2026-09-12): 完整 openid 直接用; 否则按**群名/备注**模糊匹配
|
|
1194
|
+
* (注册表 {cwd}/.qqbot/groups.json + 各实例活跃会话)。命中 0 个或多个 → 只回候选清单,
|
|
1195
|
+
* **绝不猜着发**(这是"默认不 dry-run"还能安全的原因)。
|
|
1196
|
+
*/
|
|
1197
|
+
function resolveBroadcastTargets(rawTargets, root) {
|
|
1198
|
+
const isOpenid = (s) => /^[A-Za-z0-9_-]{20,40}$/.test(s);
|
|
1199
|
+
// 合并多份 groups.json:同一目标**按 lastAt 最新者胜**。
|
|
1200
|
+
// ⚠️ 2026-09-12 实测踩到:旧位置的注册表(`{cwd}/.qqbot/groups.json`)还在、名字是过时的 ——
|
|
1201
|
+
// 同一个群在旧文件里叫「AI(旧备注)」,在 dataRoot 那份里才叫「群C」;
|
|
1202
|
+
// 而「群B」只存在于 dataRoot 那份里,只读旧文件就完全查不到。
|
|
1203
|
+
const known = new Map();
|
|
1204
|
+
const add = (scope, peerId, name, lastAt = 0) => {
|
|
1205
|
+
const k = `${scope}:${peerId}`;
|
|
1206
|
+
const cur = known.get(k);
|
|
1207
|
+
if (!cur) {
|
|
1208
|
+
known.set(k, { scope, peerId, name, lastAt });
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (lastAt > cur.lastAt) {
|
|
1212
|
+
cur.lastAt = lastAt;
|
|
1213
|
+
if (name)
|
|
1214
|
+
cur.name = name;
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (!cur.name && name)
|
|
1218
|
+
cur.name = name;
|
|
1219
|
+
};
|
|
1220
|
+
const readReg = (dir) => {
|
|
1221
|
+
try {
|
|
1222
|
+
const reg = JSON.parse(readFileSync(groupRegistryPath(dir), 'utf8'));
|
|
1223
|
+
for (const [gid, v] of Object.entries(reg ?? {}))
|
|
1224
|
+
add('group', gid, String(v?.name ?? ''), Number(v?.lastAt ?? 0));
|
|
1225
|
+
}
|
|
1226
|
+
catch { /* 该目录没有注册表则跳过 */ }
|
|
1227
|
+
};
|
|
1228
|
+
const managers = managersOf();
|
|
1229
|
+
const dirs = new Set();
|
|
1230
|
+
// 只认**数据根**:manager.dataRoot = dataRootOf(config) = config.dataRoot(=cwd\dshqqbot) ——
|
|
1231
|
+
// 数据目录早就定好了, 不该再去 cwd 捞旧注册表(2026-09-12 主人指正: 就是 cwd\dshqqbot, 别绕)。
|
|
1232
|
+
for (const m of managers)
|
|
1233
|
+
dirs.add(m.dataRoot);
|
|
1234
|
+
dirs.add(root);
|
|
1235
|
+
for (const d of dirs)
|
|
1236
|
+
readReg(d);
|
|
1237
|
+
// 🗂 自定义分组(dock「📇 群组管理 → 🗂 分组」; 2026-09-12 起由浏览器搬到 host) → **与主人共用同一份**:
|
|
1238
|
+
// 主人在面板上点几下分的组, AI 直接写分组名就能群发。结构: [{ id, name, members:['group:xxx'|'c2c:yyy'] }]
|
|
1239
|
+
const targetGroups = new Map();
|
|
1240
|
+
for (const d of dirs) {
|
|
1241
|
+
try {
|
|
1242
|
+
const o = JSON.parse(readFileSync(join(d, '.qqbot', 'target-groups.json'), 'utf8'));
|
|
1243
|
+
for (const g of (Array.isArray(o?.groups) ? o.groups : [])) {
|
|
1244
|
+
if (g?.name && Array.isArray(g.members) && g.members.length > 0)
|
|
1245
|
+
targetGroups.set(String(g.name), g.members.map(String));
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
catch { /* 该数据根没有分组文件则跳过 */ }
|
|
1249
|
+
}
|
|
1250
|
+
for (const m of managers) {
|
|
1251
|
+
try {
|
|
1252
|
+
for (const s of m.listSessions())
|
|
1253
|
+
add(s.scope, s.peerId, '');
|
|
1254
|
+
}
|
|
1255
|
+
catch { /* ignore */ }
|
|
1256
|
+
}
|
|
1257
|
+
const all = [...known.values()];
|
|
1258
|
+
const targets = [];
|
|
1259
|
+
for (const raw of rawTargets) {
|
|
1260
|
+
// ① 分组名(优先于群名): "群友" 或 "分组:群友" → 展开成组内全部目标(与 dock 同一份分组数据)
|
|
1261
|
+
const gname = raw.replace(/^分组[::]\s*/u, '');
|
|
1262
|
+
const members = targetGroups.get(raw) ?? targetGroups.get(gname);
|
|
1263
|
+
if (members) {
|
|
1264
|
+
for (const m of members) {
|
|
1265
|
+
const mm = /^(group|c2c):(.+)$/i.exec(m);
|
|
1266
|
+
if (!mm)
|
|
1267
|
+
continue;
|
|
1268
|
+
const scope = mm[1].toLowerCase() === 'c2c' ? 'c2c' : 'group';
|
|
1269
|
+
const peerId = mm[2].trim();
|
|
1270
|
+
const hit = all.find((x) => x.scope === scope && x.peerId === peerId);
|
|
1271
|
+
targets.push({ scope, peerId, ...(hit?.name ? { name: hit.name } : {}) });
|
|
1272
|
+
}
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
const prefixed = /^(group|c2c):(.+)$/i.exec(raw);
|
|
1276
|
+
if (prefixed) {
|
|
1277
|
+
const scope = prefixed[1].toLowerCase() === 'c2c' ? 'c2c' : 'group';
|
|
1278
|
+
const peerId = prefixed[2].trim();
|
|
1279
|
+
const hit = all.find((x) => x.scope === scope && x.peerId === peerId);
|
|
1280
|
+
targets.push({ scope, peerId, ...(hit?.name ? { name: hit.name } : {}) });
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
if (isOpenid(raw)) {
|
|
1284
|
+
const hit = all.find((x) => x.peerId === raw);
|
|
1285
|
+
targets.push({ scope: hit?.scope ?? 'group', peerId: raw, ...(hit?.name ? { name: hit.name } : {}) });
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
const kw = raw.toLowerCase();
|
|
1289
|
+
const hits = [...new Map(all.filter((x) => x.name && x.name.toLowerCase().includes(kw)).map((h) => [h.peerId, h])).values()];
|
|
1290
|
+
if (hits.length === 1) {
|
|
1291
|
+
const h = hits[0];
|
|
1292
|
+
targets.push({ scope: h.scope, peerId: h.peerId, ...(h.name ? { name: h.name } : {}) });
|
|
1293
|
+
}
|
|
1294
|
+
else if (hits.length === 0) {
|
|
1295
|
+
const list = all.filter((x) => x.name).map((x) => `· ${x.name} — ${x.peerId}`).join('\n');
|
|
1296
|
+
return { targets: [], error: `没找到叫「${raw}」的群/私聊。已知目标:\n${list || '(暂无带备注的目标, 请用完整 openid)'}\n\n请用上面的名字或完整 openid 再调一次。` };
|
|
1297
|
+
}
|
|
1298
|
+
else {
|
|
1299
|
+
const list = hits.map((x) => `· ${x.name} — ${x.peerId}`).join('\n');
|
|
1300
|
+
return { targets: [], error: `「${raw}」匹配到多个目标, 请指定一个:\n${list}` };
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return { targets: [...new Map(targets.map((t) => [`${t.scope}:${t.peerId}`, t])).values()] };
|
|
1304
|
+
}
|
|
1305
|
+
/** 群发任务 → 人话摘要(逐目标状态 + message_id + 失败原因 + 撤回提示) */
|
|
1306
|
+
function fmtBroadcastTask(t, fallbackId) {
|
|
1307
|
+
if (!t)
|
|
1308
|
+
return `任务不存在(可能已被清理); task_id=${fallbackId ?? '(未给)'}`;
|
|
1309
|
+
// 2026-09-12 token 瘦身: 逐目标明细最多列 5 个(超出只报数量), 免得一次群发 20 个目标把结果灌爆上下文
|
|
1310
|
+
const shownTargets = t.targets.slice(0, 5);
|
|
1311
|
+
const rows = shownTargets.map((tg) => {
|
|
1312
|
+
const r = t.results[tg.peerId];
|
|
1313
|
+
const who = tg.name ? `${tg.name}(${tg.peerId})` : tg.peerId;
|
|
1314
|
+
if (!r)
|
|
1315
|
+
return `· ${who} ⏳ 待发`;
|
|
1316
|
+
if (r.ok)
|
|
1317
|
+
return `· ${who} ✅ message_id=${r.message_id ?? '(无)'}`;
|
|
1318
|
+
return `· ${who} ❌ ${r.err ?? '失败'}(已重试 ${t.retries[tg.peerId] ?? 0} 次)`;
|
|
1319
|
+
});
|
|
1320
|
+
if (t.targets.length > shownTargets.length)
|
|
1321
|
+
rows.push(`…还有 ${t.targets.length - shownTargets.length} 个目标(省略)`);
|
|
1322
|
+
const okN = t.targets.filter((tg) => t.results[tg.peerId]?.ok).length;
|
|
1323
|
+
const done = t.state === 'done' || t.state === 'partial_failed' || t.state === 'cancelled';
|
|
1324
|
+
const head = done
|
|
1325
|
+
? `群发${t.state === 'done' ? '完成' : t.state === 'cancelled' ? '已取消' : '部分失败'}: ${okN}/${t.targets.length} 成功 task_id=${t.task_id}`
|
|
1326
|
+
: `群发进行中(${t.state}): ${okN}/${t.targets.length} 已成功 task_id=${t.task_id} —— 后台队列会继续推进, 稍后用 action="status" 查`;
|
|
1327
|
+
return [
|
|
1328
|
+
head,
|
|
1329
|
+
...rows,
|
|
1330
|
+
okN > 0 ? `(2 分钟内可撤回:action="recall", task_id="${t.task_id}", targets=[要撤的目标])` : '',
|
|
1331
|
+
].filter(Boolean).join('\n');
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* 群发工具(2026-09-12 主人要求): 把插件现成的**广播队列**(features/broadcast.ts)接给 agent。
|
|
1335
|
+
* 设计(主人定): **默认直接发**, 不做 dry-run 预览 —— 主人当场指挥、自己就是发起人, 多一轮确认纯属烧 token;
|
|
1336
|
+
* 只有"按群名解析"命中 0 个/多个候选时才回一轮(那是必要信息, 不猜着发);
|
|
1337
|
+
* 护栏 = 工具描述写死"仅主人明确要求时调用" + **2 分钟撤回窗口**(返回 task_id 与 message_id)。
|
|
1338
|
+
* 白拿的队列能力: 串行逐目标、失败指数退避重试 3 次、任务落盘 {dataRoot}/.qqbot/broadcast-tasks.json、与 dock 面板同一份任务。
|
|
1339
|
+
* ⚠️ 不支持文件/图片群发(QQ 上传接口按群隔离 + 占主动消息配额), 资源请在正文放链接。
|
|
1340
|
+
*/
|
|
1341
|
+
const broadcastSendTool = defineTool({
|
|
1342
|
+
name: 'broadcast_send',
|
|
1343
|
+
description: '群发(写,高影响,**仅主人明确要求时调用**): 同一段内容一次发到多个 QQ 群/私聊, 走插件广播队列(串行+失败重试, 与 dock「📤 群发 · 广播」同一份任务, 2 分钟内可逐目标撤回)。targets 写**分组名/群名或备注**或完整 openid; 名字有歧义时只列候选让你确认, 不猜着发。默认直接发送(dry_run=true 才预览)。不支持发文件/图片 —— 资源把链接写进正文。' +
|
|
1344
|
+
'\n最小示例(照抄改值):\n' +
|
|
1345
|
+
'· 发分组 {targets:["群友"], text:"早上好呀各位~"}\n' +
|
|
1346
|
+
'· 发指定群 {targets:["群A","群B"], text:"公告:…"}\n' +
|
|
1347
|
+
'· 撤回/查进度 {action:"recall", task_id:"bc-xxx", targets:["群友"]} / {action:"status", task_id:"bc-xxx"}',
|
|
1348
|
+
parameters: {
|
|
1349
|
+
targets: { type: 'array', items: { type: 'string' }, required: true, description: '目标: 分组名(如"群友") / 群名或群备注(模糊匹配) / 完整 openid(32位) / "group:xxx" / "c2c:xxx"' },
|
|
1350
|
+
text: { type: 'string', description: '群发内容(text ≤2000 字 / markdown ≤8000 字); 链接直接写在里面' },
|
|
1351
|
+
type: { type: 'string', enum: ['text', 'markdown'], description: '默认 text(纯文本最稳); markdown 需模板权限(已开通)' },
|
|
1352
|
+
dry_run: { type: 'boolean', description: 'true=只预览"发给谁+内容"; 默认 false 直接发' },
|
|
1353
|
+
action: { type: 'string', enum: ['send', 'recall', 'status'], description: '默认 send; recall=撤回(需 task_id+targets, 2 分钟内); status=查进度' },
|
|
1354
|
+
task_id: { type: 'string', description: 'recall/status 用的任务 id(从发送结果里拿)' },
|
|
1355
|
+
},
|
|
1356
|
+
output: {
|
|
1357
|
+
schema: {
|
|
1358
|
+
type: 'object', additionalProperties: false,
|
|
1359
|
+
properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
|
|
1360
|
+
},
|
|
1361
|
+
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1362
|
+
},
|
|
1363
|
+
async execute(args, exec) {
|
|
1364
|
+
const a = args;
|
|
1365
|
+
const ga = groupAdminOf(exec);
|
|
1366
|
+
const ch = channelOf(exec);
|
|
1367
|
+
// ⚠️ 数据根 ≠ 工作目录: manager.dataRoot = dataRootOf(config)(本机=<cwd>\AI\dshqqbot),
|
|
1368
|
+
// 而 manager.cwd = config.cwd(本机=<cwd>\AI)。2026-09-12 主人实测发现我一开始写成了 cwd,
|
|
1369
|
+
// 结果广播任务落到 AI\.qqbot\(旧位置), dock 在 dshqqbot\.qqbot\ 里根本看不到。
|
|
1370
|
+
const root = ch?.manager?.dataRoot ?? managersOf()[0]?.dataRoot ?? managersOf()[0]?.cwd;
|
|
1371
|
+
if (!root)
|
|
1372
|
+
return { ok: false, msg: '找不到数据根(无已注册实例)' };
|
|
1373
|
+
if (!ga)
|
|
1374
|
+
return { ok: false, msg: '群管理未开启(设置页→QQ群管理): 群发要走官方接口, 需要机器人凭据' };
|
|
1375
|
+
const action = String(a.action ?? 'send');
|
|
1376
|
+
const rawTargets = Array.isArray(a.targets) ? a.targets.map((t) => String(t).trim()).filter(Boolean) : [];
|
|
1377
|
+
if (rawTargets.length === 0)
|
|
1378
|
+
return { ok: false, msg: 'targets 至少给一个(群名或 openid)' };
|
|
1379
|
+
if (action === 'status') {
|
|
1380
|
+
const t = a.task_id ? broadcastQueue.getTask(root, String(a.task_id)) : undefined;
|
|
1381
|
+
if (!t)
|
|
1382
|
+
return { ok: false, msg: '没找到该任务(或没传 task_id)' };
|
|
1383
|
+
return { ok: true, msg: fmtBroadcastTask(t) };
|
|
1384
|
+
}
|
|
1385
|
+
const resolved = resolveBroadcastTargets(rawTargets, root);
|
|
1386
|
+
if (resolved.error)
|
|
1387
|
+
return { ok: false, msg: resolved.error };
|
|
1388
|
+
if (action === 'recall') {
|
|
1389
|
+
const taskId = String(a.task_id ?? '');
|
|
1390
|
+
if (!taskId)
|
|
1391
|
+
return { ok: false, msg: 'recall 需要 task_id(从发送结果里拿)' };
|
|
1392
|
+
const out = [];
|
|
1393
|
+
for (const tg of resolved.targets) {
|
|
1394
|
+
const r = await broadcastQueue.recallTaskMessage(root, taskId, tg.peerId, ga.client);
|
|
1395
|
+
out.push(`${r.ok ? '✅ 已撤回' : '❌ 撤回失败'} ${tg.name ?? ''}(${tg.peerId})${r.err ? ' — ' + r.err : ''}`);
|
|
1396
|
+
}
|
|
1397
|
+
return { ok: true, msg: out.join('\n') };
|
|
1398
|
+
}
|
|
1399
|
+
// ── send ──
|
|
1400
|
+
const text = String(a.text ?? '');
|
|
1401
|
+
const type = a.type === 'markdown' ? 'markdown' : 'text';
|
|
1402
|
+
const limit = type === 'markdown' ? 8000 : 2000;
|
|
1403
|
+
if (!text.trim())
|
|
1404
|
+
return { ok: false, msg: 'text 必填(要群发的内容)' };
|
|
1405
|
+
if (text.length > limit)
|
|
1406
|
+
return { ok: false, msg: `内容过长: ${text.length} 字(${type} 上限 ${limit} 字)` };
|
|
1407
|
+
if (resolved.targets.length > 20)
|
|
1408
|
+
return { ok: false, msg: `目标太多(${resolved.targets.length} 个), 单次最多 20 个` };
|
|
1409
|
+
if (a.dry_run === true) {
|
|
1410
|
+
return {
|
|
1411
|
+
ok: true,
|
|
1412
|
+
msg: `【dry-run, 未发送】将发到 ${resolved.targets.length} 个目标:\n` +
|
|
1413
|
+
resolved.targets.slice(0, 5).map((t) => `· ${t.name ?? '(无备注)'}`).join('\n') +
|
|
1414
|
+
(resolved.targets.length > 5 ? `\n…还有 ${resolved.targets.length - 5} 个(省略)` : '') +
|
|
1415
|
+
`\n\n内容(${type}, ${text.length} 字):\n${text.slice(0, 200)}${text.length > 200 ? '…' : ''}`,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
const task = broadcastQueue.createTask(root, { type, content: text, targets: resolved.targets, created_by: 'agent' });
|
|
1419
|
+
broadcastQueue.confirmTask(root, task.task_id);
|
|
1420
|
+
// 同步推进: 每次一个目标, 最多等 ~20 秒; 目标多/网络慢时交给 host 的定时器继续, 用 action=status 查。
|
|
1421
|
+
// 目标之间隔 1.5s —— 群发是**主动消息**, 官方频控 Bot 维度 60/qpm(认证)/30/qpm(未认证),
|
|
1422
|
+
// 留余量给被动回复与别的推送(原来无间隔, 目标一多会瞬时打满额度)。
|
|
1423
|
+
const STEP_MS = 1500;
|
|
1424
|
+
const deadline = Date.now() + 20_000;
|
|
1425
|
+
let cur = broadcastQueue.getTask(root, task.task_id);
|
|
1426
|
+
while (cur && (cur.state === 'queued' || cur.state === 'sending') && Date.now() < deadline) {
|
|
1427
|
+
cur = await broadcastQueue.advanceTask(root, task.task_id, ga.client);
|
|
1428
|
+
if (cur && (cur.state === 'queued' || cur.state === 'sending') && Date.now() < deadline) {
|
|
1429
|
+
await new Promise((resolve) => setTimeout(resolve, STEP_MS));
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return { ok: true, msg: fmtBroadcastTask(cur, task.task_id) };
|
|
1433
|
+
},
|
|
1434
|
+
});
|
|
1435
|
+
/**
|
|
1436
|
+
* 目标分组管理(2026-09-12 主人要求"AI 也要能编辑分组"):
|
|
1437
|
+
* 读写的就是 dock「📇 群组管理 → 🗂 分组」那份 host 数据({dataRoot}/.qqbot/target-groups.json),
|
|
1438
|
+
* 所以 AI 建/改的分组主人刷新面板就能看到; 反过来主人分的组, AI 直接拿来群发(targets 写分组名)。
|
|
1439
|
+
*/
|
|
1440
|
+
const targetGroupTool = defineTool({
|
|
1441
|
+
name: 'target_group',
|
|
1442
|
+
description: '目标分组管理(与 dock「📇 群组管理 → 🗂 分组」共用同一份数据, 改完主人刷新面板就能看到): 查看/新建/改名/删除分组, 或把目标加进/移出分组。建好后 broadcast_send 的 targets 直接写**分组名**即可群发。改动类动作仅主人明确要求时调用; 重名/找不到会明确报错。' +
|
|
1443
|
+
'\n最小示例: {action:"list"} · {action:"create", name:"开发群"} · {action:"add", name:"群友", targets:["群A"]} · {action:"remove", name:"群友", targets:["群B"]}',
|
|
1444
|
+
parameters: {
|
|
1445
|
+
action: { type: 'string', enum: ['list', 'create', 'rename', 'delete', 'add', 'remove'], required: true, description: 'list=查看全部分组 / create=新建 / rename=改名 / delete=删除 / add=把 targets 加进分组 / remove=把 targets 移出分组' },
|
|
1446
|
+
name: { type: 'string', description: '分组名(除 list 外必填)' },
|
|
1447
|
+
new_name: { type: 'string', description: 'rename 时的新分组名' },
|
|
1448
|
+
targets: { type: 'array', items: { type: 'string' }, description: 'add/remove 的目标(群名/群备注/完整 openid/分组名 均可, 写法同 broadcast_send)' },
|
|
1449
|
+
},
|
|
1450
|
+
output: {
|
|
1451
|
+
schema: {
|
|
1452
|
+
type: 'object', additionalProperties: false,
|
|
1453
|
+
properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
|
|
1454
|
+
},
|
|
1455
|
+
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1456
|
+
},
|
|
1457
|
+
async execute(args, exec) {
|
|
1458
|
+
const a = args;
|
|
1459
|
+
const ch = channelOf(exec);
|
|
1460
|
+
const root = ch?.manager?.dataRoot ?? managersOf()[0]?.dataRoot ?? managersOf()[0]?.cwd;
|
|
1461
|
+
if (!root)
|
|
1462
|
+
return { ok: false, msg: '找不到数据根(无已注册实例)' };
|
|
1463
|
+
const file = join(root, '.qqbot', 'target-groups.json');
|
|
1464
|
+
const read = () => {
|
|
1465
|
+
try {
|
|
1466
|
+
const o = JSON.parse(readFileSync(file, 'utf8'));
|
|
1467
|
+
return Array.isArray(o?.groups) ? o.groups : [];
|
|
1468
|
+
}
|
|
1469
|
+
catch {
|
|
1470
|
+
return [];
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
const save = (groups) => {
|
|
1474
|
+
try {
|
|
1475
|
+
mkdirSync(join(root, '.qqbot'), { recursive: true });
|
|
1476
|
+
writeFileSync(file, JSON.stringify({ groups }, null, 1), 'utf8');
|
|
1477
|
+
}
|
|
1478
|
+
catch { /* 落盘失败: 内存结果照常返回, 主人可重试 */ }
|
|
1479
|
+
};
|
|
1480
|
+
const fmt = (g) => {
|
|
1481
|
+
const ms = g.members ?? [];
|
|
1482
|
+
const tail = ms.slice(0, 6).map((m) => '…' + String(m).replace(/^[a-z]+:/i, '').slice(-4)).join(', ');
|
|
1483
|
+
return `· ${g.name}(${ms.length})${ms.length ? ' — ' + tail + (ms.length > 6 ? ' …' : '') : ''}`;
|
|
1484
|
+
};
|
|
1485
|
+
const action = String(a.action ?? 'list');
|
|
1486
|
+
const name = String(a.name ?? '').trim();
|
|
1487
|
+
let groups = read();
|
|
1488
|
+
if (action === 'list') {
|
|
1489
|
+
if (groups.length === 0)
|
|
1490
|
+
return { ok: true, msg: '当前没有任何分组(dock「📇 群组管理 → 🗂 分组」可新建, 也可以让我建)' };
|
|
1491
|
+
return { ok: true, msg: `分组 ${groups.length} 个:\n${groups.map(fmt).join('\n')}` };
|
|
1492
|
+
}
|
|
1493
|
+
if (name === '')
|
|
1494
|
+
return { ok: false, msg: `${action} 需要 name(分组名)` };
|
|
1495
|
+
const idx = groups.findIndex((g) => String(g.name ?? '') === name);
|
|
1496
|
+
if (action === 'create') {
|
|
1497
|
+
if (idx >= 0)
|
|
1498
|
+
return { ok: false, msg: `分组「${name}」已经存在了` };
|
|
1499
|
+
groups.push({ id: 'tg-' + Date.now().toString(36), name, members: [] });
|
|
1500
|
+
save(groups);
|
|
1501
|
+
return { ok: true, msg: `✅ 已新建分组「${name}」(空组) —— 可以用 action=add 加目标, 或让主人在面板勾选加入` };
|
|
1502
|
+
}
|
|
1503
|
+
if (idx < 0)
|
|
1504
|
+
return { ok: false, msg: `找不到分组「${name}」。现有: ${groups.map((g) => g.name).join('、') || '(无)'}` };
|
|
1505
|
+
if (action === 'rename') {
|
|
1506
|
+
const nn = String(a.new_name ?? '').trim();
|
|
1507
|
+
if (nn === '')
|
|
1508
|
+
return { ok: false, msg: 'rename 需要 new_name' };
|
|
1509
|
+
if (groups.some((g) => String(g.name ?? '') === nn))
|
|
1510
|
+
return { ok: false, msg: `已经有个分组叫「${nn}」了` };
|
|
1511
|
+
groups[idx].name = nn;
|
|
1512
|
+
save(groups);
|
|
1513
|
+
return { ok: true, msg: `✅ 分组「${name}」已改名为「${nn}」` };
|
|
1514
|
+
}
|
|
1515
|
+
if (action === 'delete') {
|
|
1516
|
+
const n = (groups[idx].members ?? []).length;
|
|
1517
|
+
groups = groups.filter((_, i) => i !== idx);
|
|
1518
|
+
save(groups);
|
|
1519
|
+
return { ok: true, msg: `✅ 已删除分组「${name}」(${n} 个成员; 只是取消分组, 不影响这些群/人本身)` };
|
|
1520
|
+
}
|
|
1521
|
+
if (action === 'add' || action === 'remove') {
|
|
1522
|
+
const rawTargets = Array.isArray(a.targets) ? a.targets.map((t) => String(t).trim()).filter(Boolean) : [];
|
|
1523
|
+
if (rawTargets.length === 0)
|
|
1524
|
+
return { ok: false, msg: `${action} 需要 targets(至少一个)` };
|
|
1525
|
+
const resolved = resolveBroadcastTargets(rawTargets, root);
|
|
1526
|
+
if (resolved.error)
|
|
1527
|
+
return { ok: false, msg: resolved.error };
|
|
1528
|
+
const cur = new Set(groups[idx].members ?? []);
|
|
1529
|
+
const before = cur.size;
|
|
1530
|
+
for (const t of resolved.targets) {
|
|
1531
|
+
const k = `${t.scope}:${t.peerId}`;
|
|
1532
|
+
if (action === 'add')
|
|
1533
|
+
cur.add(k);
|
|
1534
|
+
else
|
|
1535
|
+
cur.delete(k);
|
|
1536
|
+
}
|
|
1537
|
+
groups[idx].members = [...cur];
|
|
1538
|
+
save(groups);
|
|
1539
|
+
const delta = action === 'add' ? cur.size - before : before - cur.size;
|
|
1540
|
+
return {
|
|
1541
|
+
ok: true,
|
|
1542
|
+
msg: `✅ 分组「${name}」${action === 'add' ? '加入' : '移出'} ${delta} 个目标, 现共 ${cur.size} 个${delta < resolved.targets.length ? '(重复的已跳过)' : ''}\n${resolved.targets.slice(0, 5).map((t) => '· ' + (t.name || t.peerId)).join('\n')}${resolved.targets.length > 5 ? `\n…还有 ${resolved.targets.length - 5} 个(省略)` : ''}`,
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
return { ok: false, msg: `未知 action: ${action}` };
|
|
1546
|
+
},
|
|
1547
|
+
});
|
|
1041
1548
|
/** 发到目标 QQ 群/私聊: 若给了 media 先发图(用目标 manager 归属的 sender), 再发带来源文本; 无 sender 则 groupAdmin 文本兜底 */
|
|
1042
1549
|
async function sendQQWithMedia(manager, scope, peer, body, media, exec) {
|
|
1043
1550
|
const ch = channelBridges.find((b) => b.manager === manager);
|
|
@@ -1089,11 +1596,16 @@ export async function apply(ctx) {
|
|
|
1089
1596
|
}
|
|
1090
1597
|
const sessionWakeTool = defineTool({
|
|
1091
1598
|
name: 'session_wake',
|
|
1092
|
-
description: '跨会话(
|
|
1599
|
+
description: '跨会话(写,谨慎,**仅主人明确要求时调用**): 向指定会话发一条消息。mode=wake(默认)唤醒对方 LLM; mode=append 只写上下文不唤醒(省 token 的喇叭模式)。同时经 QQ 通道把消息发到该会话绑定的群/私聊。目标用 session_id(见 session_list) 或 peer_id+scope。' +
|
|
1600
|
+
'\n最小示例(照抄改值):\n' +
|
|
1601
|
+
'· 唤醒单发 {session_id:"<uuid>", text:"帮我看下这个"}\n' +
|
|
1602
|
+
'· 跨群喇叭不唤醒 {peer_id:"<群openid>", scope:"group", text:"公告:…", mode:"append"}\n' +
|
|
1603
|
+
'· 批量(每项自带 text) {batch:[{peer_id:"<g1>",scope:"group",text:"A"},{peer_id:"<g2>",scope:"group",text:"B",media:"D:/pic.png"}]} ← 用 batch 时**不要再传顶层 text**',
|
|
1604
|
+
parameters: {
|
|
1093
1605
|
session_id: { type: 'string', description: '目标会话 id(完整 sessionId, 来自 session_list)' },
|
|
1094
1606
|
peer_id: { type: 'string', description: '或按 peer 寻址: 群 openid/私聊 openid(需带 scope)' },
|
|
1095
1607
|
scope: { type: 'string', enum: ['group', 'c2c'], description: 'peer_id 寻址时的范围(group=群 / c2c=私聊)' },
|
|
1096
|
-
text: { type: 'string',
|
|
1608
|
+
text: { type: 'string', description: '要发送的消息内容(单发必填; 用 batch 时**不要**传这个, 每项自带 text)' },
|
|
1097
1609
|
mode: { type: 'string', enum: ['wake', 'append'], description: 'wake=唤醒 LLM 开回合(默认); append=只追加上下文不唤醒(省 token, 喇叭模式)' },
|
|
1098
1610
|
send_qq: { type: 'boolean', description: '是否同时发到绑定的 QQ 群/私聊(给人看, 默认 true)。false=只处理 LLM 侧不走 QQ 通道' },
|
|
1099
1611
|
media: { type: 'string', description: '跨群发图: 图片本地路径或 http(s) URL, 随消息发到目标群(需 send_qq=true)' },
|
|
@@ -1145,7 +1657,7 @@ export async function apply(ctx) {
|
|
|
1145
1657
|
const sendOne = async (one) => {
|
|
1146
1658
|
const text = String(one.text || '');
|
|
1147
1659
|
if (!text)
|
|
1148
|
-
return '❌ 缺少 text';
|
|
1660
|
+
return '❌ 缺少 text —— 单发请传顶层 text; 用 batch 时**每项自带 text**(顶层 text 不必传, 传了也不算错)。最小示例见本工具描述';
|
|
1149
1661
|
const body = `【来自会话 ${from}】\n${text}${one.media ? `\n[附带图片: ${one.media}]` : ''}`;
|
|
1150
1662
|
const sid = String(one.session_id || '');
|
|
1151
1663
|
if (sid) {
|
|
@@ -1377,10 +1889,13 @@ export async function apply(ctx) {
|
|
|
1377
1889
|
{ name: 'outbound_mode', tool: outboundModeTool },
|
|
1378
1890
|
{ name: 'group_join_requests', tool: listJoinRequestsTool },
|
|
1379
1891
|
{ name: 'group_approve_join', tool: approveJoinTool },
|
|
1892
|
+
{ name: 'group_join_auto', tool: autoApproveJoinTool },
|
|
1380
1893
|
{ name: 'group_mute_state', tool: muteStateTool },
|
|
1381
1894
|
{ name: 'group_mute_member', tool: muteMemberTool },
|
|
1382
1895
|
{ name: 'session_list', tool: sessionListTool },
|
|
1383
1896
|
{ name: 'session_wake', tool: sessionWakeTool },
|
|
1897
|
+
{ name: 'broadcast_send', tool: broadcastSendTool },
|
|
1898
|
+
{ name: 'target_group', tool: targetGroupTool },
|
|
1384
1899
|
{ name: 'list_stickers', tool: listStickersTool },
|
|
1385
1900
|
{ name: 'sticker_tag', tool: tagStickerTool },
|
|
1386
1901
|
{ name: 'sticker_delete', tool: deleteStickerTool },
|