@zaofan/dsh-qqbot 1.2.0 → 1.4.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 +7 -1
- package/README_EN.md +4 -1
- package/client/qqbot-settings.js +4136 -4113
- package/dist/api/group-admin.d.ts +1 -1
- package/dist/api/group-admin.d.ts.map +1 -1
- package/dist/api/group-admin.js +20 -4
- package/dist/api/group-admin.js.map +1 -1
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +318 -36
- package/dist/channel-tools.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.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 +19 -5
- package/dist/features/group-join-request.js.map +1 -1
- package/dist/features/qq-approval.js +0 -1
- package/dist/features/qq-approval.js.map +1 -1
- package/dist/gateway/bootstrap.js +0 -1
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/debounce.d.ts +5 -7
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +64 -11
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +17 -1
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- 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 +5 -2
- package/dist/transport/inbound.js.map +1 -1
- package/dist/transport/inject-rules.d.ts +1 -1
- package/dist/transport/inject-rules.d.ts.map +1 -1
- package/dist/transport/inject-rules.js +2 -2
- package/dist/transport/inject-rules.js.map +1 -1
- package/package.json +1 -1
- package/settings-host.js +5 -2
package/dist/channel-tools.js
CHANGED
|
@@ -17,6 +17,7 @@ import { getScheduleStore } from './features/schedule-store.js';
|
|
|
17
17
|
import { switchOutboundMode } from './features/outbound-mode-switch.js';
|
|
18
18
|
import { loadExtensionTools } from './features/extension-store.js';
|
|
19
19
|
import { verifyHuman, groupRegistryPath } from './api/group-admin.js';
|
|
20
|
+
import { readGroupMembers } from './features/chat-ledger.js';
|
|
20
21
|
import * as broadcastQueue from './features/broadcast.js';
|
|
21
22
|
import { wakeSessionAgent, safeAppendUserMessage } from './features/group-hub.js';
|
|
22
23
|
import { managersOf, findManagerByPeer, findManagerBySessionId } from './features/session-registry.js';
|
|
@@ -322,11 +323,11 @@ export async function apply(ctx) {
|
|
|
322
323
|
});
|
|
323
324
|
const listStickersTool = defineTool({
|
|
324
325
|
name: 'list_stickers',
|
|
325
|
-
description: '(
|
|
326
|
+
description: '(聊天加分项,气氛对就大胆用!)按情绪关键词搜本地表情包库, 挑最贴切的一张用 send_media(kind=image, source=路径) 发出去(一次最多一张); 实在查不到贴切的才回纯文字。',
|
|
326
327
|
parameters: {
|
|
327
|
-
query: { type: 'string', description: '语义关键词(可空=最近收藏;
|
|
328
|
+
query: { type: 'string', description: '语义关键词(可空=最近收藏; 匹配标签与描述)' },
|
|
328
329
|
tag: { type: 'string', description: '标签过滤(可空)' },
|
|
329
|
-
limit: { type: 'integer', description: '
|
|
330
|
+
limit: { type: 'integer', description: '返回条数 1~20(默认 3)' },
|
|
330
331
|
},
|
|
331
332
|
output: {
|
|
332
333
|
schema: {
|
|
@@ -366,7 +367,8 @@ export async function apply(ctx) {
|
|
|
366
367
|
try {
|
|
367
368
|
const store = stickerStoreOf(exec);
|
|
368
369
|
const limitRaw = args.limit;
|
|
369
|
-
|
|
370
|
+
// 默认 3 条(2026-09-12 token 瘦身: 每条含描述+路径, 5 条≈白烧几十 token, 3 条足够挑图)
|
|
371
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, Math.floor(limitRaw ?? 3))) : 3;
|
|
370
372
|
const hits = store.search({
|
|
371
373
|
q: args.query ?? '',
|
|
372
374
|
tag: args.tag ?? '',
|
|
@@ -416,7 +418,7 @@ export async function apply(ctx) {
|
|
|
416
418
|
};
|
|
417
419
|
const tagStickerTool = defineTool({
|
|
418
420
|
name: 'sticker_tag',
|
|
419
|
-
description: '(
|
|
421
|
+
description: '(主动维护)看到值得留的图就收藏+打标(群友发的有梗/好图, 不管这轮发不发): tags=逗号分隔中文短标签(如 爆笑,元气), desc=一句"画面+情绪+适用场合"; 缺标签的图(见 sticker_untagged)也用它补。想省视觉额度可传 vision=false(只对已在库的图打标)。',
|
|
420
422
|
parameters: {
|
|
421
423
|
sticker: { type: 'string', required: true, description: '图片URL 或 本地绝对路径(也可传 list_stickers 返回的路径)' },
|
|
422
424
|
tags: { type: 'string', description: '逗号分隔中文标签,如 开心,元气,打招呼' },
|
|
@@ -805,7 +807,8 @@ export async function apply(ctx) {
|
|
|
805
807
|
// 切换走 outbound-mode-switch 注册表 → live 热生效 + settings 持久化 → dock/设置一致。
|
|
806
808
|
const outboundModeTool = defineTool({
|
|
807
809
|
name: 'outbound_mode',
|
|
808
|
-
description: '出站模式开关(
|
|
810
|
+
description: '出站模式开关(自己决定, 热更新不用重启): adaptive=适配主动(默认: 真人消息前5条带引用、连发自动转独立消息不被QQ吞); detail=详细主动(聊天同 adaptive, 额外把工具调用/工具结果也推到QQ —— 主人要看进度时用, 消息会变多, 用完切回); passive=被动(始终回最后一条, 连发约4~5条后被QQ吞); silent=完全不出站(照常思考但不发, 潜水观察)。nothink 那档只能主人在设置页配。' +
|
|
811
|
+
'\n怎么选: 正常聊天/被@→adaptive · 主人要看工具进度→detail · 想保持引用感→passive · 判断不该在群里说话→silent',
|
|
809
812
|
parameters: {
|
|
810
813
|
mode: { type: 'string', required: true, enum: ['adaptive', 'detail', 'passive', 'silent'], description: '目标模式: adaptive(默认推荐) / detail(详细主动: 连工具调用一起推) / passive / silent' },
|
|
811
814
|
reason: { type: 'string', required: true, description: '为什么切到这档(简短理由)' },
|
|
@@ -896,9 +899,12 @@ export async function apply(ctx) {
|
|
|
896
899
|
}
|
|
897
900
|
const listJoinRequestsTool = defineTool({
|
|
898
901
|
name: 'group_join_requests',
|
|
899
|
-
description: '群管理(读):
|
|
902
|
+
description: '群管理(读): 看待审批入群申请(**默认只给本页 5 条**, 用 page 翻页; 同一人重复申请只留最新)。传 gid 可查指定群, 不填=当前群/manageGroup。仅主人要求时调用; 需开启"QQ群管理"且机器人为群管理员。跨群审批请显式带 gid。' +
|
|
903
|
+
'\n示例: {gid:"<群openid>"} · {gid:"<群openid>", page:2} · {limit:20 看全}',
|
|
900
904
|
parameters: {
|
|
901
|
-
gid: { type: 'string', description: '目标群 openid(可选)。不填=当前会话群或 manageGroup
|
|
905
|
+
gid: { type: 'string', description: '目标群 openid(可选)。不填=当前会话群或 manageGroup' },
|
|
906
|
+
page: { type: 'integer', description: '第几页(默认 1)' },
|
|
907
|
+
limit: { type: 'integer', description: '本页条数 1~20(默认 5; 要一次看全可传 20)' },
|
|
902
908
|
},
|
|
903
909
|
output: {
|
|
904
910
|
schema: {
|
|
@@ -925,16 +931,34 @@ export async function apply(ctx) {
|
|
|
925
931
|
gname = gi.data.group_name;
|
|
926
932
|
}
|
|
927
933
|
catch { /* 群名失败不影响 */ }
|
|
928
|
-
const
|
|
929
|
-
if (
|
|
934
|
+
const raw = r.data.list;
|
|
935
|
+
if (raw.length === 0)
|
|
930
936
|
return { ok: true, msg: `当前没有待审批的入群申请 ✓(群 ${gname ? `「${gname}」` : ''}${gid})` };
|
|
931
|
-
|
|
932
|
-
|
|
937
|
+
// 去重(2026-09-12 主人要求): 同一人反复申请只留**最新**一条 —— 官方列表按时间倒序, 故首次出现即最新
|
|
938
|
+
const seen = new Set();
|
|
939
|
+
const list = raw.filter((j) => {
|
|
940
|
+
const key = String(j.member_openid ?? '');
|
|
941
|
+
if (key === '' || seen.has(key))
|
|
942
|
+
return false;
|
|
943
|
+
seen.add(key);
|
|
944
|
+
return true;
|
|
945
|
+
});
|
|
946
|
+
// 分页(2026-09-12 token 瘦身): 默认只回 5 条, 其余用 page 翻
|
|
947
|
+
const lim = Math.max(1, Math.min(20, Math.round(Number(args.limit)) || 5));
|
|
948
|
+
const totalPages = Math.max(1, Math.ceil(list.length / lim));
|
|
949
|
+
const page = Math.min(Math.max(1, Math.round(Number(args.page)) || 1), totalPages);
|
|
950
|
+
const start = (page - 1) * lim;
|
|
951
|
+
const slice = list.slice(start, start + lim);
|
|
952
|
+
const head = `入群申请 共 ${raw.length} 条(去重后 ${list.length} 条 · 第 ${page}/${totalPages} 页 · 每页 ${lim} 条) 群 ${gname ? `「${gname}」` : ''}${gid}:`;
|
|
953
|
+
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}` : ''}`);
|
|
954
|
+
const more = list.length > start + slice.length ? `\n(还有 ${list.length - start - slice.length} 条 → 再调本工具传 page=${page + 1})` : '';
|
|
955
|
+
return { ok: true, msg: `${head}\n${lines.join('\n')}${more}` };
|
|
933
956
|
},
|
|
934
957
|
});
|
|
935
958
|
const approveJoinTool = defineTool({
|
|
936
959
|
name: 'group_approve_join',
|
|
937
|
-
description: '群管理(写,危险):
|
|
960
|
+
description: '群管理(写,危险): 审批入群申请(approve 放行 / decline 拒绝可带理由), 支持批量 member_openids。仅主人明确要求时调用; 调用前先用 group_join_requests 核对申请人。⚠️跨群审批务必显式传 gid(不传=当前会话所在群, 会批错群)。' +
|
|
961
|
+
'\n示例: {gid:"<群openid>", member_openids:["<id1>","<id2>"], op:"approve", reason:"-"} · 拒绝: op:"decline", reason:"答非所问"',
|
|
938
962
|
parameters: {
|
|
939
963
|
gid: { type: 'string', description: '目标群 openid(可选)。不填=当前会话群或 manageGroup; 填了则审批指定群的申请' },
|
|
940
964
|
member_openids: { type: 'array', description: '批量审批: 申请人 member_openid 数组(来自 group_join_requests), 一次批多人' },
|
|
@@ -991,10 +1015,121 @@ export async function apply(ctx) {
|
|
|
991
1015
|
return { ok: failed === targets.length ? false : true, msg: `${targets.length - failed}/${targets.length} 成功:\n${results.join('\n')}` };
|
|
992
1016
|
},
|
|
993
1017
|
});
|
|
1018
|
+
/**
|
|
1019
|
+
* 入群申请「自动审批」(2026-09-12 主人要求): 给一组关键词 —— **验证消息命中的程序直接放行, 不命中的直接拒绝**。
|
|
1020
|
+
* ⚠️ 基于**官方待审列表**(`join_request_list`), **不是**本地 pending 流水 —— 后者含已处理/重复事件, 照着批会报错。
|
|
1021
|
+
* ⚠️ 拒绝不可逆: 默认直接执行(主人风格), 想先看结果传 `dry_run=true`。
|
|
1022
|
+
* 拒绝理由一律中性(默认"答非所问"), **绝不写暗号/答案/内部判据**。
|
|
1023
|
+
*/
|
|
1024
|
+
const autoApproveJoinTool = defineTool({
|
|
1025
|
+
name: 'group_join_auto',
|
|
1026
|
+
description: '群管理(写,危险,**仅主人明确要求时调用**): 按关键词批量审批入群申请 —— 验证消息**命中任一关键词的放行**, 其余直接拒绝。基于官方待审列表(不含本地历史流水)。拒绝不可逆; 想先看结果传 dry_run=true。' +
|
|
1027
|
+
'\n示例: {gid:"<群openid>", keywords:["早饭"], rejectReason:"答非所问"} · 只放行不拒: {..., rejectUnmatched:false} · 预览: {..., dry_run:true}',
|
|
1028
|
+
parameters: {
|
|
1029
|
+
gid: { type: 'string', description: '目标群 openid(可选; 不填=当前会话群/manageGroup)' },
|
|
1030
|
+
keywords: { type: 'array', items: { type: 'string' }, required: true, description: '放行关键词(任一命中即放行; 不区分大小写)' },
|
|
1031
|
+
rejectUnmatched: { type: 'boolean', description: 'true(默认)=未命中的一律拒绝; false=只放行命中的, 其余不动' },
|
|
1032
|
+
rejectReason: { type: 'string', description: '拒绝理由(默认"答非所问"; 不要写暗号/答案)' },
|
|
1033
|
+
dry_run: { type: 'boolean', description: 'true=只返回"将放行/拒绝哪些人", 不执行' },
|
|
1034
|
+
},
|
|
1035
|
+
output: {
|
|
1036
|
+
schema: {
|
|
1037
|
+
type: 'object', additionalProperties: false,
|
|
1038
|
+
properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
|
|
1039
|
+
},
|
|
1040
|
+
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1041
|
+
},
|
|
1042
|
+
async execute(args, exec) {
|
|
1043
|
+
const ga = groupAdminOf(exec);
|
|
1044
|
+
if (!ga)
|
|
1045
|
+
return { ok: false, msg: '群管理未开启(设置→QQ群管理)或非群会话' };
|
|
1046
|
+
const gid = String(args.gid || ga.gid || '');
|
|
1047
|
+
if (!gid)
|
|
1048
|
+
return { ok: false, msg: '当前不是群会话且未指定 gid, 无法确定目标群' };
|
|
1049
|
+
const kws = (Array.isArray(args.keywords) ? args.keywords : [])
|
|
1050
|
+
.map((k) => String(k).trim().toLowerCase())
|
|
1051
|
+
.filter(Boolean);
|
|
1052
|
+
if (kws.length === 0)
|
|
1053
|
+
return { ok: false, msg: 'keywords 至少给一个(放行关键词)' };
|
|
1054
|
+
const r = await ga.client.listJoinRequests(gid);
|
|
1055
|
+
if (!r.ok)
|
|
1056
|
+
return { ok: false, msg: r.err.human };
|
|
1057
|
+
const raw = r.data.list;
|
|
1058
|
+
if (raw.length === 0)
|
|
1059
|
+
return { ok: true, msg: '当前没有待审批的入群申请 ✓(无需自动审批)' };
|
|
1060
|
+
// 同一人重复申请只留最新(官方列表按时间倒序 → 首次出现即最新)
|
|
1061
|
+
const seen = new Set();
|
|
1062
|
+
const list = raw.filter((j) => {
|
|
1063
|
+
const k = String(j.member_openid ?? '');
|
|
1064
|
+
if (k === '' || seen.has(k))
|
|
1065
|
+
return false;
|
|
1066
|
+
seen.add(k);
|
|
1067
|
+
return true;
|
|
1068
|
+
});
|
|
1069
|
+
const hitList = [];
|
|
1070
|
+
const missList = [];
|
|
1071
|
+
for (const j of list) {
|
|
1072
|
+
const text = verifyHuman(j.verify_info).toLowerCase();
|
|
1073
|
+
(kws.some((k) => text.includes(k)) ? hitList : missList).push(j);
|
|
1074
|
+
}
|
|
1075
|
+
const rejectUnmatched = args.rejectUnmatched !== false;
|
|
1076
|
+
const nameOf = (j) => `${j.username ?? '?'}(…${String(j.member_openid).slice(-4)})`;
|
|
1077
|
+
if (args.dry_run === true) {
|
|
1078
|
+
return {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
msg: `【dry-run, 未执行】待审 ${list.length} 条(去重后):\n` +
|
|
1081
|
+
`将放行 ${hitList.length} 人: ${hitList.slice(0, 5).map(nameOf).join('、') || '(无)'}\n` +
|
|
1082
|
+
`将拒绝 ${rejectUnmatched ? missList.length : 0} 人: ${rejectUnmatched ? (missList.slice(0, 5).map(nameOf).join('、') || '(无)') : '(已关闭)'}`,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
let okA = 0;
|
|
1086
|
+
let failA = 0;
|
|
1087
|
+
let okD = 0;
|
|
1088
|
+
let failD = 0;
|
|
1089
|
+
const errs = [];
|
|
1090
|
+
for (const j of hitList) {
|
|
1091
|
+
const rr = await ga.client.approveJoinRequest(gid, String(j.member_openid), 'approve', {
|
|
1092
|
+
join_request_id: String(j.join_request_id ?? ''),
|
|
1093
|
+
});
|
|
1094
|
+
if (rr.ok)
|
|
1095
|
+
okA++;
|
|
1096
|
+
else {
|
|
1097
|
+
failA++;
|
|
1098
|
+
errs.push(`${nameOf(j)} 放行失败: ${rr.err.human}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (rejectUnmatched) {
|
|
1102
|
+
const reason = String(args.rejectReason ?? '').trim() || '答非所问';
|
|
1103
|
+
for (const j of missList) {
|
|
1104
|
+
const rr = await ga.client.approveJoinRequest(gid, String(j.member_openid), 'decline', {
|
|
1105
|
+
join_request_id: String(j.join_request_id ?? ''),
|
|
1106
|
+
reject_reason: reason,
|
|
1107
|
+
});
|
|
1108
|
+
if (rr.ok)
|
|
1109
|
+
okD++;
|
|
1110
|
+
else {
|
|
1111
|
+
failD++;
|
|
1112
|
+
errs.push(`${nameOf(j)} 拒绝失败: ${rr.err.human}`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
ok: true,
|
|
1118
|
+
msg: [
|
|
1119
|
+
`✅ 自动审批完成: 放行 ${okA}/${hitList.length}${rejectUnmatched ? ` · 拒绝 ${okD}/${missList.length}` : ''}(待审共 ${list.length} 条)`,
|
|
1120
|
+
hitList.length ? `放行: ${hitList.slice(0, 5).map(nameOf).join('、')}${hitList.length > 5 ? ` …等 ${hitList.length} 人` : ''}` : '',
|
|
1121
|
+
rejectUnmatched && missList.length ? `拒绝: ${missList.slice(0, 5).map(nameOf).join('、')}${missList.length > 5 ? ` …等 ${missList.length} 人` : ''}` : '',
|
|
1122
|
+
errs.length ? `⚠️ 失败 ${failA + failD} 条: ${errs.slice(0, 3).join('; ')}` : '',
|
|
1123
|
+
].filter(Boolean).join('\n'),
|
|
1124
|
+
};
|
|
1125
|
+
},
|
|
1126
|
+
});
|
|
994
1127
|
const sessionListTool = defineTool({
|
|
995
1128
|
name: 'session_list',
|
|
996
|
-
description: '会话(读):
|
|
997
|
-
parameters: {
|
|
1129
|
+
description: '会话(读): 列出全部活跃会话(范围/peer/群名/活跃时间), 用于跨会话寻址。**默认 peer/sender 只给尾号**(省 token); 需要完整 openid 时传 full=true。',
|
|
1130
|
+
parameters: {
|
|
1131
|
+
full: { type: 'boolean', description: 'true=给完整 openid; 默认 false(只给尾号, 寻址用群名/备注即可)' },
|
|
1132
|
+
},
|
|
998
1133
|
output: {
|
|
999
1134
|
schema: {
|
|
1000
1135
|
type: 'object', additionalProperties: false,
|
|
@@ -1002,7 +1137,8 @@ export async function apply(ctx) {
|
|
|
1002
1137
|
},
|
|
1003
1138
|
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1004
1139
|
},
|
|
1005
|
-
async execute(
|
|
1140
|
+
async execute(args, exec) {
|
|
1141
|
+
const full = args?.full === true;
|
|
1006
1142
|
const lines = [];
|
|
1007
1143
|
// 遍历全部已注册实例(module 级注册表, 重启后仍可枚举; 不依赖 channelBridges)
|
|
1008
1144
|
const managers = managersOf();
|
|
@@ -1032,9 +1168,11 @@ export async function apply(ctx) {
|
|
|
1032
1168
|
for (const s of list) {
|
|
1033
1169
|
n++;
|
|
1034
1170
|
const label = s.scope === 'group' && reg[s.peerId]?.name ? ` "${reg[s.peerId].name}"` : '';
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1171
|
+
// 2026-09-12 token 瘦身(主人定): **默认只给 peer/sender 尾号** —— 完整 32 位 openid 每行 20+ token,
|
|
1172
|
+
// 而寻址用"群名/备注"就够(broadcast_send / target_group 都支持按名解析); 要完整 id 传 full=true。
|
|
1173
|
+
const peerShown = full ? s.peerId : '…' + String(s.peerId).slice(-4);
|
|
1174
|
+
const senderShown = full ? s.senderId : '…' + String(s.senderId).slice(-4);
|
|
1175
|
+
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()}`);
|
|
1038
1176
|
}
|
|
1039
1177
|
// 潜在会话: 群注册表里的群可能还没 getOrCreate(无活跃记录), 但 sessionId 可确定性算出
|
|
1040
1178
|
try {
|
|
@@ -1169,7 +1307,9 @@ export async function apply(ctx) {
|
|
|
1169
1307
|
function fmtBroadcastTask(t, fallbackId) {
|
|
1170
1308
|
if (!t)
|
|
1171
1309
|
return `任务不存在(可能已被清理); task_id=${fallbackId ?? '(未给)'}`;
|
|
1172
|
-
|
|
1310
|
+
// 2026-09-12 token 瘦身: 逐目标明细最多列 5 个(超出只报数量), 免得一次群发 20 个目标把结果灌爆上下文
|
|
1311
|
+
const shownTargets = t.targets.slice(0, 5);
|
|
1312
|
+
const rows = shownTargets.map((tg) => {
|
|
1173
1313
|
const r = t.results[tg.peerId];
|
|
1174
1314
|
const who = tg.name ? `${tg.name}(${tg.peerId})` : tg.peerId;
|
|
1175
1315
|
if (!r)
|
|
@@ -1178,6 +1318,8 @@ export async function apply(ctx) {
|
|
|
1178
1318
|
return `· ${who} ✅ message_id=${r.message_id ?? '(无)'}`;
|
|
1179
1319
|
return `· ${who} ❌ ${r.err ?? '失败'}(已重试 ${t.retries[tg.peerId] ?? 0} 次)`;
|
|
1180
1320
|
});
|
|
1321
|
+
if (t.targets.length > shownTargets.length)
|
|
1322
|
+
rows.push(`…还有 ${t.targets.length - shownTargets.length} 个目标(省略)`);
|
|
1181
1323
|
const okN = t.targets.filter((tg) => t.results[tg.peerId]?.ok).length;
|
|
1182
1324
|
const done = t.state === 'done' || t.state === 'partial_failed' || t.state === 'cancelled';
|
|
1183
1325
|
const head = done
|
|
@@ -1269,8 +1411,9 @@ export async function apply(ctx) {
|
|
|
1269
1411
|
return {
|
|
1270
1412
|
ok: true,
|
|
1271
1413
|
msg: `【dry-run, 未发送】将发到 ${resolved.targets.length} 个目标:\n` +
|
|
1272
|
-
resolved.targets.map((t) => `· ${t.name ?? '(无备注)'}
|
|
1273
|
-
|
|
1414
|
+
resolved.targets.slice(0, 5).map((t) => `· ${t.name ?? '(无备注)'}`).join('\n') +
|
|
1415
|
+
(resolved.targets.length > 5 ? `\n…还有 ${resolved.targets.length - 5} 个(省略)` : '') +
|
|
1416
|
+
`\n\n内容(${type}, ${text.length} 字):\n${text.slice(0, 200)}${text.length > 200 ? '…' : ''}`,
|
|
1274
1417
|
};
|
|
1275
1418
|
}
|
|
1276
1419
|
const task = broadcastQueue.createTask(root, { type, content: text, targets: resolved.targets, created_by: 'agent' });
|
|
@@ -1397,7 +1540,7 @@ export async function apply(ctx) {
|
|
|
1397
1540
|
const delta = action === 'add' ? cur.size - before : before - cur.size;
|
|
1398
1541
|
return {
|
|
1399
1542
|
ok: true,
|
|
1400
|
-
msg: `✅ 分组「${name}」${action === 'add' ? '加入' : '移出'} ${delta} 个目标, 现共 ${cur.size} 个${delta < resolved.targets.length ? '(
|
|
1543
|
+
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} 个(省略)` : ''}`,
|
|
1401
1544
|
};
|
|
1402
1545
|
}
|
|
1403
1546
|
return { ok: false, msg: `未知 action: ${action}` };
|
|
@@ -1655,7 +1798,9 @@ export async function apply(ctx) {
|
|
|
1655
1798
|
const muteStateTool = defineTool({
|
|
1656
1799
|
name: 'group_mute_state',
|
|
1657
1800
|
description: '群管理(读): 查看当前群禁言状态(全员模式 + 正在禁言中的成员及到期时间)。仅主人要求时调用。',
|
|
1658
|
-
parameters: {
|
|
1801
|
+
parameters: {
|
|
1802
|
+
gid: { type: 'string', description: '目标群 openid(可选; 不填=当前会话群/manageGroup)' },
|
|
1803
|
+
},
|
|
1659
1804
|
output: {
|
|
1660
1805
|
schema: {
|
|
1661
1806
|
type: 'object', additionalProperties: false,
|
|
@@ -1663,13 +1808,14 @@ export async function apply(ctx) {
|
|
|
1663
1808
|
},
|
|
1664
1809
|
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1665
1810
|
},
|
|
1666
|
-
async execute(
|
|
1811
|
+
async execute(args, exec) {
|
|
1667
1812
|
const ga = groupAdminOf(exec);
|
|
1668
1813
|
if (!ga)
|
|
1669
1814
|
return { ok: false, msg: '群管理未开启或非群会话' };
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1815
|
+
const gid = String(args.gid ?? '').trim() || ga.gid || '';
|
|
1816
|
+
if (!gid)
|
|
1817
|
+
return { ok: false, msg: '当前不是群会话且未指定 gid, 无法确定目标群' };
|
|
1818
|
+
const r = await ga.client.getMuteState(gid);
|
|
1673
1819
|
if (!r.ok)
|
|
1674
1820
|
return { ok: false, msg: r.err.human };
|
|
1675
1821
|
const mode = r.data.global_rule?.mode ?? 'none';
|
|
@@ -1680,9 +1826,12 @@ export async function apply(ctx) {
|
|
|
1680
1826
|
});
|
|
1681
1827
|
const muteMemberTool = defineTool({
|
|
1682
1828
|
name: 'group_mute_member',
|
|
1683
|
-
description: '群管理(写,危险):
|
|
1829
|
+
description: '群管理(写,危险): 禁言/解除群成员(**可批量, 单次最多 20 人**)。action=mute 禁言(seconds 秒, 默认600); unmute=解除。只能禁普通成员(群主/管理员禁不了)。' +
|
|
1830
|
+
'\n想按昵称禁言先用 id_lookup 拿 openid; 仅主人明确要求时调用。' +
|
|
1831
|
+
'\n示例: {member_openids:["<openid>"], action:"mute", seconds:600}',
|
|
1684
1832
|
parameters: {
|
|
1685
|
-
|
|
1833
|
+
gid: { type: 'string', description: '目标群 openid(可选; 不填=当前会话群/manageGroup)' },
|
|
1834
|
+
member_openids: { type: 'array', items: { type: 'string' }, required: true, description: '目标成员 openid 数组(只禁 1 个也传数组; ≤20 个; 可先用 id_lookup 按昵称查)' },
|
|
1686
1835
|
action: { type: 'string', required: true, enum: ['mute', 'unmute'], description: 'mute 禁言 / unmute 解除' },
|
|
1687
1836
|
seconds: { type: 'number', required: true, description: '禁言秒数(mute 时; 不需要可填 600)' },
|
|
1688
1837
|
},
|
|
@@ -1697,18 +1846,149 @@ export async function apply(ctx) {
|
|
|
1697
1846
|
const ga = groupAdminOf(exec);
|
|
1698
1847
|
if (!ga)
|
|
1699
1848
|
return { ok: false, msg: '群管理未开启或非群会话' };
|
|
1700
|
-
|
|
1701
|
-
|
|
1849
|
+
const gid = String(args.gid ?? '').trim() || ga.gid || '';
|
|
1850
|
+
if (!gid)
|
|
1851
|
+
return { ok: false, msg: '当前不是群会话且未指定 gid, 无法确定目标群' };
|
|
1702
1852
|
const secs = Math.max(1, Math.min(30 * 86400, Math.round(Number(args.seconds ?? 600))));
|
|
1703
1853
|
const pad = (n) => String(n).padStart(2, '0');
|
|
1704
1854
|
const expire = args.action === 'mute' ? new Date(Date.now() + secs * 1000) : null;
|
|
1705
1855
|
const rfc = expire
|
|
1706
1856
|
? `${expire.getFullYear()}-${pad(expire.getMonth() + 1)}-${pad(expire.getDate())}T${pad(expire.getHours())}:${pad(expire.getMinutes())}:${pad(expire.getSeconds())}+08:00`
|
|
1707
1857
|
: null;
|
|
1708
|
-
const
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1858
|
+
const all = (Array.isArray(args.member_openids) ? args.member_openids : [])
|
|
1859
|
+
.map((v) => String(v).trim()).filter(Boolean)
|
|
1860
|
+
.map((v) => (v.startsWith('<@') && v.endsWith('>') ? v.slice(2, -1) : v)) // 容错: 直接贴 <@openid> 也认
|
|
1861
|
+
.filter((v, i, arr) => arr.indexOf(v) === i);
|
|
1862
|
+
if (all.length === 0)
|
|
1863
|
+
return { ok: false, msg: 'member_openids 至少给一个 openid' };
|
|
1864
|
+
const ids = all.slice(0, 20); // 官方单次 ≤20 人
|
|
1865
|
+
let okCount = 0;
|
|
1866
|
+
const errs = [];
|
|
1867
|
+
for (const id of ids) {
|
|
1868
|
+
const rr = await ga.client.setMemberMute(gid, id, rfc);
|
|
1869
|
+
if (rr.ok)
|
|
1870
|
+
okCount++;
|
|
1871
|
+
else
|
|
1872
|
+
errs.push(`…${id.slice(-4)}: ${rr.err.human}`);
|
|
1873
|
+
if (ids.length > 1)
|
|
1874
|
+
await new Promise((res) => setTimeout(res, 120)); // 略停, 别撞频控
|
|
1875
|
+
}
|
|
1876
|
+
if (okCount === 0)
|
|
1877
|
+
return { ok: false, msg: errs[0] ?? '禁言失败' };
|
|
1878
|
+
const over = all.length > 20 ? ` (超出 20 人的 ${all.length - 20} 个已忽略, 请分两次)` : '';
|
|
1879
|
+
const failNote = errs.length ? ` · 失败 ${errs.length} 人(${errs.slice(0, 3).join('; ')})` : '';
|
|
1880
|
+
return {
|
|
1881
|
+
ok: true,
|
|
1882
|
+
msg: args.action === 'mute'
|
|
1883
|
+
? `✅ 已禁言 ${okCount}/${ids.length} 人, ${Math.round(secs / 60)} 分钟后自动解除${failNote}${over}`
|
|
1884
|
+
: `✅ 已解除禁言 ${okCount}/${ids.length} 人${failNote}${over}`,
|
|
1885
|
+
};
|
|
1886
|
+
},
|
|
1887
|
+
});
|
|
1888
|
+
/**
|
|
1889
|
+
* 昵称/群名 → openid(2026-09-12 主人要求) —— AI 想**主动 @ 一个没 @ 过她的人**、或按名字禁言时,
|
|
1890
|
+
* 必须先拿到 openid 才能写出 `<@openid>`。官方「群成员列表」未开放(调用得 11253),
|
|
1891
|
+
* 所以数据源是: ①本地群成员台账 `{dataRoot}/表情包/group-members.jsonl`(在群里发过言的人 + 入群申请写入的人)
|
|
1892
|
+
* ②群注册表 groups.json(群名 → 群 openid)。
|
|
1893
|
+
* ⚠️ 从没发过言、没申请过入群的人查不到 —— 官方不给名单导致的硬限制, 不是 bug。
|
|
1894
|
+
*/
|
|
1895
|
+
const idLookupTool = defineTool({
|
|
1896
|
+
name: 'id_lookup',
|
|
1897
|
+
description: '按**昵称/群名**查 openid(主动 @ 人、禁言、跨群发消息前用它拿 id)。**支持一次查多个名字**。' +
|
|
1898
|
+
'\n数据源: 群注册表(群名) + 本地群成员台账(在群里发过言/申请过入群的人)。' +
|
|
1899
|
+
'\n⚠️ 从没发过言、没申请过入群的人查不到(官方群成员列表接口未开放)。' +
|
|
1900
|
+
'\n示例: {names:["小云","阿水"]} · {names:["小云"], gid:"<群openid>"} · {names:["测试群"], kind:"group"}',
|
|
1901
|
+
parameters: {
|
|
1902
|
+
names: { type: 'array', items: { type: 'string' }, required: true, description: '一个或多个昵称/群名片段(最多 10 个; 单个也传数组; 空格分隔=多词全命中)' },
|
|
1903
|
+
gid: { type: 'string', description: '限定某个群的成员(可选; 不填=查全部已知群)' },
|
|
1904
|
+
kind: { type: 'string', enum: ['member', 'group', 'all'], description: 'member=只查人(默认) / group=只查群 / all=都查' },
|
|
1905
|
+
limit: { type: 'number', description: '**每个**名字最多返回几条(1~10, 默认 3)' },
|
|
1906
|
+
},
|
|
1907
|
+
output: {
|
|
1908
|
+
schema: {
|
|
1909
|
+
type: 'object', additionalProperties: false,
|
|
1910
|
+
properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
|
|
1911
|
+
},
|
|
1912
|
+
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1913
|
+
},
|
|
1914
|
+
async execute(args) {
|
|
1915
|
+
const raw = Array.isArray(args.names) ? args.names : (args.names ?? args.name);
|
|
1916
|
+
const kws = (Array.isArray(raw) ? raw : [raw])
|
|
1917
|
+
.map((v) => String(v ?? '').trim().toLowerCase()).filter(Boolean).slice(0, 10);
|
|
1918
|
+
if (kws.length === 0)
|
|
1919
|
+
return { ok: false, msg: 'names 至少给一个昵称' };
|
|
1920
|
+
const wantGid = String(args.gid ?? '').trim();
|
|
1921
|
+
const kind = String(args.kind ?? 'member');
|
|
1922
|
+
const limit = Math.max(1, Math.min(10, Math.round(Number(args.limit ?? 3))));
|
|
1923
|
+
const roots = new Set();
|
|
1924
|
+
for (const m of managersOf())
|
|
1925
|
+
roots.add(m.dataRoot);
|
|
1926
|
+
// 群注册表(gid → 群名/最近活跃): 多个 dataRoot 聚合, lastAt 新者胜
|
|
1927
|
+
const groups = new Map();
|
|
1928
|
+
for (const root of roots) {
|
|
1929
|
+
try {
|
|
1930
|
+
const reg = JSON.parse(readFileSync(groupRegistryPath(root), 'utf8'));
|
|
1931
|
+
for (const [gid, v] of Object.entries(reg ?? {})) {
|
|
1932
|
+
const name = String(v?.name ?? '');
|
|
1933
|
+
const lastAt = Number(v?.lastAt ?? 0);
|
|
1934
|
+
const cur = groups.get(gid);
|
|
1935
|
+
if (!cur || lastAt > cur.lastAt)
|
|
1936
|
+
groups.set(gid, { name: name || cur?.name || '', lastAt });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
catch { /* 该 root 没有注册表 */ }
|
|
1940
|
+
}
|
|
1941
|
+
// 台账只读一次(按词重复读纯浪费), 按 openid 合并群列表
|
|
1942
|
+
const people = new Map();
|
|
1943
|
+
if (kind !== 'group') {
|
|
1944
|
+
for (const root of roots) {
|
|
1945
|
+
try {
|
|
1946
|
+
for (const m of readGroupMembers(join(root, '表情包'), wantGid || undefined)) {
|
|
1947
|
+
const cur = people.get(m.mid);
|
|
1948
|
+
if (!cur) {
|
|
1949
|
+
people.set(m.mid, { name: m.name ?? '', gids: new Set([m.gid]), lastSeen: m.lastSeen, count: m.count });
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
cur.gids.add(m.gid);
|
|
1953
|
+
if (m.lastSeen > cur.lastSeen) {
|
|
1954
|
+
cur.lastSeen = m.lastSeen;
|
|
1955
|
+
if (m.name)
|
|
1956
|
+
cur.name = m.name;
|
|
1957
|
+
}
|
|
1958
|
+
cur.count += m.count;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
catch { /* 无台账则跳过 */ }
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
const gName = (gid) => groups.get(gid)?.name || `群…${gid.slice(-4)}`;
|
|
1965
|
+
const out = [];
|
|
1966
|
+
for (const kw of kws) {
|
|
1967
|
+
let hit = 0;
|
|
1968
|
+
if (kind !== 'group') {
|
|
1969
|
+
const matched = [...people.entries()]
|
|
1970
|
+
.filter(([, v]) => v.name.toLowerCase().includes(kw))
|
|
1971
|
+
.sort((a, b) => b[1].lastSeen - a[1].lastSeen)
|
|
1972
|
+
.slice(0, limit);
|
|
1973
|
+
for (const [mid, v] of matched) {
|
|
1974
|
+
out.push(`${v.name} ${mid} 在: ${[...v.gids].map(gName).join(', ')}`);
|
|
1975
|
+
hit++;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
if (kind !== 'member') {
|
|
1979
|
+
for (const [gid, v] of groups) {
|
|
1980
|
+
if (hit >= limit)
|
|
1981
|
+
break;
|
|
1982
|
+
if (!v.name.toLowerCase().includes(kw))
|
|
1983
|
+
continue;
|
|
1984
|
+
out.push(`群 ${v.name} ${gid}`);
|
|
1985
|
+
hit++;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (hit === 0)
|
|
1989
|
+
out.push(`${kw} → 没查到(台账只记发过言 / 申请过入群的人)`);
|
|
1990
|
+
}
|
|
1991
|
+
return { ok: true, msg: out.join('\n') };
|
|
1712
1992
|
},
|
|
1713
1993
|
});
|
|
1714
1994
|
// tools_reload: 热刷新 QQ 通道工具(2026-09-08, 主人建议)——AI 自己写完扩展工具后,
|
|
@@ -1747,8 +2027,10 @@ export async function apply(ctx) {
|
|
|
1747
2027
|
{ name: 'outbound_mode', tool: outboundModeTool },
|
|
1748
2028
|
{ name: 'group_join_requests', tool: listJoinRequestsTool },
|
|
1749
2029
|
{ name: 'group_approve_join', tool: approveJoinTool },
|
|
2030
|
+
{ name: 'group_join_auto', tool: autoApproveJoinTool },
|
|
1750
2031
|
{ name: 'group_mute_state', tool: muteStateTool },
|
|
1751
2032
|
{ name: 'group_mute_member', tool: muteMemberTool },
|
|
2033
|
+
{ name: 'id_lookup', tool: idLookupTool },
|
|
1752
2034
|
{ name: 'session_list', tool: sessionListTool },
|
|
1753
2035
|
{ name: 'session_wake', tool: sessionWakeTool },
|
|
1754
2036
|
{ name: 'broadcast_send', tool: broadcastSendTool },
|