@zaofan/dsh-qqbot 0.6.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/README_EN.md +273 -0
  4. package/client/qqbot-settings.js +1463 -0
  5. package/cordis.patch.yml +36 -0
  6. package/dist/api/group-admin.d.ts +112 -0
  7. package/dist/api/group-admin.d.ts.map +1 -0
  8. package/dist/api/group-admin.js +179 -0
  9. package/dist/api/group-admin.js.map +1 -0
  10. package/dist/channel-tools.d.ts +14 -0
  11. package/dist/channel-tools.d.ts.map +1 -0
  12. package/dist/channel-tools.js +880 -0
  13. package/dist/channel-tools.js.map +1 -0
  14. package/dist/commands/help.d.ts +11 -0
  15. package/dist/commands/help.d.ts.map +1 -0
  16. package/dist/commands/help.js +21 -0
  17. package/dist/commands/help.js.map +1 -0
  18. package/dist/commands/index.d.ts +13 -0
  19. package/dist/commands/index.d.ts.map +1 -0
  20. package/dist/commands/index.js +29 -0
  21. package/dist/commands/index.js.map +1 -0
  22. package/dist/commands/misc.d.ts +14 -0
  23. package/dist/commands/misc.d.ts.map +1 -0
  24. package/dist/commands/misc.js +39 -0
  25. package/dist/commands/misc.js.map +1 -0
  26. package/dist/commands/model.d.ts +7 -0
  27. package/dist/commands/model.d.ts.map +1 -0
  28. package/dist/commands/model.js +58 -0
  29. package/dist/commands/model.js.map +1 -0
  30. package/dist/commands/session.d.ts +10 -0
  31. package/dist/commands/session.d.ts.map +1 -0
  32. package/dist/commands/session.js +26 -0
  33. package/dist/commands/session.js.map +1 -0
  34. package/dist/commands/status.d.ts +8 -0
  35. package/dist/commands/status.d.ts.map +1 -0
  36. package/dist/commands/status.js +24 -0
  37. package/dist/commands/status.js.map +1 -0
  38. package/dist/commands/types.d.ts +12 -0
  39. package/dist/commands/types.d.ts.map +1 -0
  40. package/dist/commands/types.js +2 -0
  41. package/dist/commands/types.js.map +1 -0
  42. package/dist/config.d.ts +205 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +203 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/features/chat-ledger.d.ts +53 -0
  47. package/dist/features/chat-ledger.d.ts.map +1 -0
  48. package/dist/features/chat-ledger.js +185 -0
  49. package/dist/features/chat-ledger.js.map +1 -0
  50. package/dist/features/face-tags.d.ts +17 -0
  51. package/dist/features/face-tags.d.ts.map +1 -0
  52. package/dist/features/face-tags.js +79 -0
  53. package/dist/features/face-tags.js.map +1 -0
  54. package/dist/features/group-join-request.d.ts +65 -0
  55. package/dist/features/group-join-request.d.ts.map +1 -0
  56. package/dist/features/group-join-request.js +190 -0
  57. package/dist/features/group-join-request.js.map +1 -0
  58. package/dist/features/history-store.d.ts +8 -0
  59. package/dist/features/history-store.d.ts.map +1 -0
  60. package/dist/features/history-store.js +24 -0
  61. package/dist/features/history-store.js.map +1 -0
  62. package/dist/features/qq-approval.d.ts +78 -0
  63. package/dist/features/qq-approval.d.ts.map +1 -0
  64. package/dist/features/qq-approval.js +165 -0
  65. package/dist/features/qq-approval.js.map +1 -0
  66. package/dist/features/schedule-store.d.ts +62 -0
  67. package/dist/features/schedule-store.d.ts.map +1 -0
  68. package/dist/features/schedule-store.js +253 -0
  69. package/dist/features/schedule-store.js.map +1 -0
  70. package/dist/features/scheduler.d.ts +9 -0
  71. package/dist/features/scheduler.d.ts.map +1 -0
  72. package/dist/features/scheduler.js +146 -0
  73. package/dist/features/scheduler.js.map +1 -0
  74. package/dist/features/sticker-gate.d.ts +90 -0
  75. package/dist/features/sticker-gate.d.ts.map +1 -0
  76. package/dist/features/sticker-gate.js +391 -0
  77. package/dist/features/sticker-gate.js.map +1 -0
  78. package/dist/features/sticker-store.d.ts +158 -0
  79. package/dist/features/sticker-store.d.ts.map +1 -0
  80. package/dist/features/sticker-store.js +676 -0
  81. package/dist/features/sticker-store.js.map +1 -0
  82. package/dist/features/sticker-tagger.d.ts +10 -0
  83. package/dist/features/sticker-tagger.d.ts.map +1 -0
  84. package/dist/features/sticker-tagger.js +139 -0
  85. package/dist/features/sticker-tagger.js.map +1 -0
  86. package/dist/gateway/bootstrap.d.ts +12 -0
  87. package/dist/gateway/bootstrap.d.ts.map +1 -0
  88. package/dist/gateway/bootstrap.js +346 -0
  89. package/dist/gateway/bootstrap.js.map +1 -0
  90. package/dist/gateway/debounce.d.ts +40 -0
  91. package/dist/gateway/debounce.d.ts.map +1 -0
  92. package/dist/gateway/debounce.js +262 -0
  93. package/dist/gateway/debounce.js.map +1 -0
  94. package/dist/gateway/index.d.ts +3 -0
  95. package/dist/gateway/index.d.ts.map +1 -0
  96. package/dist/gateway/index.js +3 -0
  97. package/dist/gateway/index.js.map +1 -0
  98. package/dist/gateway/middleware-setup.d.ts +12 -0
  99. package/dist/gateway/middleware-setup.d.ts.map +1 -0
  100. package/dist/gateway/middleware-setup.js +156 -0
  101. package/dist/gateway/middleware-setup.js.map +1 -0
  102. package/dist/index.d.ts +14 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +187 -0
  105. package/dist/index.js.map +1 -0
  106. package/dist/middleware/attachment.d.ts +11 -0
  107. package/dist/middleware/attachment.d.ts.map +1 -0
  108. package/dist/middleware/attachment.js +17 -0
  109. package/dist/middleware/attachment.js.map +1 -0
  110. package/dist/middleware/media-history.d.ts +30 -0
  111. package/dist/middleware/media-history.d.ts.map +1 -0
  112. package/dist/middleware/media-history.js +54 -0
  113. package/dist/middleware/media-history.js.map +1 -0
  114. package/dist/middleware/sticker-capture.d.ts +17 -0
  115. package/dist/middleware/sticker-capture.d.ts.map +1 -0
  116. package/dist/middleware/sticker-capture.js +97 -0
  117. package/dist/middleware/sticker-capture.js.map +1 -0
  118. package/dist/model/index.d.ts +10 -0
  119. package/dist/model/index.d.ts.map +1 -0
  120. package/dist/model/index.js +9 -0
  121. package/dist/model/index.js.map +1 -0
  122. package/dist/model/model-resolver.d.ts +88 -0
  123. package/dist/model/model-resolver.d.ts.map +1 -0
  124. package/dist/model/model-resolver.js +151 -0
  125. package/dist/model/model-resolver.js.map +1 -0
  126. package/dist/model/prefs-store.d.ts +24 -0
  127. package/dist/model/prefs-store.d.ts.map +1 -0
  128. package/dist/model/prefs-store.js +126 -0
  129. package/dist/model/prefs-store.js.map +1 -0
  130. package/dist/model/settings-reader.d.ts +19 -0
  131. package/dist/model/settings-reader.d.ts.map +1 -0
  132. package/dist/model/settings-reader.js +76 -0
  133. package/dist/model/settings-reader.js.map +1 -0
  134. package/dist/model/types.d.ts +15 -0
  135. package/dist/model/types.d.ts.map +1 -0
  136. package/dist/model/types.js +5 -0
  137. package/dist/model/types.js.map +1 -0
  138. package/dist/session/idle-evictor.d.ts +18 -0
  139. package/dist/session/idle-evictor.d.ts.map +1 -0
  140. package/dist/session/idle-evictor.js +31 -0
  141. package/dist/session/idle-evictor.js.map +1 -0
  142. package/dist/session/index.d.ts +9 -0
  143. package/dist/session/index.d.ts.map +1 -0
  144. package/dist/session/index.js +8 -0
  145. package/dist/session/index.js.map +1 -0
  146. package/dist/session/session-manager.d.ts +97 -0
  147. package/dist/session/session-manager.d.ts.map +1 -0
  148. package/dist/session/session-manager.js +624 -0
  149. package/dist/session/session-manager.js.map +1 -0
  150. package/dist/session/types.d.ts +130 -0
  151. package/dist/session/types.d.ts.map +1 -0
  152. package/dist/session/types.js +2 -0
  153. package/dist/session/types.js.map +1 -0
  154. package/dist/session/workspace-attach.d.ts +22 -0
  155. package/dist/session/workspace-attach.d.ts.map +1 -0
  156. package/dist/session/workspace-attach.js +21 -0
  157. package/dist/session/workspace-attach.js.map +1 -0
  158. package/dist/setup.d.ts +25 -0
  159. package/dist/setup.d.ts.map +1 -0
  160. package/dist/setup.js +198 -0
  161. package/dist/setup.js.map +1 -0
  162. package/dist/shared/index.d.ts +7 -0
  163. package/dist/shared/index.d.ts.map +1 -0
  164. package/dist/shared/index.js +7 -0
  165. package/dist/shared/index.js.map +1 -0
  166. package/dist/shared/scope.d.ts +14 -0
  167. package/dist/shared/scope.d.ts.map +1 -0
  168. package/dist/shared/scope.js +12 -0
  169. package/dist/shared/scope.js.map +1 -0
  170. package/dist/shared/send-helper.d.ts +11 -0
  171. package/dist/shared/send-helper.d.ts.map +1 -0
  172. package/dist/shared/send-helper.js +13 -0
  173. package/dist/shared/send-helper.js.map +1 -0
  174. package/dist/shared/utils.d.ts +28 -0
  175. package/dist/shared/utils.d.ts.map +1 -0
  176. package/dist/shared/utils.js +143 -0
  177. package/dist/shared/utils.js.map +1 -0
  178. package/dist/transport/attachment.d.ts +18 -0
  179. package/dist/transport/attachment.d.ts.map +1 -0
  180. package/dist/transport/attachment.js +106 -0
  181. package/dist/transport/attachment.js.map +1 -0
  182. package/dist/transport/chunker.d.ts +14 -0
  183. package/dist/transport/chunker.d.ts.map +1 -0
  184. package/dist/transport/chunker.js +78 -0
  185. package/dist/transport/chunker.js.map +1 -0
  186. package/dist/transport/events.d.ts +80 -0
  187. package/dist/transport/events.d.ts.map +1 -0
  188. package/dist/transport/events.js +62 -0
  189. package/dist/transport/events.js.map +1 -0
  190. package/dist/transport/inbound.d.ts +8 -0
  191. package/dist/transport/inbound.d.ts.map +1 -0
  192. package/dist/transport/inbound.js +292 -0
  193. package/dist/transport/inbound.js.map +1 -0
  194. package/dist/transport/index.d.ts +10 -0
  195. package/dist/transport/index.d.ts.map +1 -0
  196. package/dist/transport/index.js +10 -0
  197. package/dist/transport/index.js.map +1 -0
  198. package/dist/transport/inject-rules.d.ts +24 -0
  199. package/dist/transport/inject-rules.d.ts.map +1 -0
  200. package/dist/transport/inject-rules.js +72 -0
  201. package/dist/transport/inject-rules.js.map +1 -0
  202. package/dist/transport/outbound-buffer.d.ts +56 -0
  203. package/dist/transport/outbound-buffer.d.ts.map +1 -0
  204. package/dist/transport/outbound-buffer.js +131 -0
  205. package/dist/transport/outbound-buffer.js.map +1 -0
  206. package/dist/transport/outbound.d.ts +30 -0
  207. package/dist/transport/outbound.d.ts.map +1 -0
  208. package/dist/transport/outbound.js +139 -0
  209. package/dist/transport/outbound.js.map +1 -0
  210. package/dist/transport/rich-media.d.ts +41 -0
  211. package/dist/transport/rich-media.d.ts.map +1 -0
  212. package/dist/transport/rich-media.js +104 -0
  213. package/dist/transport/rich-media.js.map +1 -0
  214. package/dist/transport/streaming-writer.d.ts +47 -0
  215. package/dist/transport/streaming-writer.d.ts.map +1 -0
  216. package/dist/transport/streaming-writer.js +110 -0
  217. package/dist/transport/streaming-writer.js.map +1 -0
  218. package/dist/transport/tool-presenter.d.ts +38 -0
  219. package/dist/transport/tool-presenter.d.ts.map +1 -0
  220. package/dist/transport/tool-presenter.js +121 -0
  221. package/dist/transport/tool-presenter.js.map +1 -0
  222. package/dist/types.d.ts +45 -0
  223. package/dist/types.d.ts.map +1 -0
  224. package/dist/types.js +5 -0
  225. package/dist/types.js.map +1 -0
  226. package/package.json +106 -0
  227. package/settings-host.js +927 -0
@@ -0,0 +1,880 @@
1
+ /**
2
+ * channel-tools.ts — QQ 通道工具插件(send_media / recall_message)
3
+ *
4
+ * 标准 dsh-tool 插件:由 dsh-qqbot 在 QQ 会话的 setup 事务内(agentCtx)装载,
5
+ * 因此只对 QQ 会话可见;任意 preset 走 QQ 都会带上;web 会话不经过 setup → 没有。
6
+ *
7
+ * 铁律:@deepseek-ai/dsh-tools 只经 peerDependencies 由宿主解析,本文件不得被
8
+ * 复制进共享 node_modules,也不得在 dependencies 里自带 dsh-tools(避免双包)。
9
+ */
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { appendFileSync, existsSync } from 'node:fs';
12
+ import { getStickerStore } from './features/sticker-store.js';
13
+ import { autoTagImage } from './features/sticker-tagger.js';
14
+ import { isStickerGateDenied } from './features/sticker-gate.js';
15
+ import { getScheduleStore } from './features/schedule-store.js';
16
+ /** 诊断日志路径: 默认关闭; 需要排查时设环境变量 QQBOT_DIAG_FILE 指向日志文件 */
17
+ const DIAG_FILE = process.env.QQBOT_DIAG_FILE || '';
18
+ function diag(line) {
19
+ if (!DIAG_FILE)
20
+ return;
21
+ try {
22
+ appendFileSync(DIAG_FILE, `${new Date().toISOString()} ${line}\n`);
23
+ }
24
+ catch { /* ignore */ }
25
+ }
26
+ export const name = 'qqbot-channel-tools';
27
+ export const inject = ['tools'];
28
+ /**
29
+ * 运行时按当前执行 agent 解析 qqChannel service。
30
+ * 解析顺序: ①exec.agent.ctx.get('qqChannel')(agent ctx, setup 时 provide);
31
+ * ②全局桥 channelBridge(bootstrap 直接注入 manager+sender)——不依赖 setup/provide,
32
+ * 防止 setup 未执行/竞态导致"不是 QQ 会话"误判(线上踩坑: 重启后 setup 未跑到)。
33
+ */
34
+ let channelBridges = [];
35
+ export function setChannelBridge(b) {
36
+ if (!b)
37
+ return;
38
+ const i = channelBridges.findIndex((x) => x.manager === b.manager);
39
+ if (i >= 0)
40
+ channelBridges[i] = b;
41
+ else
42
+ channelBridges.push(b);
43
+ }
44
+ function channelOf(exec) {
45
+ try {
46
+ const agent = exec.agent;
47
+ const ctx = agent?.ctx;
48
+ if (ctx) {
49
+ const ch = ctx.get('qqChannel');
50
+ if (ch)
51
+ return ch;
52
+ diag('channelOf: agent.ctx 无 qqChannel, 尝试全局桥');
53
+ }
54
+ else {
55
+ diag(`channelOf: 无 agent.ctx (agent=${typeof agent}${agent ? ` keys=${Object.keys(agent).slice(0, 8).join(',')}` : ''})`);
56
+ }
57
+ }
58
+ catch (e) {
59
+ diag(`channelOf: ctx.get 异常 ${e instanceof Error ? e.message : String(e)}, 尝试全局桥`);
60
+ }
61
+ if (channelBridges.length) {
62
+ diag('channelOf: 走全局桥成功');
63
+ return channelBridges[0];
64
+ }
65
+ return undefined;
66
+ }
67
+ /**
68
+ * 解析"当前执行 agent → 会话 + 通道"(多账号/热更新后 agent 引用可能跨实例或换新对象):
69
+ * ① ctx 通道的 manager 按引用/id 找(findByAgent 已含 id 兜底); ② 找不到→遍历所有实例桥再找。
70
+ */
71
+ function findSessionRec(ch, exec) {
72
+ const tryOne = (c) => {
73
+ if (!c?.manager || !exec.agent)
74
+ return undefined;
75
+ const rec = c.manager.findByAgent(exec.agent);
76
+ return rec ? { ch: c, rec } : undefined;
77
+ };
78
+ if (ch) {
79
+ const r = tryOne(ch);
80
+ if (r)
81
+ return r;
82
+ }
83
+ for (const b of channelBridges) {
84
+ if (b === ch)
85
+ continue;
86
+ const r = tryOne(b);
87
+ if (r)
88
+ return r;
89
+ }
90
+ return undefined;
91
+ }
92
+ /**
93
+ * 按当前执行 agent 会话归属的账号实例取图库/定时 store(多账号: 各实例 cwd 各库)。
94
+ * 解析优先级: ① findSessionRec(undefined, exec) —— 遍历全部实例桥、按 exec.agent 精确匹配 record
95
+ * (不会张冠李戴; 旧代码 findSessionRec(channelOf(exec),…) 在 agent 未命中时会错落 primary);
96
+ * ② channelOf(exec) —— agent.ctx 的 qqChannel(本实例通道), 退而求其次;
97
+ * ③ 回退 primary 单例库。
98
+ */
99
+ function stickerStoreOf(exec) {
100
+ try {
101
+ const s = findSessionRec(undefined, exec);
102
+ if (s) {
103
+ diag(`stickerStoreOf: 按 agent 命中实例 dir=${s.ch.manager.stickerDataDir}`);
104
+ return getStickerStore(s.ch.manager.stickerDataDir);
105
+ }
106
+ const ch = channelOf(exec);
107
+ if (ch) {
108
+ diag(`stickerStoreOf: 走 agent.ctx 通道 dir=${ch.manager.stickerDataDir}`);
109
+ return getStickerStore(ch.manager.stickerDataDir);
110
+ }
111
+ }
112
+ catch (e) {
113
+ diag(`stickerStoreOf: 解析异常 ${e instanceof Error ? e.message : String(e)}`);
114
+ }
115
+ diag('stickerStoreOf: 回退 primary');
116
+ return getStickerStore();
117
+ }
118
+ /** 装载本插件时把 qqChannel 一并注入(由 dsh-qqbot setup 提供) */
119
+ export function apply(ctx) {
120
+ const toolsAny = ctx.tools;
121
+ diag(`apply 调用: tools=${typeof toolsAny} register=${typeof toolsAny?.register} ctxKeys=${Object.keys(ctx).slice(0, 12).join(',')}`);
122
+ const sendMediaTool = defineTool({
123
+ name: 'send_media',
124
+ description: '在当前 QQ 会话给对方发送富媒体(图片/语音/视频/文件)。kind=image|voice|video|file;source=本地文件绝对路径 或 http(s) 网址。',
125
+ parameters: {
126
+ kind: { type: 'string', required: true, enum: ['image', 'voice', 'video', 'file'], description: '媒体类型' },
127
+ source: { type: 'string', required: true, description: '本地绝对路径或 http(s) 网址' },
128
+ },
129
+ output: {
130
+ schema: {
131
+ type: 'object', additionalProperties: false,
132
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
133
+ },
134
+ render: (_a, v) => [
135
+ { type: 'text', text: v.ok ? '已发送' : `发送失败: ${v.msg}` },
136
+ ],
137
+ },
138
+ async execute(args, exec) {
139
+ const _sess = findSessionRec(channelOf(exec), exec);
140
+ if (!_sess)
141
+ return { ok: false, msg: '未找到当前 QQ 会话(通道未就绪), 请稍后重试' };
142
+ const ch = _sess.ch;
143
+ const rec = _sess.rec;
144
+ try {
145
+ const srcOpt = /^https?:\/\//i.test(args.source)
146
+ ? { url: args.source }
147
+ : { localPath: args.source };
148
+ const r = await ch.sender.sendMedia(rec.replyTarget, args.kind, srcOpt);
149
+ // 发送成功且是图库里的本地图 → 标记"用过"(升正式区, 不入滚动清理)
150
+ if (r?.id && srcOpt.localPath) {
151
+ try {
152
+ const store = getStickerStore(ch.manager.stickerDataDir);
153
+ const sid = lookupId(store, srcOpt.localPath);
154
+ if (sid)
155
+ store.markUsed(sid);
156
+ }
157
+ catch { /* 统计失败不影响发送 */ }
158
+ }
159
+ return { ok: true, msg: r?.id ? String(r.id) : '' };
160
+ }
161
+ catch (e) {
162
+ // 表情包闸门拒绝: 明确告知"不要重试", 避免 LLM 换图/反复尝试刷闸
163
+ if (isStickerGateDenied(e)) {
164
+ return { ok: false, msg: `表情包闸门拦截, 本轮不再尝试发图: ${e.message.replace(/^\[sticker-gate\]\s*/, '')}` };
165
+ }
166
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
167
+ }
168
+ },
169
+ });
170
+ const recallTool = defineTool({
171
+ name: 'recall_message',
172
+ description: '撤回本机器人最近发给当前 QQ 会话的消息。index 缺省 1(=最近一条),2=倒数第 2 条,以此类推。',
173
+ parameters: {
174
+ index: { type: 'integer', description: '要撤的倒数第几条(默认1)' },
175
+ },
176
+ output: {
177
+ schema: {
178
+ type: 'object', additionalProperties: false,
179
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
180
+ },
181
+ render: (_a, v) => [
182
+ { type: 'text', text: v.ok ? '已撤回' : `撤回失败: ${v.msg}` },
183
+ ],
184
+ },
185
+ async execute(args, exec) {
186
+ const _sess = findSessionRec(channelOf(exec), exec);
187
+ if (!_sess)
188
+ return { ok: false, msg: '未找到当前 QQ 会话(通道未就绪), 请稍后重试' };
189
+ const ch = _sess.ch;
190
+ const rec = _sess.rec;
191
+ const rawIdx = args.index;
192
+ const idx = rawIdx == null || !Number.isFinite(rawIdx) ? 1 : rawIdx;
193
+ try {
194
+ const ok = await ch.sender.recallByIndex(rec.replyTarget, idx);
195
+ return ok ? { ok: true, msg: '' } : { ok: false, msg: `第${idx}条无可撤回消息或已超时` };
196
+ }
197
+ catch (e) {
198
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
199
+ }
200
+ },
201
+ });
202
+ const listStickersTool = defineTool({
203
+ name: 'list_stickers',
204
+ description: '(只读,不发图)查本地表情包库,返回图片本地绝对路径+标签+详细描述。当你被@且语境适合回一张表情包/梗图(对方发图想接梗、自嘲、庆祝、吐槽)时,先调用它搜库;每张的"描述"会说明画面和适合的场合,据此挑最贴切的一张,再用 send_media(kind=image,source=该本地路径) 发 0~1 张。纪律:无强语境或拿不准→不查也不发;描述与想表达不合→只发文字。query 可为空(返回最近收藏)。',
205
+ parameters: {
206
+ query: { type: 'string', description: '语义关键词(可空=最近收藏; 会匹配标签和描述)' },
207
+ tag: { type: 'string', description: '标签过滤(可空)' },
208
+ limit: { type: 'integer', description: '返回条数上限 1~20,默认5' },
209
+ },
210
+ output: {
211
+ schema: {
212
+ type: 'object', additionalProperties: false,
213
+ properties: {
214
+ ok: { type: 'boolean', required: true },
215
+ msg: { type: 'string', required: true },
216
+ items: {
217
+ type: 'array', required: true,
218
+ items: {
219
+ type: 'object', additionalProperties: false,
220
+ properties: {
221
+ path: { type: 'string', required: true },
222
+ tags: { type: 'array', required: true, items: { type: 'string' } },
223
+ desc: { type: 'string', required: true },
224
+ used: { type: 'integer', required: true },
225
+ formal: { type: 'boolean', required: true },
226
+ src: { type: 'string', required: true },
227
+ },
228
+ },
229
+ },
230
+ },
231
+ },
232
+ render: (_a, v) => {
233
+ if (!v.ok)
234
+ return [{ type: 'text', text: `查询失败: ${v.msg}` }];
235
+ if (v.items.length === 0) {
236
+ return [{ type: 'text', text: '表情包库中无匹配,不建议发图,只回文字即可' }];
237
+ }
238
+ return v.items.map((it, i) => ({
239
+ type: 'text',
240
+ text: `${i + 1}. [${it.tags.length > 0 ? it.tags.join('/') : '未打标'}] 用过${it.used}次${it.desc ? `\n 描述: ${it.desc.slice(0, 120)}` : ''}\n → ${it.path}`,
241
+ }));
242
+ },
243
+ },
244
+ async execute(args, exec) {
245
+ try {
246
+ const store = stickerStoreOf(exec);
247
+ const limitRaw = args.limit;
248
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, Math.floor(limitRaw ?? 5))) : 5;
249
+ const hits = store.search({
250
+ q: args.query ?? '',
251
+ tag: args.tag ?? '',
252
+ limit,
253
+ });
254
+ return {
255
+ ok: true,
256
+ msg: '',
257
+ items: hits.map(h => ({
258
+ path: h.path,
259
+ tags: h.tags,
260
+ desc: h.desc ?? '',
261
+ used: h.useCount,
262
+ formal: h.layer === 'library',
263
+ src: h.sourceGroup ?? '',
264
+ })),
265
+ };
266
+ }
267
+ catch (e) {
268
+ return { ok: false, msg: e instanceof Error ? e.message : String(e), items: [] };
269
+ }
270
+ },
271
+ });
272
+ /** 按 path / id 前缀 / 来源URL 定位条目(list_stickers 的 path、或群消息里那张图的 URL 都行) */
273
+ const lookupId = (store, ref) => {
274
+ const r = (ref ?? '').trim();
275
+ if (!r)
276
+ return undefined;
277
+ const direct = store.get(r);
278
+ if (direct)
279
+ return direct.id;
280
+ if (/^https?:\/\//i.test(r)) {
281
+ const byUrl = store.findBySourceUrl(r);
282
+ if (byUrl)
283
+ return byUrl.id;
284
+ }
285
+ if (r.includes('\\') || r.includes('/')) {
286
+ const hit = store.search({ q: '', limit: 200, includeTrash: true }).find((h) => h.path === r);
287
+ if (hit)
288
+ return hit.id;
289
+ }
290
+ for (const it of store.search({ q: '', limit: 200, includeTrash: true })) {
291
+ if (it.id.startsWith(r))
292
+ return it.id;
293
+ }
294
+ return undefined;
295
+ };
296
+ const tagStickerTool = defineTool({
297
+ name: 'sticker_tag',
298
+ description: '给表情包打标/收藏一条龙。sticker=要处理的图:群消息里那张图的图片URL(自动匹配库里),或本地绝对路径,或list_stickers返回的路径。图已在库→直接更新它的标签/描述;图不在库→若vision=true(默认)会先自动识图生成草稿并收藏入库,然后应用你给的tags/desc(给了就覆盖草稿)。tags=逗号分隔中文短标签(内容/情绪/用途);desc=一句中文描述(画面+情绪+适合场合)。想跳过识图(省视觉额度)可传vision=false,只在图已在库时打标。',
299
+ parameters: {
300
+ sticker: { type: 'string', required: true, description: '图片URL 或 本地绝对路径(也可传 list_stickers 返回的路径)' },
301
+ tags: { type: 'string', description: '逗号分隔中文标签,如 开心,元气,打招呼' },
302
+ desc: { type: 'string', description: '一句中文详细描述(画面+情绪+适合场合)' },
303
+ vision: { type: 'boolean', description: '图不在库时是否先识图生成草稿,默认true' },
304
+ },
305
+ output: {
306
+ schema: {
307
+ type: 'object', additionalProperties: false,
308
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
309
+ },
310
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
311
+ },
312
+ async execute(args, exec) {
313
+ try {
314
+ const a = args;
315
+ const store = stickerStoreOf(exec);
316
+ const sticker = (a.sticker ?? '').trim();
317
+ if (!sticker)
318
+ return { ok: false, msg: 'sticker 不能为空' };
319
+ const userTags = (a.tags ?? '').split(/[,,\s]+/).map(s => s.trim()).filter(Boolean);
320
+ const userDesc = (a.desc ?? '').trim() || undefined;
321
+ if (!userTags.length && !userDesc && a.vision === false) {
322
+ return { ok: false, msg: 'tags/desc 至少给一个(或允许 vision 识图生成草稿)' };
323
+ }
324
+ let id = lookupId(store, sticker);
325
+ let created = false;
326
+ if (!id) {
327
+ if (/^https?:\/\//i.test(sticker)) {
328
+ const r = await store.capture(sticker, { sourceUrl: sticker }, undefined);
329
+ if (r.status === 'error')
330
+ return { ok: false, msg: `下载收藏失败: ${r.error}` };
331
+ id = r.id;
332
+ created = r.status === 'new';
333
+ }
334
+ else if (existsSync(sticker)) {
335
+ const r = store.importLocalFile(sticker);
336
+ if (r.status === 'error')
337
+ return { ok: false, msg: `导入失败: ${r.error}` };
338
+ id = r.id;
339
+ created = r.status === 'new';
340
+ }
341
+ else {
342
+ return { ok: false, msg: '找不到这张图(图不在库里,且给的不是有效URL/路径)' };
343
+ }
344
+ }
345
+ const userHas = userTags.length > 0 || !!userDesc;
346
+ if (!created) {
347
+ // 已在库: 给了内容→部分更新; 没给内容→不覆盖现有, 仅当该图本来空(待整理)才识图补
348
+ const meta = store.get(id);
349
+ if (userHas) {
350
+ store.setDescTags(id, userTags.length ? userTags : undefined, userDesc);
351
+ store.promote(id); // 主动打标=认可这张图 → 升正式区, 不入自动滚动清理
352
+ return { ok: true, msg: '已更新标签/描述 ✓(并标记为收藏保留)' };
353
+ }
354
+ if (meta?.needsDescribe && a.vision !== false) {
355
+ try {
356
+ const p = store.pathOf(id);
357
+ if (p) {
358
+ const draft = await autoTagImage(p, '');
359
+ if (draft) {
360
+ store.setDescTags(id, draft.tags, draft.desc);
361
+ return { ok: true, msg: `该图原无标签/描述,已识图补标: [${draft.tags.join('/')}]` };
362
+ }
363
+ }
364
+ }
365
+ catch { /* 草稿失败则提示用户手填 */ }
366
+ return { ok: true, msg: '该图原无标签/描述,识图失败,请传 tags/desc 手动补' };
367
+ }
368
+ const cur = store.get(id);
369
+ return {
370
+ ok: true,
371
+ msg: `该图已在库里(标签: ${(cur?.tags ?? []).join('/') || '无'}${cur?.desc ? `; 描述: ${cur.desc.slice(0, 60)}` : ''})。想改请传 tags/desc`,
372
+ };
373
+ }
374
+ // 新收藏: 识图草稿(默认) + 用户内容覆盖
375
+ let t = userTags.length ? userTags : undefined;
376
+ let d = userDesc;
377
+ const needDraft = !t && !d;
378
+ if (needDraft && a.vision !== false) {
379
+ try {
380
+ const p = store.pathOf(id);
381
+ if (p) {
382
+ const draft = await autoTagImage(p, '');
383
+ if (draft) {
384
+ t = draft.tags;
385
+ d = draft.desc;
386
+ }
387
+ }
388
+ }
389
+ catch { /* 草稿失败则留待整理 */ }
390
+ }
391
+ if (t || d)
392
+ store.setDescTags(id, t, d);
393
+ store.promote(id); // 主动收藏 → 正式区(不参与自动滚动清理)
394
+ const tagsShown = (t ?? []).join('/') || '无';
395
+ return { ok: true, msg: `已收藏并打标 [${tagsShown}]` };
396
+ }
397
+ catch (e) {
398
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
399
+ }
400
+ },
401
+ });
402
+ const deleteStickerTool = defineTool({
403
+ name: 'sticker_delete',
404
+ description: '把表情包库里的某张图送进回收站(30天后自动清,期间可用 sticker_restore 找回)。当这张图不合适/不想要/发出去被嫌弃时用。sticker=那张图的本地路径(来自 list_stickers)或群图URL。',
405
+ parameters: {
406
+ sticker: { type: 'string', required: true, description: '要操作的图: list_stickers 查到的本地路径 / 或当时群消息里那张图的图片URL' },
407
+ },
408
+ output: {
409
+ schema: {
410
+ type: 'object', additionalProperties: false,
411
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
412
+ },
413
+ render: (_a, v) => [{ type: 'text', text: v.ok ? '已送回收站(30天内可找回)' : `失败: ${v.msg}` }],
414
+ },
415
+ async execute(args, exec) {
416
+ try {
417
+ const store = stickerStoreOf(exec);
418
+ const id = lookupId(store, args.sticker ?? '');
419
+ if (!id)
420
+ return { ok: false, msg: '找不到这张图(请先用 list_stickers 拿到准确 path)' };
421
+ store.markTrash(id);
422
+ return { ok: true, msg: '' };
423
+ }
424
+ catch (e) {
425
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
426
+ }
427
+ },
428
+ });
429
+ const restoreStickerTool = defineTool({
430
+ name: 'sticker_restore',
431
+ description: '从回收站找回一张图(取消删除)。sticker=回收站里那张图的路径或URL。',
432
+ parameters: {
433
+ sticker: { type: 'string', required: true, description: '回收站中那张图的路径或URL' },
434
+ },
435
+ output: {
436
+ schema: {
437
+ type: 'object', additionalProperties: false,
438
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
439
+ },
440
+ render: (_a, v) => [{ type: 'text', text: v.ok ? '已找回 ✓' : `失败: ${v.msg}` }],
441
+ },
442
+ async execute(args, exec) {
443
+ try {
444
+ const store = stickerStoreOf(exec);
445
+ const id = lookupId(store, args.sticker ?? '');
446
+ if (!id)
447
+ return { ok: false, msg: '找不到(请确认在回收站且路径准确)' };
448
+ store.restore(id);
449
+ return { ok: true, msg: '' };
450
+ }
451
+ catch (e) {
452
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
453
+ }
454
+ },
455
+ });
456
+ const statsStickerTool = defineTool({
457
+ name: 'sticker_stats',
458
+ description: '查看表情包库概况(共多少张/各分区数量/没打标的/占多少空间),需要了解库存或整理时用。',
459
+ parameters: {},
460
+ output: {
461
+ schema: {
462
+ type: 'object', additionalProperties: false,
463
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
464
+ },
465
+ render: (_a, v) => [{ type: 'text', text: v.msg }],
466
+ },
467
+ async execute(_args, exec) {
468
+ try {
469
+ const s = stickerStoreOf(exec).stats();
470
+ const mb = (s.totalBytes / 1024 / 1024).toFixed(1);
471
+ return {
472
+ ok: true,
473
+ msg: `表情包库: 共${s.total}张(候选${s.candidate}/正式${s.library}/回收站${s.trash}/负${s.negative}), 未打标${s.untagged}张, 占用${mb}MB`,
474
+ };
475
+ }
476
+ catch (e) {
477
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
478
+ }
479
+ },
480
+ });
481
+ const untaggedStickerTool = defineTool({
482
+ name: 'sticker_untagged',
483
+ description: '查看表情包里"还没整理完"的图(缺标签或缺介绍)。参数 a: 1=全部(候选+收藏), 2=只看收藏(正式文件夹)。可选 limit(每批条数, 默认20, 上限100)、offset(跳过前N条, 翻页用)。返回清单, 供你挑几张用 sticker_tag 补标签/写介绍, 把库整理干净。',
484
+ parameters: {
485
+ a: { type: 'integer', enum: [1, 2], required: true, description: '1=查全部 2=只查收藏文件夹(正式)' },
486
+ limit: { type: 'integer', description: '每批返回条数(1-100, 默认20)' },
487
+ offset: { type: 'integer', description: '跳过前 N 条(翻页用, 默认0)' },
488
+ },
489
+ output: {
490
+ schema: {
491
+ type: 'object', additionalProperties: false,
492
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
493
+ },
494
+ render: (_a, v) => [{ type: 'text', text: v.msg }],
495
+ },
496
+ async execute(args, exec) {
497
+ try {
498
+ const store = stickerStoreOf(exec);
499
+ const onlyLibrary = args.a === 2;
500
+ const limit = Math.min(100, Math.max(1, Math.floor(args.limit ?? 20)));
501
+ const offset = Math.max(0, Math.floor(args.offset ?? 0));
502
+ // untagged 查询语义=标签与介绍都空; 这里要"缺任一即算未整理", 故全量取回后过滤
503
+ const all = store.queryAll({ layer: onlyLibrary ? 'library' : undefined, q: undefined, untagged: false, includeTrash: false });
504
+ const miss = all.filter((it) => it.layer !== 'negative' && ((!it.tags || it.tags.length === 0) || !it.desc));
505
+ const total = miss.length;
506
+ const end = Math.min(total, offset + limit);
507
+ const page = miss.slice(offset, end);
508
+ const lines = page.map((it, i) => {
509
+ const lackTag = !it.tags || it.tags.length === 0;
510
+ const lackDesc = !it.desc;
511
+ const lack = lackTag && lackDesc ? '缺标签+介绍' : lackTag ? '缺标签' : '缺介绍';
512
+ return `${offset + i + 1}. [${it.layer === 'library' ? '收藏' : '候选'}] ${lack} ${it.path || ''}`;
513
+ });
514
+ const scopeTxt = onlyLibrary ? '收藏文件夹' : '全部(候选+收藏)';
515
+ const more = total > end ? `\n(还有 ${total - end} 张, 想看更多请传 offset=${end} 继续翻页)` : '';
516
+ return {
517
+ ok: true,
518
+ msg: total === 0
519
+ ? `🎉 ${scopeTxt}里的图都整理好了(都带标签和介绍)`
520
+ : `未整理完的图(${scopeTxt}): 共 ${total} 张, 显示第 ${offset + 1}-${end} 张:\n${lines.join('\n')}${more}\n想补的用 sticker_tag 打标/写介绍(图路径就是上面返回的)。`,
521
+ };
522
+ }
523
+ catch (e) {
524
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
525
+ }
526
+ },
527
+ });
528
+ const scheduleTimerTool = defineTool({
529
+ name: 'schedule_timer',
530
+ description: '给自己(当前 QQ 会话)安排定时任务: 到点后系统会把 prompt 作为新消息注入本会话, 你会带着上下文处理并回复到 QQ。kind=once(一次性, 默认)或 daily(每天固定时刻)。once 用 seconds(N 秒后, ≥30) 或 atTime(今天 HH:MM, 已过则明天); daily 必填 atTime(HH:MM)。seconds 与 atTime 至少给一个。同一会话同一时刻的每日任务不要重复建。',
531
+ parameters: {
532
+ kind: { type: 'string', enum: ['once', 'daily'], description: 'once=一次性(默认); daily=每天固定时刻' },
533
+ seconds: { type: 'integer', description: '多少秒后触发(仅 once; ≥30)' },
534
+ atTime: { type: 'string', description: '几点几分 24h 制 HH:MM(如 08:30; once 且无 seconds 时=今天该点已过则明天; daily 必填)' },
535
+ prompt: { type: 'string', required: true, description: '到点后注入本会话的提示内容(≤800字), 写清要你做什么' },
536
+ },
537
+ output: {
538
+ schema: {
539
+ type: 'object', additionalProperties: false,
540
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
541
+ },
542
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `定时失败: ${v.msg}` }],
543
+ },
544
+ async execute(args, exec) {
545
+ const _sess = findSessionRec(channelOf(exec), exec);
546
+ if (!_sess)
547
+ return { ok: false, msg: '未找到当前 QQ 会话(通道未就绪), 请稍后重试' };
548
+ const ch = _sess.ch;
549
+ const rec = _sess.rec;
550
+ try {
551
+ const store = getScheduleStore(ch.manager.scheduleDataDir);
552
+ const r = store.add({
553
+ kind: args.kind ?? undefined,
554
+ scope: rec.scope,
555
+ peerId: rec.peerId,
556
+ seconds: args.seconds,
557
+ atTime: args.atTime,
558
+ prompt: args.prompt,
559
+ });
560
+ if (!r.ok || !r.id)
561
+ return { ok: false, msg: r.error ?? '创建失败' };
562
+ const job = store.get(r.id);
563
+ let whenDesc = '';
564
+ if (job) {
565
+ if (job.kind === 'daily') {
566
+ whenDesc = `每天 ${job.atTime}`;
567
+ }
568
+ else {
569
+ const nf = store.nextFireAt(job);
570
+ whenDesc = nf ? new Date(nf).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) : '稍后';
571
+ }
572
+ }
573
+ return { ok: true, msg: `✅ 已创建${job?.kind === 'daily' ? '每日' : '一次性'}定时任务(id=${r.id}, ${whenDesc}触发)。到点我会自动收到提示并处理。可用 schedule_cancel 查看/取消。` };
574
+ }
575
+ catch (e) {
576
+ return { ok: false, msg: e instanceof Error ? e.message : String(e) };
577
+ }
578
+ },
579
+ });
580
+ const scheduleCancelTool = defineTool({
581
+ name: 'schedule_cancel',
582
+ description: '取消/删除当前会话的定时任务。不带 id 调用会列出本会话全部定时任务(id+时间+内容)供挑选; 带 id 则删除该任务。',
583
+ parameters: {
584
+ id: { type: 'string', description: '要删除的任务 id(不填=列出本会话所有定时任务)' },
585
+ },
586
+ output: {
587
+ schema: {
588
+ type: 'object', additionalProperties: false,
589
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
590
+ },
591
+ render: (_a, v) => [{ type: 'text', text: v.msg }],
592
+ },
593
+ async execute(args, exec) {
594
+ const _sess = findSessionRec(channelOf(exec), exec);
595
+ if (!_sess)
596
+ return { ok: false, msg: '未找到当前 QQ 会话(通道未就绪), 请稍后重试' };
597
+ const ch = _sess.ch;
598
+ const rec = _sess.rec;
599
+ const store = getScheduleStore(ch.manager.scheduleDataDir);
600
+ if (!args.id) {
601
+ const mine = store.list().filter(j => j.scope === rec.scope && j.peerId === rec.peerId);
602
+ if (mine.length === 0)
603
+ return { ok: true, msg: '本会话暂无定时任务。' };
604
+ const lines = mine.map(j => {
605
+ const when = j.kind === 'daily'
606
+ ? `每天 ${j.atTime}`
607
+ : j.dueAt
608
+ ? new Date(j.dueAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
609
+ : '';
610
+ return `- id=${j.id} ${j.kind === 'daily' ? '每日' : '一次性'} ${when}${j.enabled ? '' : ' [已停用]'} | ${(j.prompt ?? '').slice(0, 60)}`;
611
+ });
612
+ return { ok: true, msg: `本会话定时任务共 ${mine.length} 条:\n${lines.join('\n')}\n要删除哪条, 请带对应 id 再调用 schedule_cancel。` };
613
+ }
614
+ const job = store.get(args.id);
615
+ if (!job || job.scope !== rec.scope || job.peerId !== rec.peerId) {
616
+ return { ok: false, msg: `任务 ${args.id} 不存在或不属于本会话` };
617
+ }
618
+ store.remove(args.id);
619
+ return { ok: true, msg: `🗑️ 已删除定时任务 ${args.id}` };
620
+ },
621
+ });
622
+ // text_break: 专用"打断正文"工具(2026-09-05, 主人定) —— 零副作用、零查询、近零 token。
623
+ // 机制: 把回复正文拆成多个文本块、块间调用本工具, QQ 端就会把每块作为独立一条消息发出
624
+ // (工具调用 = 天然断点)。本工具不做事: 输入任意(留空即可), 返回空, 仅用于中断文本流。
625
+ // ⚠️ 单轮最多 5 条(约 4 次调用), 第 6 条起被 QQ 拦截; 想逐字卖萌请控制在 5 块内。
626
+ const textBreakTool = defineTool({
627
+ name: 'text_break',
628
+ description: '打断正文实现"逐条发送": 想逐字/逐句卖萌或把长话拆成多条连发时, 把内容拆成几个文本块, 每块之间调用一次本工具(参数随便填个占位值即可), 对方就会收到分割的多条消息。工具本身无任何副作用, 不会发东西。注意: 一轮最多拆 5 条, 再多会被 QQ 吞掉。',
629
+ parameters: {
630
+ next: { type: 'string', required: true, description: '占位参数: 随便填一个值(如 "." 或下一段内容), 工具不读它, 只用于打断文本流' },
631
+ },
632
+ output: {
633
+ schema: {
634
+ type: 'object', additionalProperties: false,
635
+ properties: { ok: { type: 'boolean', required: true } },
636
+ },
637
+ render: () => [{ type: 'text', text: '' }],
638
+ },
639
+ async execute() {
640
+ return { ok: true }; // 纯打断, 无实质结果
641
+ },
642
+ });
643
+ // reply_gate: 回复闸门(实验, 2026-09-05, 主人设想: 让 AI"自主判定要不要回", 判定不吃瓜时由宿主硬终止回合,
644
+ // 实现"真·静默吃瓜"。机制参考 dsh-task-control: agent.cancel({kind:'user'},{keepInbox:true}) 立即切断当前回合输出)。
645
+ // ⚠️ 描述只讲两个分支的效果, 不给场景规则——选哪个由模型按当前对话氛围自行权衡(写死规则会导致静默率过高)。
646
+ const replyGateTool = defineTool({
647
+ name: 'reply_gate',
648
+ description: '回复闸门: 开始回复前先调用它, 决定本回合开口还是静默。传 reply:true → 正常继续, 按当前对话决定说什么; 传 reply:false → 本回合立即静默终止, 你不会再输出任何内容(这条消息就像没发生过)。选 true 还是 false 由你根据当前对话自己判断: 觉得该回应就 true, 觉得安静待着更好就 false。',
649
+ parameters: {
650
+ reply: { type: 'boolean', required: true, description: 'true=正常开口回复; false=静默终止本回合(不再输出任何内容)' },
651
+ reason: { type: 'string', required: true, description: '简短判定理由, 如"未@不插嘴" / "被@需回复" / "私聊"' },
652
+ },
653
+ output: {
654
+ schema: {
655
+ type: 'object', additionalProperties: false,
656
+ properties: {
657
+ ok: { type: 'boolean', required: true },
658
+ stopped: { type: 'boolean', required: true },
659
+ },
660
+ },
661
+ render: (_a, v) => [
662
+ { type: 'text', text: v.stopped ? '(已静默: 本回合终止, 不再回复)' : '' },
663
+ ],
664
+ },
665
+ async execute(args, exec) {
666
+ if (args.reply === false) {
667
+ try {
668
+ const agent = exec.agent;
669
+ if (agent && typeof agent.cancel === 'function') {
670
+ agent.cancel({ kind: 'user' }, { keepInbox: true });
671
+ }
672
+ }
673
+ catch {
674
+ /* cancel 抛错也按静默处理 */
675
+ }
676
+ return { ok: true, stopped: true };
677
+ }
678
+ return { ok: true, stopped: false };
679
+ },
680
+ });
681
+ // ── QQ 群管理工具组(2026-09-05, P2; 依赖 config.groupAdmin.enabled + 机器人=群管理员) ──
682
+ // 危险写操作(审批/禁言)仅按主人指示执行(工具描述写死约束); owners 白名单接入留待后续,
683
+ // 官方未开放能力(踢人/成员列表)由 client FEATURE_GATES 返回人话, 不在本层重复。
684
+ // 目标群解析(2026-09-05 主人定): QQ 群会话 → 当前群; web/非群会话 → config.groupAdmin.manageGroup
685
+ // ("对话里管一个群"不依赖会话形态; 多群管理走设置 UI⑥ P3)。
686
+ // ⚠️ 升级 0.1.2-rc.1 后补强(2026-09-05): web 直连 agent 可能不在 SessionManager 会话表里,
687
+ // findSessionRec 失败 → 旧逻辑直接 return undefined, manageGroup 兜底形同虚设。
688
+ // 改为: 找不到会话也回退到全局桥 manager(groupAdmin.enabled 且 manageGroup 非空即可用)。
689
+ function groupAdminOf(exec) {
690
+ try {
691
+ const s = findSessionRec(channelOf(exec), exec);
692
+ if (s) {
693
+ const client = s.ch.manager.groupAdmin;
694
+ if (!client)
695
+ return undefined;
696
+ const t = s.rec.replyTarget;
697
+ const gid = t && t.scope === 'group'
698
+ ? t.targetId
699
+ : s.ch.manager.manageGroup || undefined;
700
+ return { client, gid };
701
+ }
702
+ // 会话表无此 agent(web 直连等): 遍历全局桥, 找第一个"群管理开启 + 配了 manageGroup"的实例
703
+ for (const b of channelBridges) {
704
+ try {
705
+ const client = b.manager.groupAdmin;
706
+ if (!client)
707
+ continue;
708
+ const mg = b.manager.manageGroup;
709
+ if (mg)
710
+ return { client, gid: mg };
711
+ }
712
+ catch { /* 跳过坏桥 */ }
713
+ }
714
+ return undefined;
715
+ }
716
+ catch {
717
+ return undefined;
718
+ }
719
+ }
720
+ const listJoinRequestsTool = defineTool({
721
+ name: 'group_join_requests',
722
+ description: '群管理(读): 查看当前群待审批的入群申请列表(申请人/验证消息/来源/风险提示)。仅主人要求时调用。需在设置开启"QQ群管理"且机器人为群管理员。',
723
+ parameters: {},
724
+ output: {
725
+ schema: {
726
+ type: 'object', additionalProperties: false,
727
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
728
+ },
729
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
730
+ },
731
+ async execute(_args, exec) {
732
+ const ga = groupAdminOf(exec);
733
+ if (!ga)
734
+ return { ok: false, msg: '群管理未开启(设置→QQ群管理)或非群会话' };
735
+ if (!ga.gid)
736
+ return { ok: false, msg: '当前不是群会话, 无法确定目标群' };
737
+ const r = await ga.client.listJoinRequests(ga.gid);
738
+ if (!r.ok)
739
+ return { ok: false, msg: r.err.human };
740
+ const list = r.data.list;
741
+ if (list.length === 0)
742
+ return { ok: true, msg: '当前没有待审批的入群申请 ✓' };
743
+ const lines = list.map((j, i) => `${i + 1}. ${j.username ?? '?'} (${String(j.member_openid).slice(0, 10)}…) 来源:${j.apply_source ?? '?'} 验证:${j.verify_info?.verify_message ?? j.verify_info?.method ?? '-'}${j.risk_tips ? ` ⚠️${j.risk_tips}` : ''}`);
744
+ return { ok: true, msg: `入群申请 ${list.length} 条:\n${lines.join('\n')}` };
745
+ },
746
+ });
747
+ const approveJoinTool = defineTool({
748
+ name: 'group_approve_join',
749
+ description: '群管理(写,危险): 审批入群申请。approve=放行 / decline=拒绝(可带理由)。仅主人明确要求时调用; 调用前建议先 group_join_requests 核对申请人。',
750
+ parameters: {
751
+ member_openid: { type: 'string', required: true, description: '申请人 member_openid(来自 group_join_requests)' },
752
+ op: { type: 'string', required: true, enum: ['approve', 'decline'], description: 'approve 放行 / decline 拒绝' },
753
+ reason: { type: 'string', required: true, description: '拒绝理由(decline 时填; approve 或不需要可填 "-")' },
754
+ },
755
+ output: {
756
+ schema: {
757
+ type: 'object', additionalProperties: false,
758
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
759
+ },
760
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
761
+ },
762
+ async execute(args, exec) {
763
+ const ga = groupAdminOf(exec);
764
+ if (!ga)
765
+ return { ok: false, msg: '群管理未开启或非群会话' };
766
+ if (!ga.gid)
767
+ return { ok: false, msg: '当前不是群会话' };
768
+ // join_request_id 官方审批必填(缺省报 40103007): 先拉列表按 member_openid 匹配自动补上
769
+ const listR = await ga.client.listJoinRequests(ga.gid);
770
+ if (!listR.ok)
771
+ return { ok: false, msg: listR.err.human };
772
+ const found = (listR.data.list ?? []).find((j) => j.member_openid === args.member_openid);
773
+ const r = await ga.client.approveJoinRequest(ga.gid, args.member_openid, args.op, {
774
+ ...(found ? { join_request_id: found.join_request_id } : {}),
775
+ ...(args.op === 'decline' && String(args.reason ?? '') && String(args.reason) !== '-'
776
+ ? { reject_reason: String(args.reason) }
777
+ : {}),
778
+ });
779
+ if (!r.ok)
780
+ return { ok: false, msg: r.err.human };
781
+ return { ok: true, msg: args.op === 'approve' ? '✅ 已放行该入群申请' : '已拒绝该入群申请' };
782
+ },
783
+ });
784
+ const muteStateTool = defineTool({
785
+ name: 'group_mute_state',
786
+ description: '群管理(读): 查看当前群禁言状态(全员模式 + 正在禁言中的成员及到期时间)。仅主人要求时调用。',
787
+ parameters: {},
788
+ output: {
789
+ schema: {
790
+ type: 'object', additionalProperties: false,
791
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
792
+ },
793
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
794
+ },
795
+ async execute(_args, exec) {
796
+ const ga = groupAdminOf(exec);
797
+ if (!ga)
798
+ return { ok: false, msg: '群管理未开启或非群会话' };
799
+ if (!ga.gid)
800
+ return { ok: false, msg: '当前不是群会话' };
801
+ const r = await ga.client.getMuteState(ga.gid);
802
+ if (!r.ok)
803
+ return { ok: false, msg: r.err.human };
804
+ const mode = r.data.global_rule?.mode ?? 'none';
805
+ const members = r.data.members ?? [];
806
+ const lines = members.map((m) => `${m.username ?? String(m.member_openid).slice(0, 10)}… 禁言至 ${m.mute_expire_at ?? '?'}`);
807
+ return { ok: true, msg: `全员禁言: ${mode === 'always' ? '开启' : mode === 'schedule' ? '定时' : '关闭'}\n禁言中 ${members.length} 人:\n${lines.length ? lines.join('\n') : '(无)'}` };
808
+ },
809
+ });
810
+ const muteMemberTool = defineTool({
811
+ name: 'group_mute_member',
812
+ description: '群管理(写,危险): 禁言/解除群成员。action=mute 禁言(seconds 秒, 默认600); unmute=立即解除。只能禁普通成员(群主/管理员禁不了)。仅主人明确要求时调用。',
813
+ parameters: {
814
+ member_openid: { type: 'string', required: true, description: '目标成员 member_openid' },
815
+ action: { type: 'string', required: true, enum: ['mute', 'unmute'], description: 'mute 禁言 / unmute 解除' },
816
+ seconds: { type: 'number', required: true, description: '禁言秒数(mute 时; 不需要可填 600)' },
817
+ },
818
+ output: {
819
+ schema: {
820
+ type: 'object', additionalProperties: false,
821
+ properties: { ok: { type: 'boolean', required: true }, msg: { type: 'string', required: true } },
822
+ },
823
+ render: (_a, v) => [{ type: 'text', text: v.ok ? v.msg : `失败: ${v.msg}` }],
824
+ },
825
+ async execute(args, exec) {
826
+ const ga = groupAdminOf(exec);
827
+ if (!ga)
828
+ return { ok: false, msg: '群管理未开启或非群会话' };
829
+ if (!ga.gid)
830
+ return { ok: false, msg: '当前不是群会话' };
831
+ const secs = Math.max(1, Math.min(30 * 86400, Math.round(Number(args.seconds ?? 600))));
832
+ const pad = (n) => String(n).padStart(2, '0');
833
+ const expire = args.action === 'mute' ? new Date(Date.now() + secs * 1000) : null;
834
+ const rfc = expire
835
+ ? `${expire.getFullYear()}-${pad(expire.getMonth() + 1)}-${pad(expire.getDate())}T${pad(expire.getHours())}:${pad(expire.getMinutes())}:${pad(expire.getSeconds())}+08:00`
836
+ : null;
837
+ const r = await ga.client.setMemberMute(ga.gid, args.member_openid, rfc);
838
+ if (!r.ok)
839
+ return { ok: false, msg: r.err.human };
840
+ return { ok: true, msg: args.action === 'mute' ? `✅ 已禁言, ${Math.round(secs / 60)} 分钟后自动解除` : '已解除禁言' };
841
+ },
842
+ });
843
+ // 逐个注册并记录结果(便于线上定位是哪个工具失败)
844
+ const toolDefs = [
845
+ { name: 'send_media', tool: sendMediaTool },
846
+ { name: 'recall_message', tool: recallTool },
847
+ { name: 'text_break', tool: textBreakTool },
848
+ { name: 'reply_gate', tool: replyGateTool },
849
+ { name: 'group_join_requests', tool: listJoinRequestsTool },
850
+ { name: 'group_approve_join', tool: approveJoinTool },
851
+ { name: 'group_mute_state', tool: muteStateTool },
852
+ { name: 'group_mute_member', tool: muteMemberTool },
853
+ { name: 'list_stickers', tool: listStickersTool },
854
+ { name: 'sticker_tag', tool: tagStickerTool },
855
+ { name: 'sticker_delete', tool: deleteStickerTool },
856
+ { name: 'sticker_restore', tool: restoreStickerTool },
857
+ { name: 'sticker_stats', tool: statsStickerTool },
858
+ { name: 'sticker_untagged', tool: untaggedStickerTool },
859
+ { name: 'schedule_timer', tool: scheduleTimerTool },
860
+ { name: 'schedule_cancel', tool: scheduleCancelTool },
861
+ ];
862
+ for (const t of toolDefs) {
863
+ try {
864
+ ctx.tools.register(t.tool);
865
+ ctx.logger?.info?.(`[channel-tools] 已注册: ${t.name}`);
866
+ diag(`注册成功: ${t.name}`);
867
+ }
868
+ catch (err) {
869
+ const msg = err instanceof Error ? err.message : String(err);
870
+ // 重复注册(already registered)= 幂等全量重跑的正常现象, 静默跳过不刷日志
871
+ if (/already registered/i.test(msg)) {
872
+ diag(`注册幂等跳过(已存在): ${t.name}`);
873
+ continue;
874
+ }
875
+ ctx.logger?.warn?.(`[channel-tools] 注册失败 ${t.name}: ${msg}`);
876
+ diag(`注册失败: ${t.name} → ${msg}`);
877
+ }
878
+ }
879
+ }
880
+ //# sourceMappingURL=channel-tools.js.map