qq-bot-roost 0.8.3 → 0.8.7

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 CHANGED
@@ -6,11 +6,11 @@
6
6
 
7
7
  **填 AppID + AppSecret 就能连上 QQ 官方机器人。零依赖,插件可写任意语言,自带可视化面板。**
8
8
 
9
- <img src="https://img.shields.io/badge/Node.js-22%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22+">
9
+ <img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
- <img src="https://img.shields.io/badge/tests-551%20passed-brightgreen" alt="测试 551 项">
11
+ <img src="https://img.shields.io/badge/tests-600%2B%20passed-brightgreen" alt="测试 600+ 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.6.14-blueviolet" alt="版本 0.6.14">
13
+ <img src="https://img.shields.io/badge/version-0.8.7-blueviolet" alt="版本 0.8.7">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
 
16
16
  [快速开始](#3-分钟跑起来) | [写插件](docs/插件开发规范.md) | [外部插件协议](docs/插件协议.md) | [配置参考](docs/配置参考.md) | [常见问题](docs/常见问题.md) | [贡献](CONTRIBUTING.md)
@@ -106,6 +106,10 @@
106
106
  - **面板页能读自己的数据**:`ctx.registerRoute()` 注册 REST 接口(`/api/plugins/<id>/r/…`),
107
107
  鉴权/CSRF/同源校验全复用面板那套,插件拿不到裸 req/res
108
108
  - **元数据能表达兼容性**:`frameworkVersion` 声明需要的框架版本,不满足时**在加载前拦住**并说清原因,而不是跑起来才报「xxx 不是函数」
109
+ - **插件能给 AI 贡献工具(0.8.6+)**:`ctx.registerTool()` 注册一个带 JSON Schema 的工具
110
+ (名字全局唯一,建议带插件前缀),别的插件用 `ctx.tools()` 拿清单、`ctx.callTool()` 直接调用。
111
+ 于是「AI 能查斗罗玩家数据」这种事,**AI 插件和斗罗插件两边不用互相认识**——都只认这张工具表。
112
+ 工具还能标 `ownerOnly` / `admin` 限权限,`timeoutMs` 限超时,插件卸载时自动注销
109
113
 
110
114
  **统计**
111
115
 
@@ -142,16 +146,20 @@
142
146
 
143
147
  ## 3 分钟跑起来
144
148
 
145
- ### 1. 装 Node.js 22+
149
+ ### 1. 装 Node.js 22.13+
146
150
 
147
151
  ```bash
148
- node -v # 需要 >= 22
152
+ node -v # 需要 >= 22.13
149
153
  ```
150
154
 
151
155
  > 用到了 Node 22 内置的全局 `WebSocket`,所以不用装 `ws`,也不需要 `npm install`。
152
156
 
153
157
  ### 2. 拿凭证
154
158
 
159
+ **扫码最省事(0.8.4+)**:面板 → 「新建机器人」 → 点「**扫码一键创建**」,
160
+ 用手机 QQ 扫一下,AppID / AppSecret **自动填好**,不用去网页复制。
161
+ 扫码用不了、或已有机器人时,同一处还有「或者手动填」:
162
+
155
163
  1. <https://q.qq.com> → 创建机器人
156
164
  2. 「开发设置」里拿到 **AppID** 和 **AppSecret**
157
165
  3. **关键**:「功能配置」里开通 **群聊** 能力,否则连接会被拒(关闭码 4014)
@@ -358,6 +366,8 @@ export default {
358
366
  | `ctx.storage.get/set/all` | 插件私有存档 |
359
367
  | `ctx.config` / `ctx.setConfig()` | 插件配置读写 |
360
368
  | `ctx.can` / `ctx.levelOf` / `ctx.scopeOf` | 权限判断 |
369
+ | `ctx.registerTool(工具)` | 注册一个 AI 可调用的工具(跨插件,0.8.6+),返回注销函数 |
370
+ | `ctx.tools()` / `ctx.callTool(名, 参数)` | 列出 / 调用别插件注册的工具 |
361
371
  | `msg.reply` / `push` / `replyMarkdown` / `replyImage` / … | 各种发送方式 |
362
372
 
363
373
  ---
@@ -400,6 +410,7 @@ bot/
400
410
  │ ├── gateway.js WebSocket 网关
401
411
  │ ├── webhook.js Webhook 接收服务(多机器人共用一个端口)
402
412
  │ ├── sign.js Webhook 回调签名(Ed25519)
413
+ │ ├── qq/ 扫码一键建机器人(qrlogin.js / qrsvg.js)
403
414
  │ ├── tarball.js tar.gz 下载解包(不依赖 git)
404
415
  │ ├── updater.js 框架自身检查更新与升级
405
416
  │ ├── semver.js 版本比较与范围匹配(框架更新/市场/兼容校验共用)
@@ -412,8 +423,9 @@ bot/
412
423
  │ ├── web/server.js 面板服务端(REST + SSE)
413
424
  │ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
414
425
  ├── test/
415
- │ ├── selftest.mjs 框架自检(322 项)
416
- │ └── e2e.mjs 端到端测试(122 项,起真实服务打接口)
426
+ │ ├── selftest.mjs 框架自检(474 项)
427
+ │ ├── e2e.mjs 端到端测试(147 项,起真实服务打接口)
428
+ │ └── qrlogin.mjs 扫码建机器人(10 项)
417
429
  ├── web/ 面板前端(原生 JS/CSS,无构建)
418
430
  └── docs/
419
431
  ```
@@ -458,9 +470,9 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
458
470
  ## 开发
459
471
 
460
472
  ```bash
461
- npm test # 框架自检 345 项
462
- npm run test:e2e # 端到端 150 项(起 mock 服务打全部接口,跑在临时目录)
463
- npm run test:all # 两个都跑
473
+ npm test # 自检 484 项(selftest 474 + qrlogin 10)
474
+ npm run test:e2e # 端到端 147 项(起 mock 服务打全部接口,跑在临时目录)
475
+ npm run test:all # 全量约 631 项:自检 + 扫码 + e2e(真机驱动要真机,没配就跳过)
464
476
  npm run check:secrets # 扫硬编码凭据(CI 也跑这个,提交前建议过一遍)
465
477
  node src/main.js --mock # 不连 QQ,命令行发假消息调插件
466
478
  node src/main.js --mock --serve # 只开面板,调界面
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.3",
3
+ "version": "0.8.7",
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
+ }