opencode-qq-v2 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/approver.js +52 -0
- package/commands.js +6 -0
- package/config.js +25 -0
- package/constants.js +16 -0
- package/event-pusher.js +81 -0
- package/index.js +441 -0
- package/logger.js +24 -0
- package/package.json +16 -4
- package/qq/api.js +75 -0
- package/qq/auth.js +29 -0
- package/qq/gateway.js +213 -0
- package/qq/stream.js +99 -0
- package/relay.js +26 -0
- package/session-manager.js +98 -0
- package/stream-buffer.js +36 -0
- package/util/chunk.js +46 -0
- package/util/media.js +19 -0
- package/util/quote.js +25 -0
- package/util/throttle.js +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opencode-qq contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-qq (V2 适配版)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 [OpenCode](https://opencode.ai) 接入 QQ 单聊的桥接插件:在 QQ 里和 AI 对话,驱动 opencode 干活。
|
|
4
|
+
|
|
5
|
+
> 本仓库基于 [opencode-qq](https://www.npmjs.com/package/opencode-qq) v0.1.0 移植,使其兼容 **OpenCode v2**(`@opencode/plugin` 2.x + EffectTS 运行时)。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- **对话驱动 opencode**:QQ 单聊消息直达 opencode 会话,AI 回复发回 QQ;每个 QQ 用户对应一个长期会话,重启后延续。
|
|
10
|
+
- **图片理解**:单聊里发的图片转交给 opencode 多模态处理。
|
|
11
|
+
- **任务完成 / 出错推送**:opencode 会话完成(✅)或出错(❌)时主动推送到 QQ。
|
|
12
|
+
- **QQ 远程审批权限**:opencode 发出权限请求时直接在 QQ 里回复「同意 / 拒绝」。
|
|
13
|
+
- **内置指令**:`/new` 重置会话、`/status` 查看状态、`/help` 帮助。
|
|
14
|
+
|
|
15
|
+
## V2 适配改动(相对原版 v0.1.0)
|
|
16
|
+
|
|
17
|
+
1. **插件入口**:`async (input) => Hooks`(V1)→ `Plugin.define({ id, setup(ctx) })`(V2 Promise API)。
|
|
18
|
+
2. **客户端调用**:`input.client.session.*` → `ctx.session.*`(create / prompt / switchModel)、`ctx.permission.reply`、`ctx.model.default`。
|
|
19
|
+
3. **事件模型**:`message.part.updated` → `session.text.delta`;完成事件 `session.idle` → `session.execution.succeeded`;`session.error` → `session.execution.failed`。
|
|
20
|
+
4. **工具钩子**:`tool.execute.after` Hooks 回调 → `ctx.tool.hook("execute.after", cb)`。
|
|
21
|
+
5. **取网关地址补鉴权**:`GET /gateway` 现在要求 `Authorization: QQBot <token>` 头,原版缺失导致一直 401。
|
|
22
|
+
6. **C2C 消息字段修正**:发送者 openid 取 `d.author.user_openid`(原版误用 `d.openid`),消息 ID 取 `d.id`(原版误用 `d.msg_id`)。
|
|
23
|
+
7. **配置路径修正**:Windows 上 `process.env.HOME` 为空,改用 `os.homedir()`。
|
|
24
|
+
8. **双加载幂等保护**:opencode 启动分两阶段加载本地插件,加 `started` 标志避免重复启动 QQ 网关。
|
|
25
|
+
9. **回复格式**:单聊不支持 markdown 消息类型(`msg_type:2`),默认改纯文本;流式 `stream_messages` 会产生不完整文本与 `●` 收尾标记,默认关闭流式。
|
|
26
|
+
10. **单实例锁 + 自动接管**:QQ 网关对同一 bot 只保留一个活跃连接,多实例会互踢。新增 `proper-lockfile` 跨平台文件锁(锁文件 `~/.config/opencode/opencode-qq-<appId>.lock`):只有抢到锁的实例连接 QQ;其余实例进入待命,每 5 秒重试,宿主退出后自动接管(崩溃则靠 30 秒心跳超时判定僵尸锁后接管)。
|
|
27
|
+
|
|
28
|
+
## 安装(本地路径)
|
|
29
|
+
|
|
30
|
+
1. 克隆本仓库到本地,例如 `~/.config/opencode/plugins/opencode-qq`。
|
|
31
|
+
2. 安装依赖:`npm install`(只需 `ws`)。
|
|
32
|
+
3. 在 `~/.config/opencode/opencode.json` 的 `plugin` 数组中加入本目录的**反斜杠绝对路径**:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"plugin": [
|
|
37
|
+
"C:\\Users\\<你>\\ .config\\opencode\\plugins\\opencode-qq"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> 注意用反斜杠(Windows 原生分隔符),正斜杠会被 opencode 规范化为另一份,导致重复加载。
|
|
43
|
+
|
|
44
|
+
4. 重启 opencode。
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
创建 `~/.config/opencode/opencode-qq.json`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"appId": "你的AppID",
|
|
53
|
+
"appSecret": "你的AppSecret",
|
|
54
|
+
"sandbox": true,
|
|
55
|
+
"allowlist": [],
|
|
56
|
+
"model": "deepseek/deepseek-v4-pro",
|
|
57
|
+
"markdownReply": false,
|
|
58
|
+
"streaming": false,
|
|
59
|
+
"events": { "toolProgress": false }
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| 字段 | 默认 | 说明 |
|
|
64
|
+
|------|------|------|
|
|
65
|
+
| `appId` / `appSecret` | 必填 | QQ 开放平台机器人凭据 |
|
|
66
|
+
| `sandbox` | `false` | 未提审上线的 bot 用 `true`(沙箱环境);已上线用 `false` |
|
|
67
|
+
| `allowlist` | `[]` | openid 白名单,空 = 不限制 |
|
|
68
|
+
| `model` | 无 | 覆盖模型,`providerID/modelID`;不填用 opencode 全局默认模型 |
|
|
69
|
+
| `markdownReply` | `true` | 是否用 Markdown 发送(**单聊请设 `false`,否则「该消息类型不支持查看」**) |
|
|
70
|
+
| `streaming` | `true` | 流式打字机(**有截断/`●` 尾巴问题,建议 `false`**) |
|
|
71
|
+
| `events.toolProgress` | `false` | 是否推送工具执行进度 |
|
|
72
|
+
|
|
73
|
+
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件凭据。
|
|
74
|
+
|
|
75
|
+
## 多实例行为(单实例锁)
|
|
76
|
+
|
|
77
|
+
QQ 网关对同一 bot 只保留一个活跃连接,多个 opencode 实例同时连接会互相踢下线。插件用 `proper-lockfile` 文件锁保证同一 bot 只有一个「宿主」实例连接 QQ。
|
|
78
|
+
|
|
79
|
+
| 场景 | 行为 |
|
|
80
|
+
|---|---|
|
|
81
|
+
| 第 1 个实例启动 | 抢到锁 → 成为宿主 → 连接 QQ |
|
|
82
|
+
| 第 2 个实例启动 | 抢锁失败 → 待命(不连 QQ,不互踢) |
|
|
83
|
+
| 宿主正常退出 | 释放锁 |
|
|
84
|
+
| 宿主崩溃 | 心跳中断,30 秒后锁被判为僵尸锁 |
|
|
85
|
+
| 待命实例 | 每 5 秒重试抢锁,成功后自动接管 |
|
|
86
|
+
|
|
87
|
+
- **锁文件**:`~/.config/opencode/opencode-qq-<appId>.lock`,按 AppID 隔离,不同 bot 互不影响。
|
|
88
|
+
- **心跳**:宿主每 10 秒刷新一次锁,证明自己还活着。
|
|
89
|
+
- **僵尸锁**:30 秒无心跳视为宿主已死,待命实例可抢占。
|
|
90
|
+
- **接管空窗**:宿主退出后,待命实例最多 5 秒(重试间隔)内接管,QQ bot 短暂下线几秒后自动恢复。
|
|
91
|
+
|
|
92
|
+
## 前置条件
|
|
93
|
+
|
|
94
|
+
1. 在 [QQ 开放平台](https://q.qq.com/) 创建机器人,拿到 **AppID** 与 **AppSecret**。
|
|
95
|
+
2. 未上线阶段需在管理端配置**沙箱单聊账号**,并把 `sandbox` 设为 `true`。
|
|
96
|
+
3. 正式环境需在管理端填写服务器公网出口 IP 白名单。
|
|
97
|
+
|
|
98
|
+
## 日志
|
|
99
|
+
|
|
100
|
+
调试日志写入 `~/.config/opencode/opencode-qq.log`,包含网关连接、消息收发、会话处理全链路,便于排查。
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
package/approver.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export class Approver {
|
|
2
|
+
timeoutMs;
|
|
3
|
+
pending = new Map();
|
|
4
|
+
nextSeq = 1;
|
|
5
|
+
constructor(timeoutMs) {
|
|
6
|
+
this.timeoutMs = timeoutMs;
|
|
7
|
+
}
|
|
8
|
+
register(sessionId, permissionId, summary) {
|
|
9
|
+
const seq = this.nextSeq++;
|
|
10
|
+
const timer = setTimeout(() => this.pending.delete(seq), this.timeoutMs);
|
|
11
|
+
this.pending.set(seq, { permissionId, sessionId, summary, timer });
|
|
12
|
+
return seq;
|
|
13
|
+
}
|
|
14
|
+
render(seq) {
|
|
15
|
+
const item = this.pending.get(seq);
|
|
16
|
+
return [
|
|
17
|
+
`[权限请求 #${seq}] ${item?.summary ?? ""}`,
|
|
18
|
+
`回复“同意 ${seq}”批准本次,回复“拒绝 ${seq}”拒绝。`,
|
|
19
|
+
].join("\n");
|
|
20
|
+
}
|
|
21
|
+
parseReply(text) {
|
|
22
|
+
const m = /^(同意|拒绝)\s*(\d+)$/.exec(text.trim());
|
|
23
|
+
if (!m)
|
|
24
|
+
return null;
|
|
25
|
+
return { reply: m[1] === "同意" ? "once" : "reject", seq: Number(m[2]) };
|
|
26
|
+
}
|
|
27
|
+
confirm(seq) {
|
|
28
|
+
const item = this.pending.get(seq);
|
|
29
|
+
if (!item)
|
|
30
|
+
return undefined;
|
|
31
|
+
clearTimeout(item.timer);
|
|
32
|
+
this.pending.delete(seq);
|
|
33
|
+
const { timer: _t, ...rest } = item;
|
|
34
|
+
return rest;
|
|
35
|
+
}
|
|
36
|
+
clearSession(sessionId) {
|
|
37
|
+
for (const [seq, item] of this.pending) {
|
|
38
|
+
if (item.sessionId === sessionId) {
|
|
39
|
+
clearTimeout(item.timer);
|
|
40
|
+
this.pending.delete(seq);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** 终审 I4a:某会话当前待审批数量(供 /status 展示) */
|
|
45
|
+
countBySession(sessionId) {
|
|
46
|
+
let n = 0;
|
|
47
|
+
for (const item of this.pending.values())
|
|
48
|
+
if (item.sessionId === sessionId)
|
|
49
|
+
n++;
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/commands.js
ADDED
package/config.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { CONFIG_PATH } from "./constants";
|
|
3
|
+
export function loadConfig(path = CONFIG_PATH()) {
|
|
4
|
+
let file = {};
|
|
5
|
+
try {
|
|
6
|
+
file = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// 文件不存在或非法不致命,凭据可完全来自环境变量
|
|
10
|
+
}
|
|
11
|
+
const appId = process.env.QQ_BOT_APPID ?? file.appId;
|
|
12
|
+
const appSecret = process.env.QQ_BOT_APPSECRET ?? file.appSecret;
|
|
13
|
+
if (!appId || !appSecret)
|
|
14
|
+
return null;
|
|
15
|
+
return {
|
|
16
|
+
appId,
|
|
17
|
+
appSecret,
|
|
18
|
+
sandbox: file.sandbox ?? false,
|
|
19
|
+
allowlist: file.allowlist ?? [],
|
|
20
|
+
events: { toolProgress: file.events?.toolProgress ?? false },
|
|
21
|
+
model: file.model,
|
|
22
|
+
markdownReply: file.markdownReply ?? true,
|
|
23
|
+
streaming: file.streaming ?? true,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/constants.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const TOKEN_URL = "https://api.bot.qq.com/app/getAppAccessToken";
|
|
5
|
+
export const REST_BASE_PROD = "https://api.bot.qq.com";
|
|
6
|
+
export const REST_BASE_SANDBOX = "https://sandbox.api.sgroup.qq.com";
|
|
7
|
+
export const INTENT_GROUP_AND_C2C = 1 << 25;
|
|
8
|
+
export const PASSIVE_WINDOW_MS = 60 * 60 * 1000; // 单聊被动窗口 60 分钟
|
|
9
|
+
export const MAX_REPLIES_PER_MSG_ID = 4; // 每条收到的消息最多被动回复次数(ack+结果 占 2 次)
|
|
10
|
+
export const APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
11
|
+
export const STREAM_FLUSH_INTERVAL_MS = 1200; // 流式打字机推送节流间隔
|
|
12
|
+
const CONFIG_DIR = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
13
|
+
export const CONFIG_PATH = () => path.join(CONFIG_DIR, "opencode", "opencode-qq.json");
|
|
14
|
+
export const SESSIONS_PATH = () => path.join(CONFIG_DIR, "opencode", "opencode-qq-sessions.json");
|
|
15
|
+
export const LOCK_PATH = (appId) => path.join(CONFIG_DIR, "opencode", `opencode-qq-${appId}.lock`);
|
|
16
|
+
export const GATEWAY_PATH = "/gateway";
|
package/event-pusher.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Throttler } from "./util/throttle";
|
|
2
|
+
const TOOL_PROGRESS_INTERVAL_MS = 60_000;
|
|
3
|
+
const ERROR_SUMMARY_MAX = 300;
|
|
4
|
+
/**
|
|
5
|
+
* 终审 I2:props.error 是结构化对象(如 {name:"UnknownError", data:{message}}),
|
|
6
|
+
* 直接 String() 会得到 "[object Object]"。按 data.message → name → String 链提取。
|
|
7
|
+
*/
|
|
8
|
+
function summarizeError(error) {
|
|
9
|
+
if (error && typeof error === "object") {
|
|
10
|
+
const e = error;
|
|
11
|
+
const message = e.data && typeof e.data === "object" ? e.data.message : undefined;
|
|
12
|
+
if (typeof message === "string" && message)
|
|
13
|
+
return message.slice(0, ERROR_SUMMARY_MAX);
|
|
14
|
+
if (typeof e.name === "string" && e.name)
|
|
15
|
+
return e.name.slice(0, ERROR_SUMMARY_MAX);
|
|
16
|
+
}
|
|
17
|
+
return String(error ?? "未知错误").slice(0, ERROR_SUMMARY_MAX);
|
|
18
|
+
}
|
|
19
|
+
export class EventPusher {
|
|
20
|
+
deps;
|
|
21
|
+
throttler;
|
|
22
|
+
/** 断线补发队列 */
|
|
23
|
+
offlineQueue = [];
|
|
24
|
+
online = true;
|
|
25
|
+
constructor(deps) {
|
|
26
|
+
this.deps = deps;
|
|
27
|
+
this.throttler = new Throttler(deps.toolProgressIntervalMs ?? TOOL_PROGRESS_INTERVAL_MS, (key, lines) => {
|
|
28
|
+
const openid = key;
|
|
29
|
+
void this.deps
|
|
30
|
+
.send(openid, `🛠 工具进度:\n${lines.map((l) => `- ${l}`).join("\n")}`)
|
|
31
|
+
.catch(() => { });
|
|
32
|
+
});
|
|
33
|
+
deps.subscribe((evt) => this.handle(evt));
|
|
34
|
+
}
|
|
35
|
+
setOnline(online) {
|
|
36
|
+
this.online = online;
|
|
37
|
+
if (online) {
|
|
38
|
+
for (const item of this.offlineQueue.splice(0)) {
|
|
39
|
+
this.deps.send(item.openid, item.text).catch(() => { });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
deliver(openid, text) {
|
|
44
|
+
if (!this.online) {
|
|
45
|
+
this.offlineQueue.push({ openid, text });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.deps.send(openid, text).catch(() => { });
|
|
49
|
+
}
|
|
50
|
+
handle(evt) {
|
|
51
|
+
const props = evt.properties ?? {};
|
|
52
|
+
const sessionId = String(props.sessionID ?? props.sessionId ?? "");
|
|
53
|
+
if (!sessionId || !this.deps.isOurSession(sessionId))
|
|
54
|
+
return;
|
|
55
|
+
const openid = this.deps.openidOfSession(sessionId);
|
|
56
|
+
if (!openid)
|
|
57
|
+
return;
|
|
58
|
+
switch (evt.type) {
|
|
59
|
+
case "session.idle": {
|
|
60
|
+
const tail = this.deps.lastAssistantText?.(sessionId);
|
|
61
|
+
this.deliver(openid, tail ? `✅ 任务完成。\n摘要: ${tail.slice(-200)}` : "✅ 任务完成。");
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "session.error": {
|
|
65
|
+
const err = summarizeError(props.error);
|
|
66
|
+
this.deliver(openid, `❌ 出错: ${err}`);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "tool.execute.after":
|
|
70
|
+
if (this.deps.toolProgress) {
|
|
71
|
+
const tool = String(props.tool ?? "tool");
|
|
72
|
+
const title = String(props.title ?? props.description ?? "").slice(0, 80);
|
|
73
|
+
this.throttler.push(openid, `${tool}: ${title}`);
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
dispose() {
|
|
79
|
+
this.throttler.dispose();
|
|
80
|
+
}
|
|
81
|
+
}
|