@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.
Files changed (70) hide show
  1. package/README.md +15 -5
  2. package/README_EN.md +3 -1
  3. package/client/qqbot-settings.js +132 -26
  4. package/dist/api/group-admin.d.ts +13 -3
  5. package/dist/api/group-admin.d.ts.map +1 -1
  6. package/dist/api/group-admin.js +49 -5
  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 +544 -29
  10. package/dist/channel-tools.js.map +1 -1
  11. package/dist/commands/answer.d.ts +29 -0
  12. package/dist/commands/answer.d.ts.map +1 -0
  13. package/dist/commands/answer.js +29 -0
  14. package/dist/commands/answer.js.map +1 -0
  15. package/dist/commands/index.d.ts.map +1 -1
  16. package/dist/commands/index.js +5 -0
  17. package/dist/commands/index.js.map +1 -1
  18. package/dist/commands/misc.d.ts.map +1 -1
  19. package/dist/commands/misc.js +15 -1
  20. package/dist/commands/misc.js.map +1 -1
  21. package/dist/commands/outmode.d.ts.map +1 -1
  22. package/dist/commands/outmode.js +3 -2
  23. package/dist/commands/outmode.js.map +1 -1
  24. package/dist/config.d.ts +4 -4
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +2 -2
  27. package/dist/config.js.map +1 -1
  28. package/dist/features/broadcast.d.ts.map +1 -1
  29. package/dist/features/broadcast.js +3 -1
  30. package/dist/features/broadcast.js.map +1 -1
  31. package/dist/features/group-hub.d.ts +3 -1
  32. package/dist/features/group-hub.d.ts.map +1 -1
  33. package/dist/features/group-hub.js +5 -3
  34. package/dist/features/group-hub.js.map +1 -1
  35. package/dist/features/group-join-request.d.ts +2 -2
  36. package/dist/features/group-join-request.d.ts.map +1 -1
  37. package/dist/features/group-join-request.js +25 -10
  38. package/dist/features/group-join-request.js.map +1 -1
  39. package/dist/features/outbound-mode-switch.d.ts +8 -2
  40. package/dist/features/outbound-mode-switch.d.ts.map +1 -1
  41. package/dist/features/outbound-mode-switch.js +2 -2
  42. package/dist/features/outbound-mode-switch.js.map +1 -1
  43. package/dist/features/poll-join-requests.js +2 -2
  44. package/dist/features/poll-join-requests.js.map +1 -1
  45. package/dist/features/qq-user-questions.d.ts +127 -4
  46. package/dist/features/qq-user-questions.d.ts.map +1 -1
  47. package/dist/features/qq-user-questions.js +314 -74
  48. package/dist/features/qq-user-questions.js.map +1 -1
  49. package/dist/gateway/bootstrap.d.ts.map +1 -1
  50. package/dist/gateway/bootstrap.js +17 -0
  51. package/dist/gateway/bootstrap.js.map +1 -1
  52. package/dist/gateway/data-root.d.ts +13 -4
  53. package/dist/gateway/data-root.d.ts.map +1 -1
  54. package/dist/gateway/data-root.js +22 -12
  55. package/dist/gateway/data-root.js.map +1 -1
  56. package/dist/gateway/debounce.d.ts.map +1 -1
  57. package/dist/gateway/debounce.js +5 -1
  58. package/dist/gateway/debounce.js.map +1 -1
  59. package/dist/index.js +2 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/middleware/media-history.d.ts.map +1 -1
  62. package/dist/middleware/media-history.js +3 -0
  63. package/dist/middleware/media-history.js.map +1 -1
  64. package/dist/transport/inbound.js +4 -1
  65. package/dist/transport/inbound.js.map +1 -1
  66. package/dist/transport/outbound.d.ts.map +1 -1
  67. package/dist/transport/outbound.js +60 -5
  68. package/dist/transport/outbound.js.map +1 -1
  69. package/package.json +1 -1
  70. package/settings-host.js +80 -8
@@ -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: '(QQ聊天加分项,气氛对就大胆用!)查本地表情包库,返回图片本地路径+标签+详细描述。想用图回应的时刻——对方发图(接梗/回图)、爆笑/自嘲/庆祝/被夸/共鸣/话题热闹——**先**调它按情绪关键词搜库(query可空=最近收藏), 从返回里挑最贴切的一张, 用 send_media(kind=image, source=该路径) 发出去(0~1张, 一次最多一张)。每张的"描述"写着画面和适用场合帮你挑准; 只有实在查不到贴切的, 才退而回纯文字。别老憋着——QQ 聊天带张好图比干巴巴文字生动多了。',
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: '返回条数上限 1~20,默认5' },
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
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, Math.floor(limitRaw ?? 5))) : 5;
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: '(主动维护,别等用户开口!)看到"值得留"的图就调它收藏+打标: 群友发来有梗/好看/以后想用到的图(不管你这轮要不要发), 当场用 sticker_tag(传群消息里那张图的URL 或本地路径)收藏入库并给中文短标签+一句描述——图库越好用, 以后 list_stickers 越搜得到。已在库的图可改标签/描述; 缺标签或缺介绍的图(见 sticker_untagged)也用它补。tags=逗号分隔中文短标签(内容/情绪/用途, 如 爆笑,元气,打招呼); desc=一句"画面+情绪+适合场合"。想省视觉额度可传 vision=false(仅对已在库的图打标)。打标的意义: 你的表情包弹药库靠它养成, 不整理=好图烂在库里永远搜不到。',
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: '出站模式开关(自己决定): 切换本 bot 向 QQ 发消息的方式, 保存即热更新(不用重启, dock与设置同步)。三档任选: adaptive=适配主动(默认推荐): 真人消息前5条带引用回你、连发自动转独立消息不被QQ吞; passive=被动: 始终回复最后一条(连发约4~5条后被QQ吞); silent=完全不出站: 照常思考但这条回复不发出(潜水观察用; web上仍可对话)。注意: 不提供 nothink(完全不思考)——那档只能由主人在设置页配置。根据当下场景选: 正常聊天/被@回应→adaptive; 想保持引用感→passive; 判断不该在群里说话(冷场/打扰)→silent。',
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: '群管理(读): 查看待审批的入群申请列表(申请人/验证消息/来源/风险提示)。默认查当前会话所在群/manageGroup; 也可传 gid 查指定群。仅主人要求时调用。需在设置开启"QQ群管理"且机器人为群管理员。⚠️群组管理器(收到【群管·入群申请】注入)的正确姿势: ①先用 session_list 找到【申请所在群】的会话; ②用 session_wake(session_id=该会话, mode=append, send_qq=true) 把申请转达过去; ③让【那个群的 bot 会话】用本工具核对名单、并用 group_approve_join 执行放行/拒绝 —— 每个群的审批交给该群自己的会话办(群成员表/权限/上下文都在那边); 只有在对方会话不可用时, 才由本会话显式带 gid 跨群代批。',
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 list = r.data.list;
926
- if (list.length === 0)
933
+ const raw = r.data.list;
934
+ if (raw.length === 0)
927
935
  return { ok: true, msg: `当前没有待审批的入群申请 ✓(群 ${gname ? `「${gname}」` : ''}${gid})` };
928
- 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}` : ''}`);
929
- return { ok: true, msg: `入群申请 ${list.length} 条(群 ${gname ? `「${gname}」` : ''}${gid}):\n${lines.join('\n')}` };
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: '群管理(写,危险): 审批入群申请。approve=放行 / decline=拒绝(可带理由)。支持批量: member_openids 数组一次批多人(优先); 也兼容单数 member_openid。仅主人明确要求时调用; 调用前建议先 group_join_requests 核对申请人。⚠️跨群审批务必显式传 gid(不传=当前会话所在群, 会批错群)。由群组管理器(hub)代批时的顺序: 优先用 session_wake 唤醒【申请所在群的会话】, 让那边的 bot 调本工具执行审批; 该会话不可用(无会话/append 失败)时才在本会话带 gid 直批。',
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: '会话(读): 列出本 bot 全部活跃会话(sessionId/范围/peer/最近活跃/预设), 用于跨会话寻址(找 session_wake 的目标 id)。',
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(_args, exec) {
1003
- const tail = (s, n = 10) => (s && s.length > n ? '…' + s.slice(-n) : (s ?? ''));
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
- lines.push(`${n}. [${s.scope}]${ns !== 'im-qqbot' ? `(${ns})` : ''} peer=${tail(s.peerId)} sender=${tail(s.senderId, 8)} id=${s.sessionId}${s.agentPreset ? ` (${s.agentPreset})` : ''} 活跃=${new Date(s.lastActivity).toLocaleTimeString()}`);
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 ?? ''}(${tail(gid)}) id=${sid} 活跃=未创建`);
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: '跨会话(写,需谨慎): 向指定会话发送一条消息; mode=wake(默认)唤醒该会话的 LLM(给 AI 看), mode=append 只把消息写进该会话上下文不唤醒(省 token, 喇叭模式, AI 下次回合自然看到)。同时把带来源标注的消息通过 QQBot 通道发到该会话绑定的群/私聊(给人看)。可用 session_list 查目标 id; 也支持按 peerId(群/私聊) 寻址。支持 batch 批量: 一次向多个会话各发不同文本(每项带自己的 session_id/peer_id/text/mode/send_qq/media)。仅主人明确要求时调用。', parameters: {
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', required: true, description: '要发送的消息内容(唤醒模式会作为用户消息喂给 AI)' },
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 },