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/index.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import lockfile from "proper-lockfile";
|
|
3
|
+
import { Plugin } from "@opencode/plugin";
|
|
4
|
+
import { Approver } from "./approver.js";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { createGatewayUrlFetcher, QQGateway } from "./qq/gateway.js";
|
|
7
|
+
import { QQApi } from "./qq/api.js";
|
|
8
|
+
import { AuthManager } from "./qq/auth.js";
|
|
9
|
+
import { StreamSender } from "./qq/stream.js";
|
|
10
|
+
import {
|
|
11
|
+
SESSIONS_PATH,
|
|
12
|
+
LOCK_PATH,
|
|
13
|
+
REST_BASE_PROD,
|
|
14
|
+
REST_BASE_SANDBOX,
|
|
15
|
+
INTENT_GROUP_AND_C2C,
|
|
16
|
+
APPROVAL_TIMEOUT_MS,
|
|
17
|
+
PASSIVE_WINDOW_MS,
|
|
18
|
+
STREAM_FLUSH_INTERVAL_MS,
|
|
19
|
+
} from "./constants.js";
|
|
20
|
+
import { EventPusher } from "./event-pusher.js";
|
|
21
|
+
import { toolExecuteAfterHook } from "./relay.js";
|
|
22
|
+
import { AssistantTextBuffer } from "./stream-buffer.js";
|
|
23
|
+
import { SessionManager } from "./session-manager.js";
|
|
24
|
+
import { splitText } from "./util/chunk.js";
|
|
25
|
+
import { guessImageMime, toImageDataUrl } from "./util/media.js";
|
|
26
|
+
import { log } from "./logger.js";
|
|
27
|
+
|
|
28
|
+
const REPLY_TIMEOUT_MS = 180_000; // 等待会话结束的超时兜底
|
|
29
|
+
const LOCK_RETRY_MS = 5000; // 待命实例抢锁间隔
|
|
30
|
+
const LOCK_STALE_MS = 30000; // 锁文件超过 30 秒无心跳视为僵尸锁
|
|
31
|
+
const LOCK_UPDATE_MS = 10000; // 宿主每 10 秒心跳刷新锁
|
|
32
|
+
|
|
33
|
+
// 幂等保护:opencode 启动时会分两个阶段加载本地插件,
|
|
34
|
+
// 第二次加载的 setup 直接跳过,避免重复启动 QQ 网关导致报错。
|
|
35
|
+
let started = false;
|
|
36
|
+
|
|
37
|
+
// 抢文件锁:成功返回 release 函数;被占/异常返回 null
|
|
38
|
+
async function tryAcquireFileLock(lockPath) {
|
|
39
|
+
try {
|
|
40
|
+
const release = await lockfile.lock(lockPath, {
|
|
41
|
+
retries: 0,
|
|
42
|
+
stale: LOCK_STALE_MS,
|
|
43
|
+
update: LOCK_UPDATE_MS,
|
|
44
|
+
realpath: false,
|
|
45
|
+
});
|
|
46
|
+
return release;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default Plugin.define({
|
|
53
|
+
id: "opencode-qq",
|
|
54
|
+
async setup(ctx) {
|
|
55
|
+
if (started) return;
|
|
56
|
+
started = true;
|
|
57
|
+
log("[setup] 插件启动,加载配置...");
|
|
58
|
+
const cfg = loadConfig();
|
|
59
|
+
if (!cfg) {
|
|
60
|
+
log("[setup] 缺少凭据,插件未启用");
|
|
61
|
+
console.warn("[opencode-qq] 缺少 QQ_BOT_APPID/QQ_BOT_APPSECRET 或配置文件,插件未启用");
|
|
62
|
+
started = false;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
log("[setup] 配置已加载 sandbox=", cfg.sandbox, "model=", cfg.model ?? "(默认)", "streaming=", cfg.streaming);
|
|
66
|
+
|
|
67
|
+
const allowSet = new Set(cfg.allowlist);
|
|
68
|
+
const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
|
|
69
|
+
const auth = new AuthManager(cfg.appId, cfg.appSecret);
|
|
70
|
+
const api = new QQApi({ restBase, getToken: () => auth.getToken() });
|
|
71
|
+
const approver = new Approver(APPROVAL_TIMEOUT_MS);
|
|
72
|
+
|
|
73
|
+
// ---- 事件收集器(V1 总线形状 {type, properties})----
|
|
74
|
+
const listeners = [];
|
|
75
|
+
// sessionID → resolve(text):session.prompt 排队后等会话 idle
|
|
76
|
+
const idleWaiters = new Map();
|
|
77
|
+
|
|
78
|
+
// ---- opencode 桥接(V2 Promise API)----
|
|
79
|
+
let cachedModel = null;
|
|
80
|
+
const resolveModel = async () => {
|
|
81
|
+
if (cachedModel) return cachedModel;
|
|
82
|
+
log("[bridge] 解析默认模型...");
|
|
83
|
+
if (cfg.model) {
|
|
84
|
+
const idx = cfg.model.indexOf("/");
|
|
85
|
+
if (idx > 0) {
|
|
86
|
+
cachedModel = { providerID: cfg.model.slice(0, idx), modelID: cfg.model.slice(idx + 1) };
|
|
87
|
+
} else {
|
|
88
|
+
cachedModel = { providerID: "unknown", modelID: cfg.model };
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
const def = await ctx.model.default();
|
|
92
|
+
const m = def?.data;
|
|
93
|
+
if (!m) {
|
|
94
|
+
throw new Error("未配置 model,请在 opencode-qq.json 中设置 model: 'providerID/modelID'");
|
|
95
|
+
}
|
|
96
|
+
cachedModel = { providerID: m.providerID, modelID: m.id };
|
|
97
|
+
}
|
|
98
|
+
log("[bridge] 模型:", cachedModel.providerID, "/", cachedModel.modelID);
|
|
99
|
+
return cachedModel;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const bridge = {
|
|
103
|
+
async sessionCreate(title) {
|
|
104
|
+
log("[bridge] 创建会话 title=", title);
|
|
105
|
+
const res = await ctx.session.create({ title });
|
|
106
|
+
log("[bridge] 会话已创建 id=", res?.id);
|
|
107
|
+
return { id: res.id };
|
|
108
|
+
},
|
|
109
|
+
async sessionPrompt(id, text, noReply, files) {
|
|
110
|
+
log("[bridge] sessionPrompt id=", id, "noReply=", noReply, "text=", text.slice(0, 40));
|
|
111
|
+
const model = await resolveModel();
|
|
112
|
+
try {
|
|
113
|
+
log("[bridge] 切换模型...");
|
|
114
|
+
await ctx.session.switchModel({
|
|
115
|
+
sessionID: id,
|
|
116
|
+
model: { id: model.modelID, providerID: model.providerID },
|
|
117
|
+
});
|
|
118
|
+
log("[bridge] 模型已切换");
|
|
119
|
+
} catch (e) {
|
|
120
|
+
log("[bridge] 切换模型失败(继续):", String(e?.message ?? e));
|
|
121
|
+
}
|
|
122
|
+
log("[bridge] 排队 prompt...");
|
|
123
|
+
await ctx.session.prompt({
|
|
124
|
+
sessionID: id,
|
|
125
|
+
text,
|
|
126
|
+
files: (files ?? []).map((f) => ({
|
|
127
|
+
uri: f.dataUrl,
|
|
128
|
+
name: `image.${(f.mime ?? "image/png").split("/")[1] ?? "png"}`,
|
|
129
|
+
})),
|
|
130
|
+
delivery: "steer",
|
|
131
|
+
});
|
|
132
|
+
log("[bridge] prompt 已排队");
|
|
133
|
+
if (noReply) return { parts: [] };
|
|
134
|
+
// V2 的 prompt 是 fire-and-forget,回复通过事件流返回:等待 session.idle
|
|
135
|
+
log("[bridge] 等待 session.idle...");
|
|
136
|
+
const out = await new Promise((resolve) => {
|
|
137
|
+
idleWaiters.set(id, resolve);
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
if (idleWaiters.get(id) === resolve) {
|
|
140
|
+
idleWaiters.delete(id);
|
|
141
|
+
log("[bridge] 等待超时,返回当前已累计文本");
|
|
142
|
+
resolve(assistantBuf.text(id) ?? "");
|
|
143
|
+
}
|
|
144
|
+
}, REPLY_TIMEOUT_MS);
|
|
145
|
+
});
|
|
146
|
+
log("[bridge] 收到回复:", out.slice(0, 80));
|
|
147
|
+
return { parts: out ? [{ type: "text", text: out }] : [] };
|
|
148
|
+
},
|
|
149
|
+
resolveModel,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const sessions = new SessionManager(bridge, SESSIONS_PATH(), fs, (sid) =>
|
|
153
|
+
approver.countBySession(sid),
|
|
154
|
+
);
|
|
155
|
+
bridge.onSessionReset = (sid) => approver.clearSession(sid);
|
|
156
|
+
|
|
157
|
+
// ---- 流式缓冲 / 事件推送 ----
|
|
158
|
+
const assistantBuf = new AssistantTextBuffer((sid) => sessions.isOurSession(sid));
|
|
159
|
+
const pusher = new EventPusher({
|
|
160
|
+
isOurSession: (sid) => sessions.isOurSession(sid),
|
|
161
|
+
openidOfSession: (sid) => {
|
|
162
|
+
for (const [openid, s] of Object.entries(sessions.snapshot())) if (s === sid) return openid;
|
|
163
|
+
return null;
|
|
164
|
+
},
|
|
165
|
+
send: async (openid, text) => {
|
|
166
|
+
for (const part of splitText(text)) await api.sendC2C(openid, part);
|
|
167
|
+
},
|
|
168
|
+
toolProgress: cfg.events.toolProgress,
|
|
169
|
+
lastAssistantText: (sid) => assistantBuf.text(sid),
|
|
170
|
+
subscribe: (h) => listeners.push(h),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---- 下行:QQ 消息处理 ----
|
|
174
|
+
const passiveRefs = new Map(); // openid → 最近一条
|
|
175
|
+
const pendingNotice = new Map(); // openid → 超窗未送达说明
|
|
176
|
+
const streams = new Map();
|
|
177
|
+
|
|
178
|
+
const beginStream = (openid) => {
|
|
179
|
+
if (!cfg.streaming) return null;
|
|
180
|
+
const ref = passiveRefs.get(openid);
|
|
181
|
+
if (!ref) return null;
|
|
182
|
+
const old = streams.get(openid);
|
|
183
|
+
if (old?.sender) old.sender.failed = true; // 重入保护:旧流作废
|
|
184
|
+
const sctx = { sender: null, msgId: ref.msgId, lastLen: 0 };
|
|
185
|
+
streams.set(openid, sctx);
|
|
186
|
+
return sctx;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const endStream = (openid, fullText, handle) => {
|
|
190
|
+
const sctx = streams.get(openid);
|
|
191
|
+
if (!handle || sctx !== handle) return false; // 已被更新消息覆盖
|
|
192
|
+
streams.delete(openid);
|
|
193
|
+
if (!handle.sender || handle.sender.failed) return false; // 无增量或失败 → 回落普通回复
|
|
194
|
+
void handle.sender.finish(fullText);
|
|
195
|
+
return true;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
async function replyTo(openid, text, format = "text") {
|
|
199
|
+
const ref = passiveRefs.get(openid);
|
|
200
|
+
for (const chunk of splitText(text)) {
|
|
201
|
+
const usePassive = !!ref && Date.now() - ref.receivedAt < PASSIVE_WINDOW_MS;
|
|
202
|
+
try {
|
|
203
|
+
await api.sendC2C(openid, chunk, usePassive ? { msgId: ref.msgId, format } : { format });
|
|
204
|
+
log("[reply] 已发送 (passive=", usePassive, "):", chunk.slice(0, 50));
|
|
205
|
+
} catch (e) {
|
|
206
|
+
log("[reply] 发送失败:", String(e?.message ?? e));
|
|
207
|
+
if (!usePassive) pendingNotice.set(openid, "(此前有未能送达的消息)");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const gateway = new QQGateway({
|
|
213
|
+
getGatewayUrl: createGatewayUrlFetcher(restBase, () => auth.getToken()),
|
|
214
|
+
getToken: () => auth.getToken(),
|
|
215
|
+
intents: INTENT_GROUP_AND_C2C,
|
|
216
|
+
connected: () => pusher.setOnline(true),
|
|
217
|
+
disconnected: () => pusher.setOnline(false),
|
|
218
|
+
message: async (msg) => {
|
|
219
|
+
let stream = null;
|
|
220
|
+
try {
|
|
221
|
+
log("[msg] 收到消息 openid=", msg.openid, "content=", msg.content.slice(0, 100));
|
|
222
|
+
if (allowSet.size > 0 && !allowSet.has(msg.openid)) {
|
|
223
|
+
log("[msg] 被 allowlist 拦截");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
|
|
227
|
+
log("[msg] 发送 ack...");
|
|
228
|
+
await replyTo(msg.openid, "收到,正在处理");
|
|
229
|
+
log("[msg] ack 已发送");
|
|
230
|
+
|
|
231
|
+
// 远程审批回复优先
|
|
232
|
+
const parsed = approver.parseReply(msg.content.trim());
|
|
233
|
+
if (parsed) {
|
|
234
|
+
const item = approver.confirm(parsed.seq);
|
|
235
|
+
if (!item) {
|
|
236
|
+
await replyTo(msg.openid, `#${parsed.seq} 不存在或已超时。`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// V2:permission.reply 用 { sessionID, requestID, decision }
|
|
240
|
+
await ctx.permission.reply({
|
|
241
|
+
sessionID: item.sessionId,
|
|
242
|
+
requestID: item.permissionId,
|
|
243
|
+
decision: parsed.reply, // "once" | "reject"
|
|
244
|
+
});
|
|
245
|
+
await replyTo(msg.openid, `已${parsed.reply === "once" ? "批准" : "拒绝"} #${parsed.seq}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const notice = pendingNotice.get(msg.openid);
|
|
250
|
+
pendingNotice.delete(msg.openid);
|
|
251
|
+
|
|
252
|
+
// 附件下载为 data URL,失败降级为纯文字
|
|
253
|
+
const files = [];
|
|
254
|
+
for (const att of msg.attachments ?? []) {
|
|
255
|
+
try {
|
|
256
|
+
files.push({ mime: guessImageMime(att.url), dataUrl: await toImageDataUrl(att.url) });
|
|
257
|
+
} catch {
|
|
258
|
+
await replyTo(msg.openid, "⚠️ 图片下载失败,仅处理文字部分").catch(() => {});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const promptText =
|
|
262
|
+
(msg.quotedText ? `[引用消息] ${msg.quotedText}\n` : "") +
|
|
263
|
+
(files.length ? `[图片 x${files.length}] ` : "") +
|
|
264
|
+
msg.content;
|
|
265
|
+
|
|
266
|
+
stream = beginStream(msg.openid);
|
|
267
|
+
log("[msg] 开始 dispatch...");
|
|
268
|
+
const answer = await sessions.dispatch(msg.openid, promptText, files);
|
|
269
|
+
log("[msg] dispatch 结果:", answer.slice(0, 120));
|
|
270
|
+
const deliveredByStream = endStream(msg.openid, answer, stream);
|
|
271
|
+
if (!deliveredByStream) {
|
|
272
|
+
log("[msg] 发送回复...");
|
|
273
|
+
await replyTo(
|
|
274
|
+
msg.openid,
|
|
275
|
+
(notice ? `${notice}\n` : "") + answer,
|
|
276
|
+
cfg.markdownReply ? "markdown" : "text",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
} catch (e) {
|
|
280
|
+
log("[msg] 处理失败:", String(e).slice(0, 300));
|
|
281
|
+
if (stream && streams.get(msg.openid) === stream) streams.delete(msg.openid);
|
|
282
|
+
await replyTo(msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ---- 权限请求监听 ----
|
|
288
|
+
listeners.push((e) => {
|
|
289
|
+
if (e.type !== "permission.asked" && e.type !== "permission.updated") return;
|
|
290
|
+
const p = e.properties ?? {};
|
|
291
|
+
const sessionId = String(p.sessionID ?? "");
|
|
292
|
+
const permissionId = String(p.id ?? "");
|
|
293
|
+
if (!sessions.isOurSession(sessionId) || !permissionId) return;
|
|
294
|
+
const summary = String(p.action ?? p.title ?? p.type ?? "需要授权");
|
|
295
|
+
const seq = approver.register(sessionId, permissionId, summary);
|
|
296
|
+
const openid = (() => {
|
|
297
|
+
for (const [o, s] of Object.entries(sessions.snapshot())) if (s === sessionId) return o;
|
|
298
|
+
return null;
|
|
299
|
+
})();
|
|
300
|
+
if (openid) void replyTo(openid, approver.render(seq));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ---- V2 事件循环:把 V2 事件映射成 V1 总线形状分发给 listeners ----
|
|
304
|
+
function dispatchEvent(ev) {
|
|
305
|
+
const props = ev.data ?? {};
|
|
306
|
+
switch (ev.type) {
|
|
307
|
+
case "session.text.delta":
|
|
308
|
+
assistantBuf.handle({ type: "session.text.delta", properties: props });
|
|
309
|
+
break;
|
|
310
|
+
case "session.idle": {
|
|
311
|
+
const sid = String(props.sessionID ?? "");
|
|
312
|
+
// 先唤醒等待者拿全文,再清缓冲并通知
|
|
313
|
+
const w = idleWaiters.get(sid);
|
|
314
|
+
if (w) {
|
|
315
|
+
idleWaiters.delete(sid);
|
|
316
|
+
w(assistantBuf.text(sid) ?? "");
|
|
317
|
+
}
|
|
318
|
+
assistantBuf.clear(sid);
|
|
319
|
+
for (const h of listeners) h({ type: "session.idle", properties: props });
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case "session.execution.succeeded": {
|
|
323
|
+
// V2 实际完成事件是 execution.succeeded(不是 session.idle)
|
|
324
|
+
const sid = String(props.sessionID ?? "");
|
|
325
|
+
log("[event] 执行成功,唤醒等待者 sid=", sid, "text=", (assistantBuf.text(sid) ?? "").slice(0, 60));
|
|
326
|
+
const w = idleWaiters.get(sid);
|
|
327
|
+
if (w) {
|
|
328
|
+
idleWaiters.delete(sid);
|
|
329
|
+
w(assistantBuf.text(sid) ?? "");
|
|
330
|
+
}
|
|
331
|
+
assistantBuf.clear(sid);
|
|
332
|
+
for (const h of listeners) h({ type: "session.idle", properties: props });
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
case "session.execution.failed": {
|
|
336
|
+
const sid = String(props.sessionID ?? "");
|
|
337
|
+
const w = idleWaiters.get(sid);
|
|
338
|
+
if (w) {
|
|
339
|
+
idleWaiters.delete(sid);
|
|
340
|
+
w(assistantBuf.text(sid) ?? "");
|
|
341
|
+
}
|
|
342
|
+
for (const h of listeners)
|
|
343
|
+
h({ type: "session.error", properties: { sessionID: sid, error: props.error ?? props } });
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case "permission.asked":
|
|
347
|
+
for (const h of listeners) h({ type: "permission.asked", properties: props });
|
|
348
|
+
break;
|
|
349
|
+
default:
|
|
350
|
+
for (const h of listeners) h({ type: ev.type, properties: props });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const eventTask = (async () => {
|
|
355
|
+
log("[event] 开始订阅事件流...");
|
|
356
|
+
for await (const ev of ctx.event.subscribe()) {
|
|
357
|
+
log("[event] 收到事件:", ev.type);
|
|
358
|
+
dispatchEvent(ev);
|
|
359
|
+
}
|
|
360
|
+
})().catch((err) => {
|
|
361
|
+
log("[event] 订阅结束/错误:", String(err));
|
|
362
|
+
console.warn("[opencode-qq] 事件订阅结束:", String(err));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ---- 工具钩子(V2)----
|
|
366
|
+
const toolReg = await ctx.tool.hook(
|
|
367
|
+
"execute.after",
|
|
368
|
+
toolExecuteAfterHook((e) => {
|
|
369
|
+
for (const h of listeners) h(e);
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// ---- 流式增量:session.text.delta 累计 → 节流推送 stream_messages ----
|
|
374
|
+
const flushTimer = setInterval(() => {
|
|
375
|
+
const snapshot = sessions.snapshot();
|
|
376
|
+
for (const [openid, sid] of Object.entries(snapshot)) {
|
|
377
|
+
const bufText = assistantBuf.text(sid);
|
|
378
|
+
if (!bufText) continue;
|
|
379
|
+
const sctx = streams.get(openid);
|
|
380
|
+
if (!sctx || sctx.sender?.failed) continue;
|
|
381
|
+
if (!sctx.sender) {
|
|
382
|
+
sctx.sender = new StreamSender(
|
|
383
|
+
{ restBase, getToken: () => auth.getToken() },
|
|
384
|
+
{ openid, msgId: sctx.msgId, msgSeq: 2 },
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (bufText.length <= sctx.lastLen) continue; // 无新增内容不重发
|
|
388
|
+
sctx.lastLen = bufText.length;
|
|
389
|
+
void sctx.sender.update(bufText);
|
|
390
|
+
}
|
|
391
|
+
}, STREAM_FLUSH_INTERVAL_MS);
|
|
392
|
+
|
|
393
|
+
// ---- 单实例锁:多个 opencode 实例时,只有一个会连接 QQ 网关 ----
|
|
394
|
+
const lockPath = LOCK_PATH(cfg.appId);
|
|
395
|
+
let releaseLock = await tryAcquireFileLock(lockPath);
|
|
396
|
+
let lockTimer = null;
|
|
397
|
+
|
|
398
|
+
const startGateway = () => {
|
|
399
|
+
log("[lock] 启动 QQ 网关");
|
|
400
|
+
gateway.start();
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
if (releaseLock) {
|
|
404
|
+
log("[lock] 抢锁成功,本实例为 QQ bot 宿主");
|
|
405
|
+
startGateway();
|
|
406
|
+
} else {
|
|
407
|
+
log("[lock] 锁被占用,本实例进入待命,每", LOCK_RETRY_MS, "ms 重试抢锁");
|
|
408
|
+
lockTimer = setInterval(async () => {
|
|
409
|
+
releaseLock = await tryAcquireFileLock(lockPath);
|
|
410
|
+
if (releaseLock) {
|
|
411
|
+
clearInterval(lockTimer);
|
|
412
|
+
lockTimer = null;
|
|
413
|
+
log("[lock] 抢锁成功,接管 QQ bot");
|
|
414
|
+
startGateway();
|
|
415
|
+
}
|
|
416
|
+
}, LOCK_RETRY_MS);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return async () => {
|
|
420
|
+
clearInterval(flushTimer);
|
|
421
|
+
if (lockTimer) clearInterval(lockTimer);
|
|
422
|
+
assistantBuf.clearAll();
|
|
423
|
+
gateway.stop();
|
|
424
|
+
pusher.dispose();
|
|
425
|
+
if (releaseLock) {
|
|
426
|
+
try {
|
|
427
|
+
await releaseLock();
|
|
428
|
+
} catch {
|
|
429
|
+
/* ignore */
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
await toolReg.dispose();
|
|
434
|
+
} catch {
|
|
435
|
+
/* ignore */
|
|
436
|
+
}
|
|
437
|
+
started = false;
|
|
438
|
+
// eventTask 无法主动取消,随插件生命周期结束
|
|
439
|
+
};
|
|
440
|
+
},
|
|
441
|
+
});
|
package/logger.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const LOG_PATH = path.join(
|
|
6
|
+
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"),
|
|
7
|
+
"opencode",
|
|
8
|
+
"opencode-qq.log",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
function ts() {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function log(...args) {
|
|
16
|
+
const line = `[${ts()}] ${args
|
|
17
|
+
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
|
18
|
+
.join(" ")}\n`;
|
|
19
|
+
try {
|
|
20
|
+
fs.appendFileSync(LOG_PATH, line, "utf8");
|
|
21
|
+
} catch {
|
|
22
|
+
/* ignore */
|
|
23
|
+
}
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-qq-v2",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "QQ bot (C2C) <-> opencode bridge plugin (V2 port)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": ["opencode", "opencode-plugin", "qq", "bot"],
|
|
9
|
+
"files": ["*.js", "qq", "util", "README.md", "LICENSE"],
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"proper-lockfile": "^4.1.2",
|
|
12
|
+
"ws": "^8.18.0"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@opencode/plugin": "^2.0.0",
|
|
16
|
+
"effect": "^4.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/qq/api.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { MAX_REPLIES_PER_MSG_ID } from "../constants";
|
|
2
|
+
export class QQApi {
|
|
3
|
+
opts;
|
|
4
|
+
fetchFn;
|
|
5
|
+
queue = Promise.resolve();
|
|
6
|
+
seqCounters = new Map();
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.opts = opts;
|
|
9
|
+
this.fetchFn = opts.fetchFn ?? fetch;
|
|
10
|
+
}
|
|
11
|
+
/** 串行化所有发送,避免打爆频控 */
|
|
12
|
+
sendC2C(openid, content, options = {}) {
|
|
13
|
+
const task = this.queue.then(() => this.doSend(openid, content, options));
|
|
14
|
+
this.queue = task.catch(() => { }); // 吞掉错误保持队列继续
|
|
15
|
+
return task;
|
|
16
|
+
}
|
|
17
|
+
nextSeq(msgId) {
|
|
18
|
+
if (!msgId)
|
|
19
|
+
return undefined;
|
|
20
|
+
const used = this.seqCounters.get(msgId) ?? 0;
|
|
21
|
+
if (used >= MAX_REPLIES_PER_MSG_ID)
|
|
22
|
+
return undefined; // 额度用尽 → 调用方降级主动消息
|
|
23
|
+
const seq = used + 1;
|
|
24
|
+
this.seqCounters.set(msgId, seq);
|
|
25
|
+
if (this.seqCounters.size > 500)
|
|
26
|
+
this.seqCounters.clear();
|
|
27
|
+
return seq;
|
|
28
|
+
}
|
|
29
|
+
async postWithRetry(openid, body) {
|
|
30
|
+
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
31
|
+
const token = await this.opts.getToken();
|
|
32
|
+
const res = await this.fetchFn(`${this.opts.restBase}/v2/users/${openid}/messages`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
});
|
|
37
|
+
if (res.status === 429 && attempt < 3) {
|
|
38
|
+
const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
|
|
39
|
+
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 30) * 1000));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`sendC2C failed: HTTP ${res.status} ${await res.text()}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async doSend(openid, content, options) {
|
|
48
|
+
const format = options.format ?? "text";
|
|
49
|
+
let msgId = options.msgId;
|
|
50
|
+
let seqReserved;
|
|
51
|
+
if (msgId) {
|
|
52
|
+
seqReserved = this.nextSeq(msgId);
|
|
53
|
+
if (seqReserved === undefined)
|
|
54
|
+
msgId = undefined; // 被动额度用尽 → 主动消息
|
|
55
|
+
}
|
|
56
|
+
const makeBody = (fmt) => {
|
|
57
|
+
const body = fmt === "markdown" ? { msg_type: 2, markdown: { content } } : { msg_type: 0, content };
|
|
58
|
+
if (msgId && seqReserved !== undefined) {
|
|
59
|
+
body.msg_id = msgId;
|
|
60
|
+
body.msg_seq = seqReserved;
|
|
61
|
+
}
|
|
62
|
+
return body;
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
await this.postWithRetry(openid, makeBody(format));
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (format === "markdown") {
|
|
69
|
+
await this.postWithRetry(openid, makeBody("text")); // 降级复用同一 msg_seq
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
package/qq/auth.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TOKEN_URL } from "../constants";
|
|
2
|
+
export class AuthManager {
|
|
3
|
+
appId;
|
|
4
|
+
appSecret;
|
|
5
|
+
fetchFn;
|
|
6
|
+
token = null;
|
|
7
|
+
expireAt = 0;
|
|
8
|
+
constructor(appId, appSecret, fetchFn = fetch) {
|
|
9
|
+
this.appId = appId;
|
|
10
|
+
this.appSecret = appSecret;
|
|
11
|
+
this.fetchFn = fetchFn;
|
|
12
|
+
}
|
|
13
|
+
async getToken() {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
if (this.token && now < this.expireAt - 60_000)
|
|
16
|
+
return this.token;
|
|
17
|
+
const res = await this.fetchFn(TOKEN_URL, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify({ appId: this.appId, clientSecret: this.appSecret }),
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
throw new Error(`getAppAccessToken failed: HTTP ${res.status}`);
|
|
24
|
+
const data = (await res.json());
|
|
25
|
+
this.token = data.access_token;
|
|
26
|
+
this.expireAt = Date.now() + Number(data.expires_in) * 1000;
|
|
27
|
+
return this.token;
|
|
28
|
+
}
|
|
29
|
+
}
|