@zaofan/dsh-qqbot 1.1.6 → 1.2.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 +23 -6
- package/README_EN.md +10 -0
- package/client/qqbot-settings.js +114 -26
- package/dist/api/group-admin.d.ts +13 -3
- package/dist/api/group-admin.d.ts.map +1 -1
- package/dist/api/group-admin.js +49 -5
- package/dist/api/group-admin.js.map +1 -1
- package/dist/channel-tools.d.ts.map +1 -1
- package/dist/channel-tools.js +386 -14
- package/dist/channel-tools.js.map +1 -1
- package/dist/commands/answer.d.ts +29 -0
- package/dist/commands/answer.d.ts.map +1 -0
- package/dist/commands/answer.js +29 -0
- package/dist/commands/answer.js.map +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +5 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/misc.d.ts.map +1 -1
- package/dist/commands/misc.js +15 -1
- package/dist/commands/misc.js.map +1 -1
- package/dist/commands/outmode.d.ts.map +1 -1
- package/dist/commands/outmode.js +3 -2
- package/dist/commands/outmode.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/features/broadcast.d.ts.map +1 -1
- package/dist/features/broadcast.js +3 -1
- package/dist/features/broadcast.js.map +1 -1
- package/dist/features/group-hub.d.ts +3 -1
- package/dist/features/group-hub.d.ts.map +1 -1
- package/dist/features/group-hub.js +5 -3
- package/dist/features/group-hub.js.map +1 -1
- package/dist/features/group-join-request.d.ts.map +1 -1
- package/dist/features/group-join-request.js +6 -5
- package/dist/features/group-join-request.js.map +1 -1
- package/dist/features/outbound-mode-switch.d.ts +8 -2
- package/dist/features/outbound-mode-switch.d.ts.map +1 -1
- package/dist/features/outbound-mode-switch.js +2 -2
- package/dist/features/outbound-mode-switch.js.map +1 -1
- package/dist/features/poll-join-requests.js +2 -2
- package/dist/features/poll-join-requests.js.map +1 -1
- package/dist/features/qq-user-questions.d.ts +127 -4
- package/dist/features/qq-user-questions.d.ts.map +1 -1
- package/dist/features/qq-user-questions.js +314 -74
- package/dist/features/qq-user-questions.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +17 -0
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/data-root.d.ts +13 -4
- package/dist/gateway/data-root.d.ts.map +1 -1
- package/dist/gateway/data-root.js +22 -12
- package/dist/gateway/data-root.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/transport/outbound.d.ts.map +1 -1
- package/dist/transport/outbound.js +60 -5
- package/dist/transport/outbound.js.map +1 -1
- package/package.json +1 -1
- package/settings-host.js +75 -6
package/dist/channel-tools.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 复制进共享 node_modules,也不得在 dependencies 里自带 dsh-tools(避免双包)。
|
|
9
9
|
*/
|
|
10
10
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
11
|
-
import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
12
13
|
import { getStickerStore } from './features/sticker-store.js';
|
|
13
14
|
import { autoTagImage } from './features/sticker-tagger.js';
|
|
14
15
|
import { isStickerGateDenied } from './features/sticker-gate.js';
|
|
@@ -16,6 +17,7 @@ import { getScheduleStore } from './features/schedule-store.js';
|
|
|
16
17
|
import { switchOutboundMode } from './features/outbound-mode-switch.js';
|
|
17
18
|
import { loadExtensionTools } from './features/extension-store.js';
|
|
18
19
|
import { verifyHuman, groupRegistryPath } from './api/group-admin.js';
|
|
20
|
+
import * as broadcastQueue from './features/broadcast.js';
|
|
19
21
|
import { wakeSessionAgent, safeAppendUserMessage } from './features/group-hub.js';
|
|
20
22
|
import { managersOf, findManagerByPeer, findManagerBySessionId } from './features/session-registry.js';
|
|
21
23
|
/** 诊断日志路径: 默认关闭; 需要排查时设环境变量 QQBOT_DIAG_FILE 指向日志文件 */
|
|
@@ -803,9 +805,9 @@ export async function apply(ctx) {
|
|
|
803
805
|
// 切换走 outbound-mode-switch 注册表 → live 热生效 + settings 持久化 → dock/设置一致。
|
|
804
806
|
const outboundModeTool = defineTool({
|
|
805
807
|
name: 'outbound_mode',
|
|
806
|
-
description: '出站模式开关(自己决定): 切换本 bot 向 QQ 发消息的方式, 保存即热更新(不用重启, dock与设置同步)
|
|
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。',
|
|
807
809
|
parameters: {
|
|
808
|
-
mode: { type: 'string', required: true, enum: ['adaptive', 'passive', 'silent'], description: '目标模式: adaptive(默认推荐) / passive / silent' },
|
|
810
|
+
mode: { type: 'string', required: true, enum: ['adaptive', 'detail', 'passive', 'silent'], description: '目标模式: adaptive(默认推荐) / detail(详细主动: 连工具调用一起推) / passive / silent' },
|
|
809
811
|
reason: { type: 'string', required: true, description: '为什么切到这档(简短理由)' },
|
|
810
812
|
},
|
|
811
813
|
output: {
|
|
@@ -818,8 +820,8 @@ export async function apply(ctx) {
|
|
|
818
820
|
],
|
|
819
821
|
},
|
|
820
822
|
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 ?? '') };
|
|
823
|
+
if (args.mode !== 'adaptive' && args.mode !== 'detail' && args.mode !== 'passive' && args.mode !== 'silent') {
|
|
824
|
+
return { ok: false, msg: '只允许 adaptive/detail/passive/silent(nothink 需主人在设置页配置)', mode: String(args.mode ?? '') };
|
|
823
825
|
}
|
|
824
826
|
// 多实例修复(2026-09-11): 按当前会话所属实例(ns)切换 —— 原全局单例 writer 会被多实例覆盖,
|
|
825
827
|
// 导致切到别的实例的 config(dock 显示与实际不符)。
|
|
@@ -836,6 +838,7 @@ export async function apply(ctx) {
|
|
|
836
838
|
// 主人也一定能收到"模式已切换"的通知(与 send_media 同款工具直发通道, 不受 outboundMode 拦截)。
|
|
837
839
|
const MODE_LABEL = {
|
|
838
840
|
adaptive: '适配主动(推荐默认): 真人消息前5条带引用回你, 连发自动转独立消息',
|
|
841
|
+
detail: '详细主动: 聊天同适配主动, 额外把工具调用/工具结果也推给你(看进度用)',
|
|
839
842
|
passive: '被动: 始终回复你那条(连发约4~5条后被QQ吞)',
|
|
840
843
|
silent: '完全不出站: 照常思考但这条回复不发出(潜水观察用; web上仍可对话)',
|
|
841
844
|
};
|
|
@@ -1000,7 +1003,6 @@ export async function apply(ctx) {
|
|
|
1000
1003
|
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1001
1004
|
},
|
|
1002
1005
|
async execute(_args, exec) {
|
|
1003
|
-
const tail = (s, n = 10) => (s && s.length > n ? '…' + s.slice(-n) : (s ?? ''));
|
|
1004
1006
|
const lines = [];
|
|
1005
1007
|
// 遍历全部已注册实例(module 级注册表, 重启后仍可枚举; 不依赖 channelBridges)
|
|
1006
1008
|
const managers = managersOf();
|
|
@@ -1016,28 +1018,391 @@ export async function apply(ctx) {
|
|
|
1016
1018
|
for (const manager of managers) {
|
|
1017
1019
|
const list = manager.listSessions();
|
|
1018
1020
|
const ns = manager.settingsNs;
|
|
1021
|
+
// 群名映射(注册表 {cwd}/.qqbot/groups.json: gid → {name}) —— 让 AI 能用"群名"寻址, 不必抄 openid
|
|
1022
|
+
// 群名取自**数据根**注册表({dataRoot}/.qqbot/groups.json, 本机=cwd\dshqqbot\.qqbot) —— 2026-09-12 修正:
|
|
1023
|
+
// 以前用 manager.cwd(=**工作目录** <工作目录>) 读, 拿到的是旧位置那份, 群名过时
|
|
1024
|
+
// (「群C」显示成「AI(旧备注)」)、新群(群B)整个缺席。
|
|
1025
|
+
let reg = {};
|
|
1026
|
+
try {
|
|
1027
|
+
reg = JSON.parse(readFileSync(groupRegistryPath(manager.dataRoot), 'utf8'));
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
reg = {};
|
|
1031
|
+
}
|
|
1019
1032
|
for (const s of list) {
|
|
1020
1033
|
n++;
|
|
1021
|
-
|
|
1034
|
+
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()}`);
|
|
1022
1038
|
}
|
|
1023
1039
|
// 潜在会话: 群注册表里的群可能还没 getOrCreate(无活跃记录), 但 sessionId 可确定性算出
|
|
1024
1040
|
try {
|
|
1025
|
-
const raw = readFileSync(groupRegistryPath(manager.cwd), 'utf8');
|
|
1026
|
-
const reg = JSON.parse(raw);
|
|
1027
1041
|
const ids = new Set(list.map((s) => s.sessionId));
|
|
1028
1042
|
for (const gid of Object.keys(reg ?? {})) {
|
|
1029
1043
|
const sid = manager.sessionIdFor('group', gid);
|
|
1030
1044
|
if (!ids.has(sid))
|
|
1031
|
-
lines.push(`[潜在群] ${reg[gid]?.name ?? ''}
|
|
1045
|
+
lines.push(`[潜在群] ${reg[gid]?.name ?? ''} peer=${gid} id=${sid} 活跃=未创建`);
|
|
1032
1046
|
}
|
|
1033
1047
|
}
|
|
1034
|
-
catch { /*
|
|
1048
|
+
catch { /* 注册表异常则跳过 */ }
|
|
1035
1049
|
}
|
|
1036
1050
|
if (lines.length === 0)
|
|
1037
1051
|
return { ok: true, msg: '当前无活跃会话, 也无已注册群' };
|
|
1038
1052
|
return { ok: true, msg: `会话 ${lines.length} 个:\n${lines.join('\n')}` };
|
|
1039
1053
|
},
|
|
1040
1054
|
});
|
|
1055
|
+
/**
|
|
1056
|
+
* 群发目标解析(2026-09-12): 完整 openid 直接用; 否则按**群名/备注**模糊匹配
|
|
1057
|
+
* (注册表 {cwd}/.qqbot/groups.json + 各实例活跃会话)。命中 0 个或多个 → 只回候选清单,
|
|
1058
|
+
* **绝不猜着发**(这是"默认不 dry-run"还能安全的原因)。
|
|
1059
|
+
*/
|
|
1060
|
+
function resolveBroadcastTargets(rawTargets, root) {
|
|
1061
|
+
const isOpenid = (s) => /^[A-Za-z0-9_-]{20,40}$/.test(s);
|
|
1062
|
+
// 合并多份 groups.json:同一目标**按 lastAt 最新者胜**。
|
|
1063
|
+
// ⚠️ 2026-09-12 实测踩到:旧位置的注册表(`{cwd}/.qqbot/groups.json`)还在、名字是过时的 ——
|
|
1064
|
+
// 同一个群在旧文件里叫「AI(旧备注)」,在 dataRoot 那份里才叫「群C」;
|
|
1065
|
+
// 而「群B」只存在于 dataRoot 那份里,只读旧文件就完全查不到。
|
|
1066
|
+
const known = new Map();
|
|
1067
|
+
const add = (scope, peerId, name, lastAt = 0) => {
|
|
1068
|
+
const k = `${scope}:${peerId}`;
|
|
1069
|
+
const cur = known.get(k);
|
|
1070
|
+
if (!cur) {
|
|
1071
|
+
known.set(k, { scope, peerId, name, lastAt });
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (lastAt > cur.lastAt) {
|
|
1075
|
+
cur.lastAt = lastAt;
|
|
1076
|
+
if (name)
|
|
1077
|
+
cur.name = name;
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (!cur.name && name)
|
|
1081
|
+
cur.name = name;
|
|
1082
|
+
};
|
|
1083
|
+
const readReg = (dir) => {
|
|
1084
|
+
try {
|
|
1085
|
+
const reg = JSON.parse(readFileSync(groupRegistryPath(dir), 'utf8'));
|
|
1086
|
+
for (const [gid, v] of Object.entries(reg ?? {}))
|
|
1087
|
+
add('group', gid, String(v?.name ?? ''), Number(v?.lastAt ?? 0));
|
|
1088
|
+
}
|
|
1089
|
+
catch { /* 该目录没有注册表则跳过 */ }
|
|
1090
|
+
};
|
|
1091
|
+
const managers = managersOf();
|
|
1092
|
+
const dirs = new Set();
|
|
1093
|
+
// 只认**数据根**:manager.dataRoot = dataRootOf(config) = config.dataRoot(=cwd\dshqqbot) ——
|
|
1094
|
+
// 数据目录早就定好了, 不该再去 cwd 捞旧注册表(2026-09-12 主人指正: 就是 cwd\dshqqbot, 别绕)。
|
|
1095
|
+
for (const m of managers)
|
|
1096
|
+
dirs.add(m.dataRoot);
|
|
1097
|
+
dirs.add(root);
|
|
1098
|
+
for (const d of dirs)
|
|
1099
|
+
readReg(d);
|
|
1100
|
+
// 🗂 自定义分组(dock「📇 群组管理 → 🗂 分组」; 2026-09-12 起由浏览器搬到 host) → **与主人共用同一份**:
|
|
1101
|
+
// 主人在面板上点几下分的组, AI 直接写分组名就能群发。结构: [{ id, name, members:['group:xxx'|'c2c:yyy'] }]
|
|
1102
|
+
const targetGroups = new Map();
|
|
1103
|
+
for (const d of dirs) {
|
|
1104
|
+
try {
|
|
1105
|
+
const o = JSON.parse(readFileSync(join(d, '.qqbot', 'target-groups.json'), 'utf8'));
|
|
1106
|
+
for (const g of (Array.isArray(o?.groups) ? o.groups : [])) {
|
|
1107
|
+
if (g?.name && Array.isArray(g.members) && g.members.length > 0)
|
|
1108
|
+
targetGroups.set(String(g.name), g.members.map(String));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
catch { /* 该数据根没有分组文件则跳过 */ }
|
|
1112
|
+
}
|
|
1113
|
+
for (const m of managers) {
|
|
1114
|
+
try {
|
|
1115
|
+
for (const s of m.listSessions())
|
|
1116
|
+
add(s.scope, s.peerId, '');
|
|
1117
|
+
}
|
|
1118
|
+
catch { /* ignore */ }
|
|
1119
|
+
}
|
|
1120
|
+
const all = [...known.values()];
|
|
1121
|
+
const targets = [];
|
|
1122
|
+
for (const raw of rawTargets) {
|
|
1123
|
+
// ① 分组名(优先于群名): "群友" 或 "分组:群友" → 展开成组内全部目标(与 dock 同一份分组数据)
|
|
1124
|
+
const gname = raw.replace(/^分组[::]\s*/u, '');
|
|
1125
|
+
const members = targetGroups.get(raw) ?? targetGroups.get(gname);
|
|
1126
|
+
if (members) {
|
|
1127
|
+
for (const m of members) {
|
|
1128
|
+
const mm = /^(group|c2c):(.+)$/i.exec(m);
|
|
1129
|
+
if (!mm)
|
|
1130
|
+
continue;
|
|
1131
|
+
const scope = mm[1].toLowerCase() === 'c2c' ? 'c2c' : 'group';
|
|
1132
|
+
const peerId = mm[2].trim();
|
|
1133
|
+
const hit = all.find((x) => x.scope === scope && x.peerId === peerId);
|
|
1134
|
+
targets.push({ scope, peerId, ...(hit?.name ? { name: hit.name } : {}) });
|
|
1135
|
+
}
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
const prefixed = /^(group|c2c):(.+)$/i.exec(raw);
|
|
1139
|
+
if (prefixed) {
|
|
1140
|
+
const scope = prefixed[1].toLowerCase() === 'c2c' ? 'c2c' : 'group';
|
|
1141
|
+
const peerId = prefixed[2].trim();
|
|
1142
|
+
const hit = all.find((x) => x.scope === scope && x.peerId === peerId);
|
|
1143
|
+
targets.push({ scope, peerId, ...(hit?.name ? { name: hit.name } : {}) });
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (isOpenid(raw)) {
|
|
1147
|
+
const hit = all.find((x) => x.peerId === raw);
|
|
1148
|
+
targets.push({ scope: hit?.scope ?? 'group', peerId: raw, ...(hit?.name ? { name: hit.name } : {}) });
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
const kw = raw.toLowerCase();
|
|
1152
|
+
const hits = [...new Map(all.filter((x) => x.name && x.name.toLowerCase().includes(kw)).map((h) => [h.peerId, h])).values()];
|
|
1153
|
+
if (hits.length === 1) {
|
|
1154
|
+
const h = hits[0];
|
|
1155
|
+
targets.push({ scope: h.scope, peerId: h.peerId, ...(h.name ? { name: h.name } : {}) });
|
|
1156
|
+
}
|
|
1157
|
+
else if (hits.length === 0) {
|
|
1158
|
+
const list = all.filter((x) => x.name).map((x) => `· ${x.name} — ${x.peerId}`).join('\n');
|
|
1159
|
+
return { targets: [], error: `没找到叫「${raw}」的群/私聊。已知目标:\n${list || '(暂无带备注的目标, 请用完整 openid)'}\n\n请用上面的名字或完整 openid 再调一次。` };
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
const list = hits.map((x) => `· ${x.name} — ${x.peerId}`).join('\n');
|
|
1163
|
+
return { targets: [], error: `「${raw}」匹配到多个目标, 请指定一个:\n${list}` };
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return { targets: [...new Map(targets.map((t) => [`${t.scope}:${t.peerId}`, t])).values()] };
|
|
1167
|
+
}
|
|
1168
|
+
/** 群发任务 → 人话摘要(逐目标状态 + message_id + 失败原因 + 撤回提示) */
|
|
1169
|
+
function fmtBroadcastTask(t, fallbackId) {
|
|
1170
|
+
if (!t)
|
|
1171
|
+
return `任务不存在(可能已被清理); task_id=${fallbackId ?? '(未给)'}`;
|
|
1172
|
+
const rows = t.targets.map((tg) => {
|
|
1173
|
+
const r = t.results[tg.peerId];
|
|
1174
|
+
const who = tg.name ? `${tg.name}(${tg.peerId})` : tg.peerId;
|
|
1175
|
+
if (!r)
|
|
1176
|
+
return `· ${who} ⏳ 待发`;
|
|
1177
|
+
if (r.ok)
|
|
1178
|
+
return `· ${who} ✅ message_id=${r.message_id ?? '(无)'}`;
|
|
1179
|
+
return `· ${who} ❌ ${r.err ?? '失败'}(已重试 ${t.retries[tg.peerId] ?? 0} 次)`;
|
|
1180
|
+
});
|
|
1181
|
+
const okN = t.targets.filter((tg) => t.results[tg.peerId]?.ok).length;
|
|
1182
|
+
const done = t.state === 'done' || t.state === 'partial_failed' || t.state === 'cancelled';
|
|
1183
|
+
const head = done
|
|
1184
|
+
? `群发${t.state === 'done' ? '完成' : t.state === 'cancelled' ? '已取消' : '部分失败'}: ${okN}/${t.targets.length} 成功 task_id=${t.task_id}`
|
|
1185
|
+
: `群发进行中(${t.state}): ${okN}/${t.targets.length} 已成功 task_id=${t.task_id} —— 后台队列会继续推进, 稍后用 action="status" 查`;
|
|
1186
|
+
return [
|
|
1187
|
+
head,
|
|
1188
|
+
...rows,
|
|
1189
|
+
okN > 0 ? `(2 分钟内可撤回:action="recall", task_id="${t.task_id}", targets=[要撤的目标])` : '',
|
|
1190
|
+
].filter(Boolean).join('\n');
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* 群发工具(2026-09-12 主人要求): 把插件现成的**广播队列**(features/broadcast.ts)接给 agent。
|
|
1194
|
+
* 设计(主人定): **默认直接发**, 不做 dry-run 预览 —— 主人当场指挥、自己就是发起人, 多一轮确认纯属烧 token;
|
|
1195
|
+
* 只有"按群名解析"命中 0 个/多个候选时才回一轮(那是必要信息, 不猜着发);
|
|
1196
|
+
* 护栏 = 工具描述写死"仅主人明确要求时调用" + **2 分钟撤回窗口**(返回 task_id 与 message_id)。
|
|
1197
|
+
* 白拿的队列能力: 串行逐目标、失败指数退避重试 3 次、任务落盘 {dataRoot}/.qqbot/broadcast-tasks.json、与 dock 面板同一份任务。
|
|
1198
|
+
* ⚠️ 不支持文件/图片群发(QQ 上传接口按群隔离 + 占主动消息配额), 资源请在正文放链接。
|
|
1199
|
+
*/
|
|
1200
|
+
const broadcastSendTool = defineTool({
|
|
1201
|
+
name: 'broadcast_send',
|
|
1202
|
+
description: '群发(写,高影响,**仅主人明确要求时调用**): 同一段内容一次发到多个 QQ 群/私聊, 走插件广播队列(串行+失败重试, 与 dock「📤 群发 · 广播」同一份任务, 2 分钟内可逐目标撤回)。targets 写**分组名/群名或备注**或完整 openid; 名字有歧义时只列候选让你确认, 不猜着发。默认直接发送(dry_run=true 才预览)。不支持发文件/图片 —— 资源把链接写进正文。' +
|
|
1203
|
+
'\n最小示例(照抄改值):\n' +
|
|
1204
|
+
'· 发分组 {targets:["群友"], text:"早上好呀各位~"}\n' +
|
|
1205
|
+
'· 发指定群 {targets:["群A","群B"], text:"公告:…"}\n' +
|
|
1206
|
+
'· 撤回/查进度 {action:"recall", task_id:"bc-xxx", targets:["群友"]} / {action:"status", task_id:"bc-xxx"}',
|
|
1207
|
+
parameters: {
|
|
1208
|
+
targets: { type: 'array', items: { type: 'string' }, required: true, description: '目标: 分组名(如"群友") / 群名或群备注(模糊匹配) / 完整 openid(32位) / "group:xxx" / "c2c:xxx"' },
|
|
1209
|
+
text: { type: 'string', description: '群发内容(text ≤2000 字 / markdown ≤8000 字); 链接直接写在里面' },
|
|
1210
|
+
type: { type: 'string', enum: ['text', 'markdown'], description: '默认 text(纯文本最稳); markdown 需模板权限(已开通)' },
|
|
1211
|
+
dry_run: { type: 'boolean', description: 'true=只预览"发给谁+内容"; 默认 false 直接发' },
|
|
1212
|
+
action: { type: 'string', enum: ['send', 'recall', 'status'], description: '默认 send; recall=撤回(需 task_id+targets, 2 分钟内); status=查进度' },
|
|
1213
|
+
task_id: { type: 'string', description: 'recall/status 用的任务 id(从发送结果里拿)' },
|
|
1214
|
+
},
|
|
1215
|
+
output: {
|
|
1216
|
+
schema: {
|
|
1217
|
+
type: 'object', additionalProperties: false,
|
|
1218
|
+
properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
|
|
1219
|
+
},
|
|
1220
|
+
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1221
|
+
},
|
|
1222
|
+
async execute(args, exec) {
|
|
1223
|
+
const a = args;
|
|
1224
|
+
const ga = groupAdminOf(exec);
|
|
1225
|
+
const ch = channelOf(exec);
|
|
1226
|
+
// ⚠️ 数据根 ≠ 工作目录: manager.dataRoot = dataRootOf(config)(本机=<cwd>\AI\dshqqbot),
|
|
1227
|
+
// 而 manager.cwd = config.cwd(本机=<cwd>\AI)。2026-09-12 主人实测发现我一开始写成了 cwd,
|
|
1228
|
+
// 结果广播任务落到 AI\.qqbot\(旧位置), dock 在 dshqqbot\.qqbot\ 里根本看不到。
|
|
1229
|
+
const root = ch?.manager?.dataRoot ?? managersOf()[0]?.dataRoot ?? managersOf()[0]?.cwd;
|
|
1230
|
+
if (!root)
|
|
1231
|
+
return { ok: false, msg: '找不到数据根(无已注册实例)' };
|
|
1232
|
+
if (!ga)
|
|
1233
|
+
return { ok: false, msg: '群管理未开启(设置页→QQ群管理): 群发要走官方接口, 需要机器人凭据' };
|
|
1234
|
+
const action = String(a.action ?? 'send');
|
|
1235
|
+
const rawTargets = Array.isArray(a.targets) ? a.targets.map((t) => String(t).trim()).filter(Boolean) : [];
|
|
1236
|
+
if (rawTargets.length === 0)
|
|
1237
|
+
return { ok: false, msg: 'targets 至少给一个(群名或 openid)' };
|
|
1238
|
+
if (action === 'status') {
|
|
1239
|
+
const t = a.task_id ? broadcastQueue.getTask(root, String(a.task_id)) : undefined;
|
|
1240
|
+
if (!t)
|
|
1241
|
+
return { ok: false, msg: '没找到该任务(或没传 task_id)' };
|
|
1242
|
+
return { ok: true, msg: fmtBroadcastTask(t) };
|
|
1243
|
+
}
|
|
1244
|
+
const resolved = resolveBroadcastTargets(rawTargets, root);
|
|
1245
|
+
if (resolved.error)
|
|
1246
|
+
return { ok: false, msg: resolved.error };
|
|
1247
|
+
if (action === 'recall') {
|
|
1248
|
+
const taskId = String(a.task_id ?? '');
|
|
1249
|
+
if (!taskId)
|
|
1250
|
+
return { ok: false, msg: 'recall 需要 task_id(从发送结果里拿)' };
|
|
1251
|
+
const out = [];
|
|
1252
|
+
for (const tg of resolved.targets) {
|
|
1253
|
+
const r = await broadcastQueue.recallTaskMessage(root, taskId, tg.peerId, ga.client);
|
|
1254
|
+
out.push(`${r.ok ? '✅ 已撤回' : '❌ 撤回失败'} ${tg.name ?? ''}(${tg.peerId})${r.err ? ' — ' + r.err : ''}`);
|
|
1255
|
+
}
|
|
1256
|
+
return { ok: true, msg: out.join('\n') };
|
|
1257
|
+
}
|
|
1258
|
+
// ── send ──
|
|
1259
|
+
const text = String(a.text ?? '');
|
|
1260
|
+
const type = a.type === 'markdown' ? 'markdown' : 'text';
|
|
1261
|
+
const limit = type === 'markdown' ? 8000 : 2000;
|
|
1262
|
+
if (!text.trim())
|
|
1263
|
+
return { ok: false, msg: 'text 必填(要群发的内容)' };
|
|
1264
|
+
if (text.length > limit)
|
|
1265
|
+
return { ok: false, msg: `内容过长: ${text.length} 字(${type} 上限 ${limit} 字)` };
|
|
1266
|
+
if (resolved.targets.length > 20)
|
|
1267
|
+
return { ok: false, msg: `目标太多(${resolved.targets.length} 个), 单次最多 20 个` };
|
|
1268
|
+
if (a.dry_run === true) {
|
|
1269
|
+
return {
|
|
1270
|
+
ok: true,
|
|
1271
|
+
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 ? '…' : ''}`,
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
const task = broadcastQueue.createTask(root, { type, content: text, targets: resolved.targets, created_by: 'agent' });
|
|
1277
|
+
broadcastQueue.confirmTask(root, task.task_id);
|
|
1278
|
+
// 同步推进: 每次一个目标, 最多等 ~20 秒; 目标多/网络慢时交给 host 的定时器继续, 用 action=status 查。
|
|
1279
|
+
// 目标之间隔 1.5s —— 群发是**主动消息**, 官方频控 Bot 维度 60/qpm(认证)/30/qpm(未认证),
|
|
1280
|
+
// 留余量给被动回复与别的推送(原来无间隔, 目标一多会瞬时打满额度)。
|
|
1281
|
+
const STEP_MS = 1500;
|
|
1282
|
+
const deadline = Date.now() + 20_000;
|
|
1283
|
+
let cur = broadcastQueue.getTask(root, task.task_id);
|
|
1284
|
+
while (cur && (cur.state === 'queued' || cur.state === 'sending') && Date.now() < deadline) {
|
|
1285
|
+
cur = await broadcastQueue.advanceTask(root, task.task_id, ga.client);
|
|
1286
|
+
if (cur && (cur.state === 'queued' || cur.state === 'sending') && Date.now() < deadline) {
|
|
1287
|
+
await new Promise((resolve) => setTimeout(resolve, STEP_MS));
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return { ok: true, msg: fmtBroadcastTask(cur, task.task_id) };
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
/**
|
|
1294
|
+
* 目标分组管理(2026-09-12 主人要求"AI 也要能编辑分组"):
|
|
1295
|
+
* 读写的就是 dock「📇 群组管理 → 🗂 分组」那份 host 数据({dataRoot}/.qqbot/target-groups.json),
|
|
1296
|
+
* 所以 AI 建/改的分组主人刷新面板就能看到; 反过来主人分的组, AI 直接拿来群发(targets 写分组名)。
|
|
1297
|
+
*/
|
|
1298
|
+
const targetGroupTool = defineTool({
|
|
1299
|
+
name: 'target_group',
|
|
1300
|
+
description: '目标分组管理(与 dock「📇 群组管理 → 🗂 分组」共用同一份数据, 改完主人刷新面板就能看到): 查看/新建/改名/删除分组, 或把目标加进/移出分组。建好后 broadcast_send 的 targets 直接写**分组名**即可群发。改动类动作仅主人明确要求时调用; 重名/找不到会明确报错。' +
|
|
1301
|
+
'\n最小示例: {action:"list"} · {action:"create", name:"开发群"} · {action:"add", name:"群友", targets:["群A"]} · {action:"remove", name:"群友", targets:["群B"]}',
|
|
1302
|
+
parameters: {
|
|
1303
|
+
action: { type: 'string', enum: ['list', 'create', 'rename', 'delete', 'add', 'remove'], required: true, description: 'list=查看全部分组 / create=新建 / rename=改名 / delete=删除 / add=把 targets 加进分组 / remove=把 targets 移出分组' },
|
|
1304
|
+
name: { type: 'string', description: '分组名(除 list 外必填)' },
|
|
1305
|
+
new_name: { type: 'string', description: 'rename 时的新分组名' },
|
|
1306
|
+
targets: { type: 'array', items: { type: 'string' }, description: 'add/remove 的目标(群名/群备注/完整 openid/分组名 均可, 写法同 broadcast_send)' },
|
|
1307
|
+
},
|
|
1308
|
+
output: {
|
|
1309
|
+
schema: {
|
|
1310
|
+
type: 'object', additionalProperties: false,
|
|
1311
|
+
properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
|
|
1312
|
+
},
|
|
1313
|
+
render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
|
|
1314
|
+
},
|
|
1315
|
+
async execute(args, exec) {
|
|
1316
|
+
const a = args;
|
|
1317
|
+
const ch = channelOf(exec);
|
|
1318
|
+
const root = ch?.manager?.dataRoot ?? managersOf()[0]?.dataRoot ?? managersOf()[0]?.cwd;
|
|
1319
|
+
if (!root)
|
|
1320
|
+
return { ok: false, msg: '找不到数据根(无已注册实例)' };
|
|
1321
|
+
const file = join(root, '.qqbot', 'target-groups.json');
|
|
1322
|
+
const read = () => {
|
|
1323
|
+
try {
|
|
1324
|
+
const o = JSON.parse(readFileSync(file, 'utf8'));
|
|
1325
|
+
return Array.isArray(o?.groups) ? o.groups : [];
|
|
1326
|
+
}
|
|
1327
|
+
catch {
|
|
1328
|
+
return [];
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
const save = (groups) => {
|
|
1332
|
+
try {
|
|
1333
|
+
mkdirSync(join(root, '.qqbot'), { recursive: true });
|
|
1334
|
+
writeFileSync(file, JSON.stringify({ groups }, null, 1), 'utf8');
|
|
1335
|
+
}
|
|
1336
|
+
catch { /* 落盘失败: 内存结果照常返回, 主人可重试 */ }
|
|
1337
|
+
};
|
|
1338
|
+
const fmt = (g) => {
|
|
1339
|
+
const ms = g.members ?? [];
|
|
1340
|
+
const tail = ms.slice(0, 6).map((m) => '…' + String(m).replace(/^[a-z]+:/i, '').slice(-4)).join(', ');
|
|
1341
|
+
return `· ${g.name}(${ms.length})${ms.length ? ' — ' + tail + (ms.length > 6 ? ' …' : '') : ''}`;
|
|
1342
|
+
};
|
|
1343
|
+
const action = String(a.action ?? 'list');
|
|
1344
|
+
const name = String(a.name ?? '').trim();
|
|
1345
|
+
let groups = read();
|
|
1346
|
+
if (action === 'list') {
|
|
1347
|
+
if (groups.length === 0)
|
|
1348
|
+
return { ok: true, msg: '当前没有任何分组(dock「📇 群组管理 → 🗂 分组」可新建, 也可以让我建)' };
|
|
1349
|
+
return { ok: true, msg: `分组 ${groups.length} 个:\n${groups.map(fmt).join('\n')}` };
|
|
1350
|
+
}
|
|
1351
|
+
if (name === '')
|
|
1352
|
+
return { ok: false, msg: `${action} 需要 name(分组名)` };
|
|
1353
|
+
const idx = groups.findIndex((g) => String(g.name ?? '') === name);
|
|
1354
|
+
if (action === 'create') {
|
|
1355
|
+
if (idx >= 0)
|
|
1356
|
+
return { ok: false, msg: `分组「${name}」已经存在了` };
|
|
1357
|
+
groups.push({ id: 'tg-' + Date.now().toString(36), name, members: [] });
|
|
1358
|
+
save(groups);
|
|
1359
|
+
return { ok: true, msg: `✅ 已新建分组「${name}」(空组) —— 可以用 action=add 加目标, 或让主人在面板勾选加入` };
|
|
1360
|
+
}
|
|
1361
|
+
if (idx < 0)
|
|
1362
|
+
return { ok: false, msg: `找不到分组「${name}」。现有: ${groups.map((g) => g.name).join('、') || '(无)'}` };
|
|
1363
|
+
if (action === 'rename') {
|
|
1364
|
+
const nn = String(a.new_name ?? '').trim();
|
|
1365
|
+
if (nn === '')
|
|
1366
|
+
return { ok: false, msg: 'rename 需要 new_name' };
|
|
1367
|
+
if (groups.some((g) => String(g.name ?? '') === nn))
|
|
1368
|
+
return { ok: false, msg: `已经有个分组叫「${nn}」了` };
|
|
1369
|
+
groups[idx].name = nn;
|
|
1370
|
+
save(groups);
|
|
1371
|
+
return { ok: true, msg: `✅ 分组「${name}」已改名为「${nn}」` };
|
|
1372
|
+
}
|
|
1373
|
+
if (action === 'delete') {
|
|
1374
|
+
const n = (groups[idx].members ?? []).length;
|
|
1375
|
+
groups = groups.filter((_, i) => i !== idx);
|
|
1376
|
+
save(groups);
|
|
1377
|
+
return { ok: true, msg: `✅ 已删除分组「${name}」(${n} 个成员; 只是取消分组, 不影响这些群/人本身)` };
|
|
1378
|
+
}
|
|
1379
|
+
if (action === 'add' || action === 'remove') {
|
|
1380
|
+
const rawTargets = Array.isArray(a.targets) ? a.targets.map((t) => String(t).trim()).filter(Boolean) : [];
|
|
1381
|
+
if (rawTargets.length === 0)
|
|
1382
|
+
return { ok: false, msg: `${action} 需要 targets(至少一个)` };
|
|
1383
|
+
const resolved = resolveBroadcastTargets(rawTargets, root);
|
|
1384
|
+
if (resolved.error)
|
|
1385
|
+
return { ok: false, msg: resolved.error };
|
|
1386
|
+
const cur = new Set(groups[idx].members ?? []);
|
|
1387
|
+
const before = cur.size;
|
|
1388
|
+
for (const t of resolved.targets) {
|
|
1389
|
+
const k = `${t.scope}:${t.peerId}`;
|
|
1390
|
+
if (action === 'add')
|
|
1391
|
+
cur.add(k);
|
|
1392
|
+
else
|
|
1393
|
+
cur.delete(k);
|
|
1394
|
+
}
|
|
1395
|
+
groups[idx].members = [...cur];
|
|
1396
|
+
save(groups);
|
|
1397
|
+
const delta = action === 'add' ? cur.size - before : before - cur.size;
|
|
1398
|
+
return {
|
|
1399
|
+
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')}`,
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
return { ok: false, msg: `未知 action: ${action}` };
|
|
1404
|
+
},
|
|
1405
|
+
});
|
|
1041
1406
|
/** 发到目标 QQ 群/私聊: 若给了 media 先发图(用目标 manager 归属的 sender), 再发带来源文本; 无 sender 则 groupAdmin 文本兜底 */
|
|
1042
1407
|
async function sendQQWithMedia(manager, scope, peer, body, media, exec) {
|
|
1043
1408
|
const ch = channelBridges.find((b) => b.manager === manager);
|
|
@@ -1089,11 +1454,16 @@ export async function apply(ctx) {
|
|
|
1089
1454
|
}
|
|
1090
1455
|
const sessionWakeTool = defineTool({
|
|
1091
1456
|
name: 'session_wake',
|
|
1092
|
-
description: '跨会话(
|
|
1457
|
+
description: '跨会话(写,谨慎,**仅主人明确要求时调用**): 向指定会话发一条消息。mode=wake(默认)唤醒对方 LLM; mode=append 只写上下文不唤醒(省 token 的喇叭模式)。同时经 QQ 通道把消息发到该会话绑定的群/私聊。目标用 session_id(见 session_list) 或 peer_id+scope。' +
|
|
1458
|
+
'\n最小示例(照抄改值):\n' +
|
|
1459
|
+
'· 唤醒单发 {session_id:"<uuid>", text:"帮我看下这个"}\n' +
|
|
1460
|
+
'· 跨群喇叭不唤醒 {peer_id:"<群openid>", scope:"group", text:"公告:…", mode:"append"}\n' +
|
|
1461
|
+
'· 批量(每项自带 text) {batch:[{peer_id:"<g1>",scope:"group",text:"A"},{peer_id:"<g2>",scope:"group",text:"B",media:"D:/pic.png"}]} ← 用 batch 时**不要再传顶层 text**',
|
|
1462
|
+
parameters: {
|
|
1093
1463
|
session_id: { type: 'string', description: '目标会话 id(完整 sessionId, 来自 session_list)' },
|
|
1094
1464
|
peer_id: { type: 'string', description: '或按 peer 寻址: 群 openid/私聊 openid(需带 scope)' },
|
|
1095
1465
|
scope: { type: 'string', enum: ['group', 'c2c'], description: 'peer_id 寻址时的范围(group=群 / c2c=私聊)' },
|
|
1096
|
-
text: { type: 'string',
|
|
1466
|
+
text: { type: 'string', description: '要发送的消息内容(单发必填; 用 batch 时**不要**传这个, 每项自带 text)' },
|
|
1097
1467
|
mode: { type: 'string', enum: ['wake', 'append'], description: 'wake=唤醒 LLM 开回合(默认); append=只追加上下文不唤醒(省 token, 喇叭模式)' },
|
|
1098
1468
|
send_qq: { type: 'boolean', description: '是否同时发到绑定的 QQ 群/私聊(给人看, 默认 true)。false=只处理 LLM 侧不走 QQ 通道' },
|
|
1099
1469
|
media: { type: 'string', description: '跨群发图: 图片本地路径或 http(s) URL, 随消息发到目标群(需 send_qq=true)' },
|
|
@@ -1145,7 +1515,7 @@ export async function apply(ctx) {
|
|
|
1145
1515
|
const sendOne = async (one) => {
|
|
1146
1516
|
const text = String(one.text || '');
|
|
1147
1517
|
if (!text)
|
|
1148
|
-
return '❌ 缺少 text';
|
|
1518
|
+
return '❌ 缺少 text —— 单发请传顶层 text; 用 batch 时**每项自带 text**(顶层 text 不必传, 传了也不算错)。最小示例见本工具描述';
|
|
1149
1519
|
const body = `【来自会话 ${from}】\n${text}${one.media ? `\n[附带图片: ${one.media}]` : ''}`;
|
|
1150
1520
|
const sid = String(one.session_id || '');
|
|
1151
1521
|
if (sid) {
|
|
@@ -1381,6 +1751,8 @@ export async function apply(ctx) {
|
|
|
1381
1751
|
{ name: 'group_mute_member', tool: muteMemberTool },
|
|
1382
1752
|
{ name: 'session_list', tool: sessionListTool },
|
|
1383
1753
|
{ name: 'session_wake', tool: sessionWakeTool },
|
|
1754
|
+
{ name: 'broadcast_send', tool: broadcastSendTool },
|
|
1755
|
+
{ name: 'target_group', tool: targetGroupTool },
|
|
1384
1756
|
{ name: 'list_stickers', tool: listStickersTool },
|
|
1385
1757
|
{ name: 'sticker_tag', tool: tagStickerTool },
|
|
1386
1758
|
{ name: 'sticker_delete', tool: deleteStickerTool },
|