@zaofan/dsh-qqbot 1.8.0 → 1.8.2

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
@@ -352,6 +352,32 @@ dsh: disabling profile plugin row "mcp-chrome": Plugin ... is incompatible with
352
352
 
353
353
  ---
354
354
 
355
+ ## 🧩 扩展工具:自己写 QQ 工具,还能"跨通道"(v1.8.0+)
356
+
357
+ 把 `.mjs` 放进 `{dataRoot}/.qqbot-extensions/tools/`,AI 就能调用它(写完发 `/tools-reload` 即生效,无需重启)。
358
+ **默认**:扩展工具**只在 QQ 会话里存在**——别处的会话完全看不到它,所以也不占一点 token。
359
+
360
+ **加一行 `expose: 'all'`**,它就对**所有会话可见**(web 对话、终端…),并在那边**直接操作 QQ**:
361
+
362
+ ```js
363
+ export default {
364
+ name: 'qq_send',
365
+ expose: 'all',
366
+ description: '查 QQ 聊天对象 / 向群或私聊发消息',
367
+ inputSchema: { action: { type: 'string' }, target: { type: 'string' }, text: { type: 'string' } },
368
+ run: async (args, env) => {
369
+ const qq = env.qq; // 账号包:appId / 群与私聊清单 / sender / sendText / apiCall…
370
+ return { ok: true, msg: (await qq.sendText('group', args.target, args.text)) ? '已发送 ✓' : '发送失败' };
371
+ },
372
+ };
373
+ ```
374
+
375
+ - 注册名会带上**人设 id**(如 `qq_send__whale-girl`),归属一眼可见;
376
+ ⚠️ **工具名只能用 `[a-zA-Z0-9_-]`** —— 用 `@` 之类的字符会被 LLM API **整轮拒绝**(会让机器人全面瘫痪)。插件现在会**直接拒绝注册**这类名字,不会再把整条链路带崩。
377
+ - 发送**被动优先、失败转主动**:先拿目标会话最近的 `msgId` 走被动回复(不吃主动配额、也不被 QQ 吞),失败再去掉 `msgId` 重发。冷群没有被动窗口,会直接走主动。
378
+ - **新用户不用自己搬示例**:插件启动时会把自带的 `examples/ext-tools`、`examples/botplay` **只补不覆盖**地铺进你的扩展目录 —— 装上就有能跑的工具(如 `qq_send`)和卡片示例。
379
+
380
+ 细节见 **[用户手册](./docs/USER-GUIDE.md)** 的「自定义 QQ 工具」一节。
355
381
  ## 🔨 锤子面板:禁言 / 踢人(v1.6.3)
356
382
 
357
383
  dock 面板里的 **🔨 锤子**(原来叫「🔇 禁言」),下面分两页:
package/README_EN.md CHANGED
@@ -391,6 +391,31 @@ export default {
391
391
  ```
392
392
  Then run `/tools-reload` (or ask the AI to call the `tools_reload` tool). Commands need a host restart.
393
393
 
394
+ ### Cross-channel tools: `expose: 'all'` (v1.8.0+)
395
+
396
+ By default an extension tool exists **only in QQ conversations** (that is where the plugin mounts them — zero token cost anywhere else).
397
+ One extra line makes it **visible to every conversation** (web / terminal / any non-QQ channel) and able to **act on QQ** from there:
398
+
399
+ ```js
400
+ export default {
401
+ name: 'qq_send',
402
+ expose: 'all', // ← the switch
403
+ description: 'list QQ chats / send a message to a group or DM',
404
+ inputSchema: { action: { type: 'string' }, target: { type: 'string' }, text: { type: 'string' } },
405
+ run: async (args, env) => {
406
+ const qq = env.qq; // ← injected only for expose:'all'
407
+ return { ok: true, msg: (await qq.sendText('group', args.target, args.text)) ? 'sent ✓' : 'failed' };
408
+ },
409
+ };
410
+ ```
411
+
412
+ - **Registered name** becomes `<name>__<preset_id>` (e.g. `qq_send__whale-girl`), so you can tell which persona/account it belongs to.
413
+ ⚠️ **Tool names may only contain `[a-zA-Z0-9_-]`** — a character like `@` makes the LLM API reject the **whole request** (`Invalid 'tools[N].name'`). The plugin now validates first and simply **refuses** illegal names instead of breaking every turn.
414
+ - **`env.qq`** = that persona's account pack: `{ preset, appId, appSecret, owners, groups:[{gid,name,lastAt}], c2c:[{openid,name,lastAt}], sender, sendText(scope,id,text), sendMarkdown(...), sendImage(...), apiCall(method,path,body) }`.
415
+ - **Sending is passive-first**: `sendText()` looks up the target conversation's latest inbound `msgId` and replies with it (passive — no active-message quota, and QQ will not drop it); on failure it **retries without the msgId** (active fallback). A passive window only exists in conversations with **recent activity** — a cold group has none and will always go active.
416
+ - Registration happens **at plugin startup** (no need to wait for a QQ message), idempotent and fail-soft.
417
+ - **New users get examples for free**: at startup the plugin copies `examples/ext-tools/*.mjs` and `examples/botplay/*.mjs` into `{dataRoot}/.qqbot-extensions/{tools,botplay}/` — **only missing files**, never overwriting what you wrote.
418
+
394
419
  **For AI agents — you can write these yourself (no feature request needed):**
395
420
  - Card bodies are **plain markdown you write** (`#`, bold, `![alt](url)`, fenced code). This plugin has **no template engine — the model is the engine**. Cards with buttons go through a botplay event or the dock card editor (button callbacks must be registered host-side).
396
421
  - Extensions live in the **account data directory** (`.qqbot-extensions/`), never inside the plugin package → **upgrading or reinstalling the plugin never overwrites them**.
@@ -1 +1 @@
1
- {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"channel-tools.d.ts","sourceRoot":"","sources":["../src/channel-tools.ts"],"names":[],"mappings":"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"}
@@ -251,6 +251,11 @@ function buildWorkspaceQqPack(tag, dataRoot) {
251
251
  try {
252
252
  const rec = mgr?.getSessionRecord?.(target.scope, id);
253
253
  passiveMsgId = String(rec?.replyTarget?.msgId ?? '');
254
+ // 诊断(2026-10-10):被动窗口到底有没有 —— 排查"为何走了主动通道"用
255
+ try {
256
+ extGlobDiag('send window=' + (passiveMsgId ? ('Y/' + passiveMsgId.slice(0, 10)) : 'N') + ' rec=' + (rec ? 'Y' : 'N') + ' key=' + String(rec && rec.sessionKey || '') + ' -> ' + target.scope + ':' + id.slice(0, 12));
257
+ }
258
+ catch { /* ignore */ }
254
259
  }
255
260
  catch { /* ignore */ }
256
261
  if (passiveMsgId && typeof snd.sendMarkdown === 'function') {
@@ -1030,6 +1035,8 @@ export async function apply(ctx) {
1030
1035
  const replyGateTool = defineTool({
1031
1036
  name: 'reply_gate',
1032
1037
  description: '回复闸门: 开始回复前先调用它, 决定本回合开口还是静默。传 reply:true → 正常继续, 按当前对话决定说什么; 传 reply:false → 本回合立即静默终止, 你不会再输出任何内容(这条消息就像没发生过)。选 true 还是 false 由你根据当前对话自己判断: 觉得该回应就 true, 觉得安静待着更好就 false。' +
1038
+ '\n✅ **收到 reply:false 后,你会看到一条 `tool call aborted` 之类的"报错" —— 那正是「静默成功」本身**(本工具就是靠中断输出实现的,设计如此),不是工具坏了、也不是调用失败。看到它请直接收手,什么都不用做、也不用重试。' +
1039
+ '\n⛔ **不要为了"想安静"去动别的开关**(例如把出站模式切成 silent/静默):那会影响**后续所有回合**,而你要的只是"这一轮别说话"。要静默,只走本工具。' +
1033
1040
  '\n⚠️ 传 reply:false 后**不要再调用任何工具**(包括记笔记、记喜好、发表情包): 本回合已经关闭, 那些调用会被直接中止 —— 想记录的话, 留到下一次开口的回合里做。',
1034
1041
  parameters: {
1035
1042
  reply: { type: 'boolean', required: true, description: 'true=正常开口回复; false=静默终止本回合(不再输出任何内容)' },
@@ -2524,8 +2531,21 @@ export async function apply(ctx) {
2524
2531
  // ★ expose:'all' 的工具**不在这里注册** —— 交给 ensureExtToolsGlobal 挂到插件根
2525
2532
  // (只有挂到那一层,web / 终端等非 QQ 会话才继承得到;在这儿重复注册的话,
2526
2533
  // QQ 会话会同时看到「带后缀」和「不带后缀」两套工具)
2527
- if (def.expose === 'all')
2534
+ // ★ 2026-10-10 修(主人实测抓到):工具可能被改成 expose:'all'(或改回)⇒
2535
+ // 注册名会在「原名 ↔ 原名__人设ID」之间切换。旧名字那份**必须回收**,
2536
+ // 否则会同时存在两份同样的工具(同一份 schema 进上下文两次 ⇒ 白占 token)。
2537
+ if (def.expose === 'all') {
2538
+ const stale = myDisposers.get(def.name);
2539
+ if (stale) {
2540
+ try {
2541
+ stale();
2542
+ }
2543
+ catch { /* 旧句柄已失效 */ }
2544
+ myDisposers.delete(def.name);
2545
+ extDiag('回收旧注册(该工具已改为 expose:all): ' + def.name);
2546
+ }
2528
2547
  continue;
2548
+ }
2529
2549
  try {
2530
2550
  // 注:expose:'all' 的工具已在循环开头 continue(它们由 ensureExtToolsGlobal 全局注册)。
2531
2551
  // 走到这里的都是"只服务 QQ 会话"的工具 ⇒ 名字保持原名,不带 @人设id