@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.
Files changed (43) hide show
  1. package/README.md +7 -1
  2. package/README_EN.md +4 -1
  3. package/client/qqbot-settings.js +4136 -4113
  4. package/dist/api/group-admin.d.ts +1 -1
  5. package/dist/api/group-admin.d.ts.map +1 -1
  6. package/dist/api/group-admin.js +20 -4
  7. package/dist/api/group-admin.js.map +1 -1
  8. package/dist/channel-tools.d.ts.map +1 -1
  9. package/dist/channel-tools.js +318 -36
  10. package/dist/channel-tools.js.map +1 -1
  11. package/dist/config.d.ts +6 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +2 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/features/group-join-request.d.ts +2 -2
  16. package/dist/features/group-join-request.d.ts.map +1 -1
  17. package/dist/features/group-join-request.js +19 -5
  18. package/dist/features/group-join-request.js.map +1 -1
  19. package/dist/features/qq-approval.js +0 -1
  20. package/dist/features/qq-approval.js.map +1 -1
  21. package/dist/gateway/bootstrap.js +0 -1
  22. package/dist/gateway/bootstrap.js.map +1 -1
  23. package/dist/gateway/debounce.d.ts +5 -7
  24. package/dist/gateway/debounce.d.ts.map +1 -1
  25. package/dist/gateway/debounce.js +64 -11
  26. package/dist/gateway/debounce.js.map +1 -1
  27. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  28. package/dist/gateway/middleware-setup.js +17 -1
  29. package/dist/gateway/middleware-setup.js.map +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/middleware/media-history.d.ts.map +1 -1
  34. package/dist/middleware/media-history.js +3 -0
  35. package/dist/middleware/media-history.js.map +1 -1
  36. package/dist/transport/inbound.js +5 -2
  37. package/dist/transport/inbound.js.map +1 -1
  38. package/dist/transport/inject-rules.d.ts +1 -1
  39. package/dist/transport/inject-rules.d.ts.map +1 -1
  40. package/dist/transport/inject-rules.js +2 -2
  41. package/dist/transport/inject-rules.js.map +1 -1
  42. package/package.json +1 -1
  43. package/settings-host.js +5 -2
@@ -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: '(QQ聊天加分项,气氛对就大胆用!)查本地表情包库,返回图片本地路径+标签+详细描述。想用图回应的时刻——对方发图(接梗/回图)、爆笑/自嘲/庆祝/被夸/共鸣/话题热闹——**先**调它按情绪关键词搜库(query可空=最近收藏), 从返回里挑最贴切的一张, 用 send_media(kind=image, source=该路径) 发出去(0~1张, 一次最多一张)。每张的"描述"写着画面和适用场合帮你挑准; 只有实在查不到贴切的, 才退而回纯文字。别老憋着——QQ 聊天带张好图比干巴巴文字生动多了。',
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: '返回条数上限 1~20,默认5' },
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
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, Math.floor(limitRaw ?? 5))) : 5;
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: '(主动维护,别等用户开口!)看到"值得留"的图就调它收藏+打标: 群友发来有梗/好看/以后想用到的图(不管你这轮要不要发), 当场用 sticker_tag(传群消息里那张图的URL 或本地路径)收藏入库并给中文短标签+一句描述——图库越好用, 以后 list_stickers 越搜得到。已在库的图可改标签/描述; 缺标签或缺介绍的图(见 sticker_untagged)也用它补。tags=逗号分隔中文短标签(内容/情绪/用途, 如 爆笑,元气,打招呼); desc=一句"画面+情绪+适合场合"。想省视觉额度可传 vision=false(仅对已在库的图打标)。打标的意义: 你的表情包弹药库靠它养成, 不整理=好图烂在库里永远搜不到。',
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: '出站模式开关(自己决定): 切换本 bot 向 QQ 发消息的方式, 保存即热更新(不用重启, dock与设置同步)。四档任选: adaptive=适配主动(默认推荐): 真人消息前5条带引用回你、连发自动转独立消息不被QQ吞; detail=详细主动(2026-09-11 主人加): 聊天行为与 adaptive 完全一样, 但额外把工具调用/工具结果也推到 QQ —— 主人在 QQ 上就能看见你正在调什么工具(主人明确要求"看进度"时用, 消息会明显变多, 用完记得切回 adaptive); passive=被动: 始终回复最后一条(连发约4~5条后被QQ吞); silent=完全不出站: 照常思考但这条回复不发出(潜水观察用; web上仍可对话)。注意: 不提供 nothink(完全不思考)——那档只能由主人在设置页配置。根据当下场景选: 正常聊天/被@回应→adaptive; 主人要求看工具进度→detail; 想保持引用感→passive; 判断不该在群里说话(冷场/打扰)→silent。',
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: '群管理(读): 查看待审批的入群申请列表(申请人/验证消息/来源/风险提示)。默认查当前会话所在群/manageGroup; 也可传 gid 查指定群。仅主人要求时调用。需在设置开启"QQ群管理"且机器人为群管理员。⚠️群组管理器(收到【群管·入群申请】注入)的正确姿势: ①先用 session_list 找到【申请所在群】的会话; ②用 session_wake(session_id=该会话, mode=append, send_qq=true) 把申请转达过去; ③让【那个群的 bot 会话】用本工具核对名单、并用 group_approve_join 执行放行/拒绝 —— 每个群的审批交给该群自己的会话办(群成员表/权限/上下文都在那边); 只有在对方会话不可用时, 才由本会话显式带 gid 跨群代批。',
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 list = r.data.list;
929
- if (list.length === 0)
934
+ const raw = r.data.list;
935
+ if (raw.length === 0)
930
936
  return { ok: true, msg: `当前没有待审批的入群申请 ✓(群 ${gname ? `「${gname}」` : ''}${gid})` };
931
- const lines = list.map((j, i) => `${i + 1}. ${j.username ?? '?'} (${j.member_openid}) 来源:${j.apply_source ?? '?'} 验证:${verifyHuman(j.verify_info) || '-'}${j.risk_tips ? ` ⚠️${j.risk_tips}` : ''}`);
932
- return { ok: true, msg: `入群申请 ${list.length} 条(群 ${gname ? `「${gname}」` : ''}${gid}):\n${lines.join('\n')}` };
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: '群管理(写,危险): 审批入群申请。approve=放行 / decline=拒绝(可带理由)。支持批量: member_openids 数组一次批多人(优先); 也兼容单数 member_openid。仅主人明确要求时调用; 调用前建议先 group_join_requests 核对申请人。⚠️跨群审批务必显式传 gid(不传=当前会话所在群, 会批错群)。由群组管理器(hub)代批时的顺序: 优先用 session_wake 唤醒【申请所在群的会话】, 让那边的 bot 调本工具执行审批; 该会话不可用(无会话/append 失败)时才在本会话带 gid 直批。',
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: '会话(读): 列出本 bot 全部活跃会话(sessionId/范围/peer/最近活跃/预设), 用于跨会话寻址(找 session_wake 的目标 id)。',
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(_args, exec) {
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
- // ⚠️ peer/sender 一律给**完整** openid(2026-09-12 主人要求): 以前截成 …F01DC41545,
1036
- // 跨群寻址还得去翻日志才拼得出来, 等于不可用。
1037
- lines.push(`${n}. [${s.scope}]${ns !== 'im-qqbot' ? `(${ns})` : ''} peer=${s.peerId}${label} sender=${s.senderId} id=${s.sessionId}${s.agentPreset ? ` (${s.agentPreset})` : ''} 活跃=${new Date(s.lastActivity).toLocaleTimeString()}`);
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
- const rows = t.targets.map((tg) => {
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 ?? '(无备注)'} — ${t.peerId}`).join('\n') +
1273
- `\n\n内容(${type}, ${text.length} 字):\n${text.slice(0, 300)}${text.length > 300 ? '…' : ''}`,
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 ? '(重复的已跳过; 未命中的按上面提示处理)' : ''}\n${resolved.targets.map((t) => '· ' + (t.name || t.peerId)).join('\n')}`,
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(_args, exec) {
1811
+ async execute(args, exec) {
1667
1812
  const ga = groupAdminOf(exec);
1668
1813
  if (!ga)
1669
1814
  return { ok: false, msg: '群管理未开启或非群会话' };
1670
- if (!ga.gid)
1671
- return { ok: false, msg: '当前不是群会话' };
1672
- const r = await ga.client.getMuteState(ga.gid);
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: '群管理(写,危险): 禁言/解除群成员。action=mute 禁言(seconds 秒, 默认600); unmute=立即解除。只能禁普通成员(群主/管理员禁不了)。仅主人明确要求时调用。',
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
- member_openid: { type: 'string', required: true, description: '目标成员 member_openid' },
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
- if (!ga.gid)
1701
- return { ok: false, msg: '当前不是群会话' };
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 r = await ga.client.setMemberMute(ga.gid, args.member_openid, rfc);
1709
- if (!r.ok)
1710
- return { ok: false, msg: r.err.human };
1711
- return { ok: true, msg: args.action === 'mute' ? `✅ 已禁言, ${Math.round(secs / 60)} 分钟后自动解除` : '已解除禁言' };
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 },