qq-bot-roost 0.8.2 → 0.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.2",
3
+ "version": "0.8.6",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,9 +29,9 @@
29
29
  "start": "node src/main.js",
30
30
  "mock": "node src/main.js --mock",
31
31
  "check": "node src/main.js --check",
32
- "test": "node test/selftest.mjs",
32
+ "test": "node test/selftest.mjs && node test/qrlogin.mjs",
33
33
  "test:e2e": "node test/e2e.mjs",
34
- "test:all": "node test/selftest.mjs && node test/e2e.mjs && node test/live-driver.mjs",
34
+ "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/e2e.mjs && node test/live-driver.mjs",
35
35
  "check:secrets": "node scripts/check-secrets.mjs",
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
package/src/markdown.js CHANGED
@@ -71,6 +71,71 @@ export const md = {
71
71
  },
72
72
  };
73
73
 
74
+ /**
75
+ * 把 markdown 消息降级成纯文本。
76
+ *
77
+ * 为什么需要:群机器人发 markdown 要平台放行,没权限 / 内容被判不合规时接口
78
+ * 直接返回 400,而 markdown 是**独立消息类型**、没有纯文本兜底——不发就等于
79
+ * 用户点了菜单什么也看不到。所有 `ctx.menu()` 出来的东西都走这条路,所以降级
80
+ * 不能只打日志。
81
+ *
82
+ * 降级顺序有讲究:交互标签里带 `=` 和引号,必须先换成它显示的文字,
83
+ * 否则后面剥标签的正则会把它啃成半截。
84
+ *
85
+ * @param {string|{content?:string}} src markdown 正文,或 `{ content }` 对象
86
+ * @returns {string} 尽量保留信息量的纯文本
87
+ */
88
+ export function mdToPlain(src) {
89
+ let s = typeof src === 'string' ? src : String(src?.content ?? '');
90
+ if (!s) return '';
91
+
92
+ /** 从标签里取属性值并解码(官方要求 urlencode) */
93
+ const attr = (tag, name) => {
94
+ const m = new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(tag);
95
+ if (!m) return '';
96
+ try {
97
+ return decodeURIComponent(m[1]);
98
+ } catch {
99
+ return m[1];
100
+ }
101
+ };
102
+
103
+ // 1) 交互标签 → 它显示的文字(群聊用 show,缺了退回 text)
104
+ s = s.replace(/<qqbot-cmd-input\b[^>]*>/gi, (tag) => attr(tag, 'show') || attr(tag, 'text'));
105
+ s = s.replace(/<qqbot-cmd-enter\b[^>]*>/gi, (tag) => attr(tag, 'text'));
106
+ // 2) @ 人:纯文本里没有 id 对应的昵称,留个 @ 比留一串 openid 强
107
+ s = s.replace(/<qqbot-at-user\b[^>]*>/gi, '@');
108
+ // 3) 其他标签一律剥掉
109
+ s = s.replace(/<[^>\n]{1,200}>/g, '');
110
+
111
+ // 4) 链接 [文字](地址) → 文字(地址);文字和地址一样就只留一份
112
+ s = s.replace(/\[([^\]\n]*)\]\(([^)\s]+)\)/g, (m, text, url) =>
113
+ !text || text === url ? url : `${text}(${url})`,
114
+ );
115
+
116
+ // 5) 分割线要在剥加粗之前处理,否则 *** 会被当成加粗
117
+ s = s.replace(/^[ \t]*(\*[ \t]*){3,}$/gm, '———');
118
+ s = s.replace(/^[ \t]*(-[ \t]*){3,}$/gm, '———');
119
+ s = s.replace(/^[ \t]*(_[ \t]*){3,}$/gm, '———');
120
+
121
+ // 6) 强调标记
122
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1');
123
+ s = s.replace(/__([^_\n]+)__/g, '$1');
124
+ s = s.replace(/(^|[^\w*])\*([^*\n]+)\*(?=[^\w*]|$)/g, '$1$2');
125
+ s = s.replace(/(^|[^\w_])_([^_\n]+)_(?=[^\w_]|$)/g, '$1$2');
126
+ s = s.replace(/~~([^~\n]+)~~/g, '$1');
127
+ s = s.replace(/`{1,3}([^`]*)`{1,3}/g, '$1');
128
+
129
+ // 7) 标题、引用符号
130
+ s = s.replace(/^[ \t]*#{1,6}[ \t]+/gm, '');
131
+ s = s.replace(/^[ \t]*>[ \t]?/gm, '');
132
+
133
+ // 8) 还原 escMd 的转义
134
+ s = s.replace(/\\([\\`*_~])/g, '$1');
135
+
136
+ return s.replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
137
+ }
138
+
74
139
  /**
75
140
  * 把「分组菜单」渲染成带可点击指令的 markdown。
76
141
  *
package/src/message.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createLogger } from './logger.js';
2
2
  import { FILE_TYPE } from './api.js';
3
3
  import { messageKindOf, EVENTS } from './events.js';
4
- import { renderMenuMd } from './markdown.js';
4
+ import { renderMenuMd, mdToPlain } from './markdown.js';
5
5
 
6
6
  const log = createLogger('message');
7
7
 
@@ -543,10 +543,40 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
543
543
  /** Markdown 消息(可带按钮 keyboard)。Markdown 是独立消息类型,先把攒的文本发掉 */
544
544
  msg.replyMarkdown = async (markdown, keyboard) => {
545
545
  await msg.flushReplies();
546
- return send({ markdown, keyboard }, true).catch((e) => {
547
- log.error('发送 Markdown 失败:', e.message);
548
- return null;
549
- });
546
+ try {
547
+ return await send({ markdown, keyboard }, true);
548
+ } catch (e) {
549
+ /**
550
+ * Markdown 是**独立消息类型**,没有纯文本兜底:平台不放行(没权限、
551
+ * 内容被判不合规)就整条发不出去。而所有 `ctx.menu()` 出来的菜单都是
552
+ * markdown——不降级的话,用户点菜单看到的是「什么都没发生」。
553
+ *
554
+ * 只在「服务端明确拒了」时降级(4xx)。理由:
555
+ * · 5xx / 网络错误 有可能已经送达,重发会变成两条;
556
+ * · 429 是限流,换纯文本一样被限,白烧一次;
557
+ * · offline / expired / quota 换纯文本也发不出去,expired/quota 还会多扣额度。
558
+ */
559
+ const status = Number(e?.status) || 0;
560
+ const rejected = (status >= 400 && status < 500 && status !== 429) || e?.code === 'api';
561
+ if (!rejected) {
562
+ log.error('发送 Markdown 失败:', e.message);
563
+ return null;
564
+ }
565
+ const plain = mdToPlain(markdown);
566
+ if (!plain) {
567
+ log.error(`Markdown 被拒(${e.message}),且降不出纯文本`);
568
+ return null;
569
+ }
570
+ /**
571
+ * 不带 keyboard:按钮挂在纯文本上平台不认,带上会连累整条消息再被拒一次。
572
+ * 按钮里的指令本来也会出现在正文里(ctx.menu 渲染时带上),信息不丢。
573
+ */
574
+ log.warn(`Markdown 被拒(${e.message}),降级为纯文本重发`);
575
+ return send({ content: plain }, true).catch((e2) => {
576
+ log.error('降级纯文本也没发出去:', e2.message);
577
+ return null;
578
+ });
579
+ }
550
580
  };
551
581
 
552
582
  /**
@@ -4,9 +4,11 @@ import { createLogger } from '../logger.js';
4
4
  import { scopeOf, LEVEL, NO_PERM, levelName } from '../roles.js';
5
5
  import { md, renderMenuMd } from '../markdown.js';
6
6
  import { normalizeDashboard } from './dashboard.js';
7
+ import { createToolCaller } from './tools.js';
8
+
7
9
 
8
10
  /**
9
- * 全局注册表:所有插件注册的命令和事件监听都放这里。
11
+ * 全局注册表:所有插件注册的命令、事件监听、AI 工具都放这里。
10
12
  * 重载插件时按 pluginId 精确清除,互不影响。
11
13
  */
12
14
  export class Registry {
@@ -15,6 +17,14 @@ export class Registry {
15
17
  this.listeners = new Map(); // eventName -> [{ pluginId, handler }]
16
18
  // pluginId -> { id, title, desc, sections: [{ title, items: [{ text, desc, isCommand }] }] }
17
19
  this.menus = new Map();
20
+ /**
21
+ * AI 可调用工具:name -> entry。
22
+ *
23
+ * 为什么按工具名而不是按插件索引:模型只认工具名,调用时也只给名字,
24
+ * 按名直查是 O(1);重名必须当场报错,否则模型会调到一个「不知道谁的」
25
+ * 同名工具,出问题极难排查。
26
+ */
27
+ this.tools = new Map();
18
28
  }
19
29
 
20
30
  clearPlugin(id) {
@@ -27,6 +37,9 @@ export class Registry {
27
37
  else this.listeners.delete(event);
28
38
  }
29
39
  this.menus.delete(id);
40
+ for (const [name, entry] of this.tools) {
41
+ if (entry.pluginId === id) this.tools.delete(name);
42
+ }
30
43
  }
31
44
 
32
45
  /** 取(或建)某插件的菜单 */
@@ -104,6 +117,71 @@ export class Registry {
104
117
  if (!this.listeners.has(event)) this.listeners.set(event, []);
105
118
  this.listeners.get(event).push({ pluginId, handler });
106
119
  }
120
+
121
+ /**
122
+ * 注册一个 AI 可调用的工具。
123
+ *
124
+ * @param {string} pluginId
125
+ * @param {object} meta { name, description, parameters, ownerOnly, admin, timeoutMs, hidden }
126
+ * @param {(args:object, callCtx:object)=>any} handler
127
+ */
128
+ addTool(pluginId, meta, handler) {
129
+ const name = String(meta?.name ?? '').trim();
130
+ if (!name) throw new Error('工具缺少 name');
131
+ if (this.tools.has(name)) {
132
+ const owner = this.tools.get(name).pluginId;
133
+ throw new Error(
134
+ owner === pluginId
135
+ ? `工具 "${name}" 在本插件里重复注册`
136
+ : `工具 "${name}" 已被插件 ${owner} 注册`,
137
+ );
138
+ }
139
+ this.tools.set(name, {
140
+ pluginId,
141
+ pluginName: meta?.pluginName ?? pluginId,
142
+ name,
143
+ description: String(meta?.description ?? '').trim(),
144
+ parameters: meta?.parameters ?? { type: 'object', properties: {} },
145
+ handler,
146
+ ownerOnly: Boolean(meta?.ownerOnly),
147
+ admin: Boolean(meta?.admin),
148
+ timeoutMs: Number(meta?.timeoutMs) > 0 ? Number(meta.timeoutMs) : 0,
149
+ hidden: Boolean(meta?.hidden),
150
+ });
151
+ return this.tools.get(name);
152
+ }
153
+
154
+ removeTool(pluginId, name) {
155
+ const e = this.tools.get(name);
156
+ if (e && e.pluginId === pluginId) this.tools.delete(name);
157
+ }
158
+
159
+ getTool(name) {
160
+ return this.tools.get(String(name ?? '').trim()) ?? null;
161
+ }
162
+
163
+ /**
164
+ * 可对外暴露的工具描述(**不含 handler**)。
165
+ *
166
+ * 为什么不返回 entry 本体:这些描述会原样交给模型服务商,
167
+ * 带上函数引用既没法序列化,也等于把内部实现递出去。
168
+ */
169
+ listTools({ includeHidden = false } = {}) {
170
+ const out = [];
171
+ for (const e of this.tools.values()) {
172
+ if (e.hidden && !includeHidden) continue;
173
+ out.push({
174
+ name: e.name,
175
+ description: e.description,
176
+ parameters: e.parameters,
177
+ pluginId: e.pluginId,
178
+ pluginName: e.pluginName,
179
+ ownerOnly: e.ownerOnly,
180
+ admin: e.admin,
181
+ });
182
+ }
183
+ return out;
184
+ }
107
185
  }
108
186
 
109
187
  /** 每个插件一个简单的 JSON 键值存储,游戏存档之类可以直接用 */
@@ -236,6 +314,18 @@ export function createContext({ meta, paths, registry, api, cfg, config, configS
236
314
  // 面板「运行数据」:插件用 ctx.setDashboard() 推的最后一份
237
315
  let dashboardCache = null;
238
316
 
317
+ /**
318
+ * 工具调用器。`can` 依赖 ctx 自己(要按会话算权限等级),所以这里传的是
319
+ * 延迟求值的闭包——箭头函数体在真正调用时才碰 ctx,那时它早就建好了。
320
+ */
321
+ const callTool = createToolCaller({
322
+ registry,
323
+ isOwner,
324
+ can: (m, need) => ctx.can(m, need),
325
+ scopeOf,
326
+ log: (m) => logger.debug(m),
327
+ });
328
+
239
329
  const ctx = {
240
330
  id,
241
331
  name: meta.name ?? id,
@@ -442,6 +532,86 @@ export function createContext({ meta, paths, registry, api, cfg, config, configS
442
532
  registry.addListener(id, event, handler);
443
533
  },
444
534
 
535
+ /**
536
+ * 注册一个 **AI 可调用**的工具(跨插件)。
537
+ *
538
+ * 命令是给人打的,工具是给模型调的:模型只看到名字、说明和参数 schema,
539
+ * 自己决定什么时候调。别的插件(尤其 AI 插件)用 `ctx.tools()` 拿到清单、
540
+ * `ctx.callTool()` 执行,于是「AI 能查斗罗玩家数据」这种事不用 AI 插件
541
+ * 认识斗罗插件——两边只认这张表。
542
+ *
543
+ * ctx.registerTool({
544
+ * name: 'douluo_status',
545
+ * description: '查一个斗罗玩家的等级、魂环、金币',
546
+ * parameters: {
547
+ * type: 'object',
548
+ * properties: { uin: { type: 'string', description: '玩家 QQ 号' } },
549
+ * required: ['uin'],
550
+ * },
551
+ * async execute({ uin }, call) {
552
+ * const p = await load(uin);
553
+ * return `${uin}:${p.level} 级,${p.gold} 金币`;
554
+ * },
555
+ * });
556
+ *
557
+ * `call` 里有 `{ msg, chatId, userId, userName, kind, scope, callTool }`,
558
+ * 需要按人区分的工具从 `call.userId` 取,别自己去翻全局。
559
+ *
560
+ * 名字**全局唯一**(跨插件也不能重):模型只认名字,重名会调到一个
561
+ * 「不知道谁的」同名工具,出了问题极难查。
562
+ *
563
+ * @param {object} tool
564
+ * @param {string} tool.name 全局唯一,建议带插件前缀
565
+ * @param {string} [tool.description] 给模型看的说明,写「做什么」别写「怎么实现」
566
+ * @param {object} [tool.parameters] JSON Schema(OpenAI function calling 同款)
567
+ * @param {boolean} [tool.ownerOnly] 只有机器人主人能调
568
+ * @param {boolean} [tool.admin] 需要当前会话管理员
569
+ * @param {number} [tool.timeoutMs] 超时,默认 15s
570
+ * @param {boolean} [tool.hidden] 不出现在 ctx.tools() 里,只能显式调用
571
+ * @param {(args:object, call:object)=>any} tool.execute
572
+ * @returns {() => void} 注销函数(插件卸载时会自动调用,不用手动收)
573
+ */
574
+ registerTool(tool) {
575
+ if (!tool || typeof tool.execute !== 'function') {
576
+ throw new Error('registerTool 需要 { name, execute }');
577
+ }
578
+ registry.addTool(
579
+ id,
580
+ { ...tool, pluginName: meta.name ?? id },
581
+ // 包一层:把 execute 抛出的同步异常也变成 rejection,
582
+ // 否则 callTool 里的 try/finally 接不住同步 throw
583
+ (args, call) => Promise.resolve().then(() => tool.execute(args, call)),
584
+ );
585
+ const off = () => registry.removeTool(id, tool.name);
586
+ disposers.push(off);
587
+ return off;
588
+ },
589
+
590
+ /**
591
+ * 列出所有插件暴露的 AI 工具(**不含执行函数**,可以直接序列化给模型)。
592
+ *
593
+ * 为什么不带 handler:这份清单会原样进请求体发给服务商,
594
+ * 带上函数引用既没法 JSON 化,也等于把内部实现递出去。
595
+ *
596
+ * @param {{includeHidden?:boolean}} [opts]
597
+ */
598
+ tools(opts) {
599
+ return registry.listTools(opts);
600
+ },
601
+
602
+ /**
603
+ * 执行一个工具。权限、超时、重入在这里统一兜住,调用方只管处理结果。
604
+ *
605
+ * const list = ctx.tools();
606
+ * const out = await ctx.callTool('douluo_status', { uin }, { msg });
607
+ *
608
+ * 失败一律 **throw**(不是返回空):调用方多半是 AI 插件,它要把
609
+ * 「工具报错了」这句话回填给模型,让模型自己圆回来。
610
+ */
611
+ callTool(name, args, opts) {
612
+ return callTool(name, args, opts);
613
+ },
614
+
445
615
  /**
446
616
  * 定时任务。返回停止函数;插件卸载时会自动清理,不会残留。
447
617
  * ctx.schedule('daily-reset', 60_000, () => { ... })
@@ -275,6 +275,16 @@ export class SubprocessPlugin {
275
275
  this.commands = Array.isArray(o.commands) ? o.commands : [];
276
276
  this.menus = Array.isArray(o.menus) ? o.menus : [];
277
277
  this.levels = Array.isArray(o.levels) ? o.levels : [];
278
+ /**
279
+ * 外部插件还不能注册 AI 工具:工具的调用要走一次「核心 → 插件 → 核心」
280
+ * 的往返,协议里目前只有 msg 这一条请求/响应通道。与其默默忽略、
281
+ * 让作者以为自己注册成功了,不如当场说清楚。
282
+ */
283
+ if (Array.isArray(o.tools) && o.tools.length) {
284
+ this.log.warn(
285
+ `插件声明了 ${o.tools.length} 个 AI 工具,但外部插件暂不支持注册工具(需要内核插件,或等协议 v2)`,
286
+ );
287
+ }
278
288
  // listen 支持 true(= 全部消息)或字符串数组
279
289
  if (o.listen === true) this.listen = ['message'];
280
290
  else if (Array.isArray(o.listen)) this.listen = o.listen.map(String);
@@ -15,6 +15,8 @@
15
15
 
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
+ import { Registry } from './context.js';
19
+ import { createToolCaller } from './tools.js';
18
20
 
19
21
  /** 假的存储:全放内存,跑完就没了 */
20
22
  function memoryStorage() {
@@ -69,11 +71,32 @@ function fakeLogger(quiet) {
69
71
  * 造一个够用的 ctx。
70
72
  * @param {object} [opts]
71
73
  */
72
- export function makeCtx({ id = 'test', config = {}, quiet = true, dataDir = null } = {}) {
74
+ export function makeCtx({
75
+ id = 'test',
76
+ config = {},
77
+ quiet = true,
78
+ dataDir = null,
79
+ registry = null,
80
+ isOwner: isOwnerFn = null,
81
+ can: canFn = null,
82
+ } = {}) {
73
83
  const commands = new Map();
74
84
  const menus = [];
75
85
  const listeners = new Map();
76
86
  const scheduled = [];
87
+ /**
88
+ * 工具表。不传 registry 就自己造一个;要测「A 插件注册、B 插件调用」
89
+ * 时给两个 ctx 传**同一个** Registry 即可,和内核里的行为一致。
90
+ */
91
+ const toolRegistry = registry ?? new Registry();
92
+
93
+ /** 和内核同一份调用语义(权限/超时/重入),避免夹具与线上分叉 */
94
+ const toolCaller = createToolCaller({
95
+ registry: toolRegistry,
96
+ isOwner: (u) => ctx.isOwner(u),
97
+ can: (m, need) => ctx.can(m, need),
98
+ scopeOf: (m) => ctx.scopeOf(m),
99
+ });
77
100
 
78
101
  const ctx = {
79
102
  id,
@@ -131,9 +154,9 @@ export function makeCtx({ id = 'test', config = {}, quiet = true, dataDir = null
131
154
  for (const k of Object.keys(map)) out[k] = 10 + (map[k] ?? i++);
132
155
  return out;
133
156
  },
134
- isOwner: () => false,
157
+ isOwner: isOwnerFn ?? (() => false),
135
158
  levelOf: async () => 9999,
136
- can: async () => true,
159
+ can: canFn ?? (async () => true),
137
160
  scopeOf: (msg) => (msg?.groupId ? `group:${msg.groupId}` : `user:${msg?.userId ?? ''}`),
138
161
  isGroupAdmin: async () => false,
139
162
  getGroup: async () => null,
@@ -163,11 +186,31 @@ export function makeCtx({ id = 'test', config = {}, quiet = true, dataDir = null
163
186
  },
164
187
  dispose: () => {},
165
188
 
189
+ /* ---------------- 跨插件 AI 工具(和内核同一套语义) ---------------- */
190
+ registerTool(tool) {
191
+ if (!tool || typeof tool.execute !== 'function') {
192
+ throw new Error('registerTool 需要 { name, execute }');
193
+ }
194
+ toolRegistry.addTool(
195
+ id,
196
+ { ...tool, pluginName: ctx.name },
197
+ (args, call) => Promise.resolve().then(() => tool.execute(args, call)),
198
+ );
199
+ return () => toolRegistry.removeTool(id, tool.name);
200
+ },
201
+ tools(opts) {
202
+ return toolRegistry.listTools(opts);
203
+ },
204
+ callTool(name, args, opts) {
205
+ return toolCaller(name, args, opts);
206
+ },
207
+
166
208
  /* 夹具内部用 */
167
209
  __commands: commands,
168
210
  __menus: menus,
169
211
  __listeners: listeners,
170
212
  __scheduled: scheduled,
213
+ __tools: toolRegistry,
171
214
  };
172
215
 
173
216
  return ctx;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 跨插件 AI 工具的调用核心
3
+ * ========================
4
+ *
5
+ * 工具注册在 `Registry.tools`(按工具名索引,重载插件时按 pluginId 清),
6
+ * 这个文件只管**执行**:权限、超时、重入、调用上下文。
7
+ *
8
+ * 为什么单独拎出来:真 `ctx`(context.js)和插件测试夹具(testkit.js)都要
9
+ * 能调工具。语义写两份必然分叉——夹具里「有权限、没超时」,线上「要管理员、
10
+ * 15 秒超时」,插件作者在本地测通了上线却挂,是最难查的一类问题。
11
+ */
12
+
13
+ /** 工具默认超时:AI 插件按轮推进,一个工具卡住等于整轮回复卡住 */
14
+ export const DEFAULT_TOOL_TIMEOUT_MS = 15000;
15
+
16
+ /**
17
+ * @param {object} deps
18
+ * @param {import('./context.js').Registry} deps.registry
19
+ * @param {(userId:string)=>boolean} deps.isOwner
20
+ * @param {(msg:object, need:string)=>Promise<boolean>} deps.can
21
+ * @param {(msg:object)=>string} deps.scopeOf
22
+ * @param {(msg:string)=>void} [deps.log]
23
+ */
24
+ export function createToolCaller({ registry, isOwner, can, scopeOf, log = () => {} }) {
25
+ /**
26
+ * 执行一个工具。
27
+ *
28
+ * 失败一律 **throw**:调用方多半是 AI 插件,它要把「工具报错了」这句话
29
+ * 回填给模型让模型自己圆回来。返回 null / '' 表示「成功但没内容」,
30
+ * 这两种语义不能混。
31
+ *
32
+ * @param {string} name
33
+ * @param {object} [args]
34
+ * @param {{msg?:object, stack?:string[]}} [opts]
35
+ * `stack` 是内部用的调用链,别自己传。
36
+ */
37
+ return async function callTool(name, args = {}, { msg = null, stack = [] } = {}) {
38
+ const key = String(name ?? '').trim();
39
+ const t = registry.getTool(key);
40
+ if (!t) throw new Error(`没有名为 "${key}" 的工具`);
41
+
42
+ /**
43
+ * 同一条调用链里不许重复出现同一个工具。
44
+ *
45
+ * 为什么用「链」而不是「全局正在执行集合」:全局集合会把**并发**误杀——
46
+ * 两个群同时问一句,第二个请求会因为「第一个还在跑」被拒,
47
+ * 而这两次调用本来毫无关系。用调用链则只挡真正的递归。
48
+ */
49
+ if (stack.includes(key)) {
50
+ throw new Error(`工具 "${key}" 不能在自己的调用链里重复调用(会无限递归)`);
51
+ }
52
+ const nextStack = [...stack, key];
53
+
54
+ const userId = msg?.userId ?? '';
55
+ if (t.ownerOnly && !isOwner(userId)) {
56
+ throw new Error(`工具 "${key}" 只有机器人主人能用`);
57
+ }
58
+ if (t.admin) {
59
+ if (!msg) throw new Error(`工具 "${key}" 需要管理员身份,但调用方没给会话上下文`);
60
+ if (!(await can(msg, 'admin'))) {
61
+ throw new Error(`工具 "${key}" 需要当前会话的管理员权限`);
62
+ }
63
+ }
64
+
65
+ const ms = t.timeoutMs || DEFAULT_TOOL_TIMEOUT_MS;
66
+ let timer;
67
+ const timeout = new Promise((_, reject) => {
68
+ timer = setTimeout(
69
+ () =>
70
+ reject(
71
+ new Error(
72
+ `工具 "${key}" 超过 ${ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${ms}ms`} 没返回`,
73
+ ),
74
+ ),
75
+ ms,
76
+ );
77
+ // 别让一个 15s 的定时器把进程钉在事件循环里
78
+ timer.unref?.();
79
+ });
80
+
81
+ const call = {
82
+ msg,
83
+ chatId: msg?.chatId ?? '',
84
+ userId,
85
+ userName: msg?.userName ?? '',
86
+ kind: msg?.kind ?? '',
87
+ scope: msg ? scopeOf(msg) : 'global',
88
+ pluginId: t.pluginId,
89
+ toolName: key,
90
+ // 工具之间可以组合,但同样受重入与超时约束
91
+ callTool: (n, a) => callTool(n, a, { msg, stack: nextStack }),
92
+ };
93
+
94
+ log(`调用工具 ${key}(注册方:${t.pluginId})`);
95
+ try {
96
+ return await Promise.race([t.handler(args ?? {}, call), timeout]);
97
+ } finally {
98
+ clearTimeout(timer);
99
+ }
100
+ };
101
+ }