qqbot-opencode 1.0.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 +197 -0
- package/bin/qqbot.js +16 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +154 -0
- package/dist/app.js.map +1 -0
- package/dist/bundle.cjs +850 -0
- package/dist/bundle.js +826 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +179 -0
- package/dist/config.js.map +1 -0
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js +3 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/message.d.ts +8 -0
- package/dist/handlers/message.d.ts.map +1 -0
- package/dist/handlers/message.js +57 -0
- package/dist/handlers/message.js.map +1 -0
- package/dist/handlers/session.d.ts +13 -0
- package/dist/handlers/session.d.ts.map +1 -0
- package/dist/handlers/session.js +104 -0
- package/dist/handlers/session.js.map +1 -0
- package/dist/opencode/client.d.ts +23 -0
- package/dist/opencode/client.d.ts.map +1 -0
- package/dist/opencode/client.js +141 -0
- package/dist/opencode/client.js.map +1 -0
- package/dist/opencode/index.d.ts +2 -0
- package/dist/opencode/index.d.ts.map +1 -0
- package/dist/opencode/index.js +2 -0
- package/dist/opencode/index.js.map +1 -0
- package/dist/qq/connection.d.ts +23 -0
- package/dist/qq/connection.d.ts.map +1 -0
- package/dist/qq/connection.js +188 -0
- package/dist/qq/connection.js.map +1 -0
- package/dist/qq/index.d.ts +5 -0
- package/dist/qq/index.d.ts.map +1 -0
- package/dist/qq/index.js +4 -0
- package/dist/qq/index.js.map +1 -0
- package/dist/qq/parser.d.ts +4 -0
- package/dist/qq/parser.d.ts.map +1 -0
- package/dist/qq/parser.js +99 -0
- package/dist/qq/parser.js.map +1 -0
- package/dist/qq/sender.d.ts +28 -0
- package/dist/qq/sender.d.ts.map +1 -0
- package/dist/qq/sender.js +123 -0
- package/dist/qq/sender.js.map +1 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
- package/src/app.ts +204 -0
- package/src/config.ts +200 -0
- package/src/handlers/index.ts +2 -0
- package/src/handlers/message.ts +86 -0
- package/src/handlers/session.ts +130 -0
- package/src/opencode/client.ts +204 -0
- package/src/opencode/index.ts +1 -0
- package/src/qq/connection.ts +252 -0
- package/src/qq/index.ts +9 -0
- package/src/qq/parser.ts +126 -0
- package/src/qq/sender.ts +215 -0
- package/src/types.ts +52 -0
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
function loadConfig(configPath) {
|
|
6
|
+
const absolutePath = path.resolve(configPath);
|
|
7
|
+
if (!fs.existsSync(absolutePath)) {
|
|
8
|
+
throw new Error(`Config file not found: ${absolutePath}`);
|
|
9
|
+
}
|
|
10
|
+
const rawContent = fs.readFileSync(absolutePath, "utf-8");
|
|
11
|
+
const config = yaml.load(rawContent);
|
|
12
|
+
validateConfig(config);
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
15
|
+
function validateConfig(config) {
|
|
16
|
+
if (!config.qq?.appId) {
|
|
17
|
+
throw new Error("Missing required field: qq.appId");
|
|
18
|
+
}
|
|
19
|
+
if (!config.qq?.clientSecret) {
|
|
20
|
+
throw new Error("Missing required field: qq.clientSecret");
|
|
21
|
+
}
|
|
22
|
+
if (!config.opencode?.port) {
|
|
23
|
+
config.opencode.port = 4096;
|
|
24
|
+
}
|
|
25
|
+
if (!config.opencode?.hostname) {
|
|
26
|
+
config.opencode.hostname = "127.0.0.1";
|
|
27
|
+
}
|
|
28
|
+
if (!config.app?.workingDir) {
|
|
29
|
+
config.app.workingDir = process.cwd();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function expandEnvVariables(obj) {
|
|
33
|
+
if (typeof obj === "string") {
|
|
34
|
+
const envVarPattern = /\$\{([^}]+)\}/g;
|
|
35
|
+
return obj.replace(envVarPattern, (_, envName) => {
|
|
36
|
+
return process.env[envName] || "";
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(obj)) {
|
|
40
|
+
return obj.map(expandEnvVariables);
|
|
41
|
+
}
|
|
42
|
+
if (obj && typeof obj === "object") {
|
|
43
|
+
const result = {};
|
|
44
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
45
|
+
result[key] = expandEnvVariables(value);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
return obj;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/qq/connection.ts
|
|
53
|
+
import WebSocket from "ws";
|
|
54
|
+
var INTENTS = {
|
|
55
|
+
GUILDS: 1 << 0,
|
|
56
|
+
GUILD_MEMBERS: 1 << 1,
|
|
57
|
+
PUBLIC_GUILD_MESSAGES: 1 << 30,
|
|
58
|
+
DIRECT_MESSAGE: 1 << 12,
|
|
59
|
+
GROUP_AND_C2C: 1 << 25
|
|
60
|
+
};
|
|
61
|
+
var FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
|
|
62
|
+
var API_BASE = "https://api.sgroup.qq.com";
|
|
63
|
+
var TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
64
|
+
var accessToken = null;
|
|
65
|
+
var sessionId = null;
|
|
66
|
+
var lastSeq = null;
|
|
67
|
+
var reconnectAttempts = 0;
|
|
68
|
+
var MAX_RECONNECT_ATTEMPTS = 10;
|
|
69
|
+
var RECONNECT_DELAY = 5e3;
|
|
70
|
+
async function getAccessToken(appId, clientSecret) {
|
|
71
|
+
const response = await fetch(TOKEN_URL, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
"Content-Type": "application/json"
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({ appId, clientSecret })
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
const errorText = await response.text();
|
|
80
|
+
throw new Error(`Failed to get access token: ${response.status} - ${errorText}`);
|
|
81
|
+
}
|
|
82
|
+
const data = await response.json();
|
|
83
|
+
if (!data.access_token) {
|
|
84
|
+
throw new Error(`Failed to get access token: ${JSON.stringify(data)}`);
|
|
85
|
+
}
|
|
86
|
+
return data.access_token;
|
|
87
|
+
}
|
|
88
|
+
async function getGatewayUrl(token) {
|
|
89
|
+
const response = await fetch(`${API_BASE}/gateway`, {
|
|
90
|
+
method: "GET",
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `QQBot ${token}`
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`Failed to get gateway URL: ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
return data.url;
|
|
100
|
+
}
|
|
101
|
+
async function startQQConnection(options) {
|
|
102
|
+
const { qq, onMessage, onReady, onError, onDisconnect } = options;
|
|
103
|
+
accessToken = await getAccessToken(qq.appId, qq.clientSecret);
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
let ws = null;
|
|
106
|
+
let heartbeatInterval = null;
|
|
107
|
+
let isReconnecting = false;
|
|
108
|
+
async function connect() {
|
|
109
|
+
const gatewayUrl = await getGatewayUrl(accessToken);
|
|
110
|
+
console.log(`[QQ] Connecting to gateway: ${gatewayUrl}`);
|
|
111
|
+
ws = new WebSocket(gatewayUrl);
|
|
112
|
+
ws.on("open", () => {
|
|
113
|
+
console.log("[QQ] WebSocket connected");
|
|
114
|
+
reconnectAttempts = 0;
|
|
115
|
+
});
|
|
116
|
+
ws.on("message", async (data) => {
|
|
117
|
+
try {
|
|
118
|
+
const rawData = data.toString();
|
|
119
|
+
const payload = JSON.parse(rawData);
|
|
120
|
+
const { op, d, s, t } = payload;
|
|
121
|
+
if (s) {
|
|
122
|
+
lastSeq = s;
|
|
123
|
+
}
|
|
124
|
+
if (op === 10) {
|
|
125
|
+
console.log("[QQ] Hello received");
|
|
126
|
+
if (sessionId && lastSeq !== null) {
|
|
127
|
+
console.log("[QQ] Attempting to resume session");
|
|
128
|
+
ws?.send(JSON.stringify({
|
|
129
|
+
op: 6,
|
|
130
|
+
d: {
|
|
131
|
+
token: `QQBot ${accessToken}`,
|
|
132
|
+
session_id: sessionId,
|
|
133
|
+
seq: lastSeq
|
|
134
|
+
}
|
|
135
|
+
}));
|
|
136
|
+
} else {
|
|
137
|
+
console.log("[QQ] Sending identify with intents:", FULL_INTENTS);
|
|
138
|
+
ws?.send(JSON.stringify({
|
|
139
|
+
op: 2,
|
|
140
|
+
d: {
|
|
141
|
+
token: `QQBot ${accessToken}`,
|
|
142
|
+
intents: FULL_INTENTS,
|
|
143
|
+
shard: [0, 1]
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
const interval = d.heartbeat_interval;
|
|
148
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
149
|
+
heartbeatInterval = setInterval(() => {
|
|
150
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
151
|
+
ws.send(JSON.stringify({ op: 1, d: lastSeq }));
|
|
152
|
+
}
|
|
153
|
+
}, interval);
|
|
154
|
+
} else if (op === 0) {
|
|
155
|
+
if (t === "READY") {
|
|
156
|
+
const readyData = d;
|
|
157
|
+
sessionId = readyData.session_id;
|
|
158
|
+
console.log("[QQ] Ready, session:", sessionId);
|
|
159
|
+
onReady?.();
|
|
160
|
+
} else if (t === "C2C_MESSAGE_CREATE") {
|
|
161
|
+
const event = d;
|
|
162
|
+
if (event.author?.user_openid) {
|
|
163
|
+
onMessage(event);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if (op === 11) {
|
|
167
|
+
} else if (op === 7) {
|
|
168
|
+
console.log("[QQ] Server requested reconnect");
|
|
169
|
+
cleanup();
|
|
170
|
+
scheduleReconnect();
|
|
171
|
+
} else if (op === 9) {
|
|
172
|
+
const canResume = d;
|
|
173
|
+
console.log("[QQ] Invalid session, can resume:", canResume);
|
|
174
|
+
if (!canResume) {
|
|
175
|
+
sessionId = null;
|
|
176
|
+
lastSeq = null;
|
|
177
|
+
}
|
|
178
|
+
cleanup();
|
|
179
|
+
scheduleReconnect();
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error("[QQ] Message parse error:", err);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
ws.on("close", (code, reason) => {
|
|
186
|
+
console.log(`[QQ] WebSocket closed: ${code} ${reason.toString()}`);
|
|
187
|
+
cleanup();
|
|
188
|
+
if (!isReconnecting) {
|
|
189
|
+
onDisconnect?.();
|
|
190
|
+
scheduleReconnect();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
ws.on("error", (err) => {
|
|
194
|
+
console.error("[QQ] WebSocket error:", err.message);
|
|
195
|
+
onError?.(new Error(err.message));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function cleanup() {
|
|
199
|
+
if (heartbeatInterval) {
|
|
200
|
+
clearInterval(heartbeatInterval);
|
|
201
|
+
heartbeatInterval = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function scheduleReconnect() {
|
|
205
|
+
if (isReconnecting) return;
|
|
206
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
207
|
+
console.error("[QQ] Max reconnect attempts reached");
|
|
208
|
+
reject(new Error("Max reconnect attempts reached"));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
isReconnecting = true;
|
|
212
|
+
reconnectAttempts++;
|
|
213
|
+
const delay = RECONNECT_DELAY * reconnectAttempts;
|
|
214
|
+
console.log(`[QQ] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
|
|
215
|
+
setTimeout(async () => {
|
|
216
|
+
isReconnecting = false;
|
|
217
|
+
try {
|
|
218
|
+
accessToken = await getAccessToken(qq.appId, qq.clientSecret);
|
|
219
|
+
await connect();
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error("[QQ] Reconnect failed:", err);
|
|
222
|
+
scheduleReconnect();
|
|
223
|
+
}
|
|
224
|
+
}, delay);
|
|
225
|
+
}
|
|
226
|
+
connect().catch(reject);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/qq/sender.ts
|
|
231
|
+
import crypto from "crypto";
|
|
232
|
+
var API_BASE2 = "https://api.sgroup.qq.com";
|
|
233
|
+
var TOKEN_URL2 = "https://bots.qq.com/app/getAppAccessToken";
|
|
234
|
+
var tokenCache = null;
|
|
235
|
+
async function getAccessToken2(appId, clientSecret) {
|
|
236
|
+
if (tokenCache && Date.now() < tokenCache.expiresAt - 6e4) {
|
|
237
|
+
return tokenCache.token;
|
|
238
|
+
}
|
|
239
|
+
const response = await fetch(TOKEN_URL2, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: {
|
|
242
|
+
"Content-Type": "application/json"
|
|
243
|
+
},
|
|
244
|
+
body: JSON.stringify({ appId, clientSecret })
|
|
245
|
+
});
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const errorText = await response.text();
|
|
248
|
+
throw new Error(`Failed to get access token: ${response.status} - ${errorText}`);
|
|
249
|
+
}
|
|
250
|
+
const data = await response.json();
|
|
251
|
+
if (!data.access_token) {
|
|
252
|
+
throw new Error(`Failed to get access token: ${JSON.stringify(data)}`);
|
|
253
|
+
}
|
|
254
|
+
tokenCache = {
|
|
255
|
+
token: data.access_token,
|
|
256
|
+
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1e3
|
|
257
|
+
};
|
|
258
|
+
return tokenCache.token;
|
|
259
|
+
}
|
|
260
|
+
function getNextMsgSeq(msgId) {
|
|
261
|
+
const hash = crypto.createHash("md5").update(msgId).digest("hex");
|
|
262
|
+
return parseInt(hash.substring(0, 8), 16) % 9007199254740990 + 1;
|
|
263
|
+
}
|
|
264
|
+
async function apiRequest(accessToken2, method, path2, body) {
|
|
265
|
+
const url = `${API_BASE2}${path2}`;
|
|
266
|
+
const response = await fetch(url, {
|
|
267
|
+
method,
|
|
268
|
+
headers: {
|
|
269
|
+
Authorization: `QQBot ${accessToken2}`,
|
|
270
|
+
"Content-Type": "application/json"
|
|
271
|
+
},
|
|
272
|
+
body: body ? JSON.stringify(body) : void 0
|
|
273
|
+
});
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
const errorText = await response.text();
|
|
276
|
+
throw new Error(`API Error [${path2}]: ${response.status} - ${errorText}`);
|
|
277
|
+
}
|
|
278
|
+
return response.json();
|
|
279
|
+
}
|
|
280
|
+
async function sendC2CMessage(account, options) {
|
|
281
|
+
const token = await getAccessToken2(account.appId, account.clientSecret);
|
|
282
|
+
const msgSeq = getNextMsgSeq(options.messageId);
|
|
283
|
+
const useMarkdown = options.markdown ?? account.markdownSupport ?? false;
|
|
284
|
+
let body;
|
|
285
|
+
if (useMarkdown) {
|
|
286
|
+
body = {
|
|
287
|
+
markdown: { content: options.content },
|
|
288
|
+
msg_type: 2,
|
|
289
|
+
msg_seq: msgSeq,
|
|
290
|
+
msg_id: options.messageId
|
|
291
|
+
};
|
|
292
|
+
} else {
|
|
293
|
+
body = {
|
|
294
|
+
content: options.content,
|
|
295
|
+
msg_type: 0,
|
|
296
|
+
msg_seq: msgSeq,
|
|
297
|
+
msg_id: options.messageId
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (options.quoteRef && !useMarkdown) {
|
|
301
|
+
body.message_reference = { message_id: options.quoteRef };
|
|
302
|
+
}
|
|
303
|
+
await apiRequest(token, "POST", `/v2/users/${options.toOpenid}/messages`, body);
|
|
304
|
+
}
|
|
305
|
+
function clearTokenCache() {
|
|
306
|
+
tokenCache = null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/qq/parser.ts
|
|
310
|
+
var FACE_TAG_REGEX = /<face name="([^"]+)"[^/]*\/>/gi;
|
|
311
|
+
function parseMessage(event) {
|
|
312
|
+
let content = event.content;
|
|
313
|
+
content = parseFaceTags(content);
|
|
314
|
+
content = content.trim();
|
|
315
|
+
const imageUrls = extractImageUrls(content);
|
|
316
|
+
content = removeImageUrls(content);
|
|
317
|
+
const { quoteRef, quoteId } = parseQuoteRef(event);
|
|
318
|
+
return {
|
|
319
|
+
content,
|
|
320
|
+
imageUrls,
|
|
321
|
+
quoteRef,
|
|
322
|
+
quoteId
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function parseFaceTags(content) {
|
|
326
|
+
let result = content;
|
|
327
|
+
let match;
|
|
328
|
+
FACE_TAG_REGEX.lastIndex = 0;
|
|
329
|
+
while ((match = FACE_TAG_REGEX.exec(content)) !== null) {
|
|
330
|
+
const faceName = match[1];
|
|
331
|
+
result = result.replace(match[0], `[\u8868\u60C5:${faceName}]`);
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
function extractImageUrls(content) {
|
|
336
|
+
const urls = [];
|
|
337
|
+
let match;
|
|
338
|
+
const regex = /https?:\/\/[^\s"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?/gi;
|
|
339
|
+
while ((match = regex.exec(content)) !== null) {
|
|
340
|
+
urls.push(match[0]);
|
|
341
|
+
}
|
|
342
|
+
return urls;
|
|
343
|
+
}
|
|
344
|
+
function removeImageUrls(content) {
|
|
345
|
+
return content.replace(/https?:\/\/[^\s"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?/gi, "").trim();
|
|
346
|
+
}
|
|
347
|
+
function parseQuoteRef(event) {
|
|
348
|
+
const ext = event.message_scene?.ext;
|
|
349
|
+
if (!ext) {
|
|
350
|
+
return {};
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const scene = JSON.parse(ext);
|
|
354
|
+
if (scene?.refMsgIdx) {
|
|
355
|
+
return {
|
|
356
|
+
quoteRef: String(scene.refMsgIdx),
|
|
357
|
+
quoteId: scene.refMsgIdx
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
}
|
|
362
|
+
return {};
|
|
363
|
+
}
|
|
364
|
+
function chunkText(text, limit) {
|
|
365
|
+
if (!text || text.length === 0) {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
if (text.length <= limit) {
|
|
369
|
+
return [text];
|
|
370
|
+
}
|
|
371
|
+
const chunks = [];
|
|
372
|
+
const lines = text.split("\n");
|
|
373
|
+
let currentChunk = "";
|
|
374
|
+
for (const line of lines) {
|
|
375
|
+
if (currentChunk.length + line.length + 1 <= limit) {
|
|
376
|
+
currentChunk += (currentChunk ? "\n" : "") + line;
|
|
377
|
+
} else {
|
|
378
|
+
if (currentChunk) {
|
|
379
|
+
chunks.push(currentChunk);
|
|
380
|
+
}
|
|
381
|
+
if (line.length <= limit) {
|
|
382
|
+
currentChunk = line;
|
|
383
|
+
} else {
|
|
384
|
+
const subChunks = splitLongLine(line, limit);
|
|
385
|
+
chunks.push(...subChunks.slice(0, -1));
|
|
386
|
+
currentChunk = subChunks[subChunks.length - 1] || "";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (currentChunk) {
|
|
391
|
+
chunks.push(currentChunk);
|
|
392
|
+
}
|
|
393
|
+
return chunks;
|
|
394
|
+
}
|
|
395
|
+
function splitLongLine(line, limit) {
|
|
396
|
+
const chunks = [];
|
|
397
|
+
for (let i = 0; i < line.length; i += limit) {
|
|
398
|
+
chunks.push(line.slice(i, i + limit));
|
|
399
|
+
}
|
|
400
|
+
return chunks;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/opencode/client.ts
|
|
404
|
+
import { createOpencode } from "@opencode-ai/sdk";
|
|
405
|
+
var instance = null;
|
|
406
|
+
async function initOpencodeClient(config) {
|
|
407
|
+
const opencodeConfig = config.config || {};
|
|
408
|
+
const { client, server } = await createOpencode({
|
|
409
|
+
hostname: config.hostname,
|
|
410
|
+
port: config.port,
|
|
411
|
+
config: opencodeConfig
|
|
412
|
+
});
|
|
413
|
+
instance = {
|
|
414
|
+
client,
|
|
415
|
+
server,
|
|
416
|
+
currentSessionId: null
|
|
417
|
+
};
|
|
418
|
+
const sessionsResponse = await client.session.list();
|
|
419
|
+
const sessions = sessionsResponse.data;
|
|
420
|
+
if (sessions && sessions.length > 0) {
|
|
421
|
+
instance.currentSessionId = sessions[0].id;
|
|
422
|
+
console.log(`[Opencode] Resumed session: ${sessions[0].id}`);
|
|
423
|
+
}
|
|
424
|
+
return instance;
|
|
425
|
+
}
|
|
426
|
+
async function createSession() {
|
|
427
|
+
if (!instance) {
|
|
428
|
+
throw new Error("Opencode client not initialized");
|
|
429
|
+
}
|
|
430
|
+
const sessionResponse = await instance.client.session.create({
|
|
431
|
+
body: {}
|
|
432
|
+
});
|
|
433
|
+
const session = sessionResponse.data;
|
|
434
|
+
if (!session) {
|
|
435
|
+
throw new Error("Failed to create session");
|
|
436
|
+
}
|
|
437
|
+
instance.currentSessionId = session.id;
|
|
438
|
+
console.log(`[Opencode] Created new session: ${session.id}`);
|
|
439
|
+
return {
|
|
440
|
+
id: session.id,
|
|
441
|
+
title: session.title
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
async function switchSession(sessionId2) {
|
|
445
|
+
if (!instance) {
|
|
446
|
+
throw new Error("Opencode client not initialized");
|
|
447
|
+
}
|
|
448
|
+
const sessionResponse = await instance.client.session.get({
|
|
449
|
+
path: { id: sessionId2 }
|
|
450
|
+
});
|
|
451
|
+
const session = sessionResponse.data;
|
|
452
|
+
if (!session) {
|
|
453
|
+
throw new Error(`Session not found: ${sessionId2}`);
|
|
454
|
+
}
|
|
455
|
+
instance.currentSessionId = sessionId2;
|
|
456
|
+
console.log(`[Opencode] Switched to session: ${sessionId2}`);
|
|
457
|
+
return {
|
|
458
|
+
id: session.id,
|
|
459
|
+
title: session.title
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
async function listSessions() {
|
|
463
|
+
if (!instance) {
|
|
464
|
+
throw new Error("Opencode client not initialized");
|
|
465
|
+
}
|
|
466
|
+
const sessionsResponse = await instance.client.session.list();
|
|
467
|
+
const sessions = sessionsResponse.data;
|
|
468
|
+
if (!sessions) {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
return sessions.map((s) => ({
|
|
472
|
+
id: s.id,
|
|
473
|
+
title: s.title
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
async function getCurrentSession() {
|
|
477
|
+
if (!instance || !instance.currentSessionId) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
const sessionResponse = await instance.client.session.get({
|
|
481
|
+
path: { id: instance.currentSessionId }
|
|
482
|
+
});
|
|
483
|
+
const session = sessionResponse.data;
|
|
484
|
+
if (!session) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
id: session.id,
|
|
489
|
+
title: session.title
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
async function sendPrompt(message, imageUrls = []) {
|
|
493
|
+
if (!instance || !instance.currentSessionId) {
|
|
494
|
+
throw new Error("Opencode client not initialized or no active session");
|
|
495
|
+
}
|
|
496
|
+
console.log(`[Opencode] Sending prompt to session ${instance.currentSessionId}: "${message.slice(0, 50)}..."`);
|
|
497
|
+
const parts = [
|
|
498
|
+
{ type: "text", text: message }
|
|
499
|
+
];
|
|
500
|
+
for (const imageUrl of imageUrls) {
|
|
501
|
+
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
|
502
|
+
parts.push({ type: "image_url", url: imageUrl });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
const resultResponse = await instance.client.session.prompt({
|
|
507
|
+
path: { id: instance.currentSessionId },
|
|
508
|
+
body: {
|
|
509
|
+
parts
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
console.log(`[Opencode] Prompt response:`, JSON.stringify(resultResponse, null, 2).slice(0, 500));
|
|
513
|
+
const result = resultResponse.data;
|
|
514
|
+
let text = "";
|
|
515
|
+
if (result?.parts && Array.isArray(result.parts)) {
|
|
516
|
+
for (const part of result.parts) {
|
|
517
|
+
console.log(`[Opencode] Part type: ${part.type}`, part);
|
|
518
|
+
if (part.type === "text" && part.text) {
|
|
519
|
+
text += part.text;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
console.log(`[Opencode] No parts in response or unexpected format`);
|
|
524
|
+
}
|
|
525
|
+
if (!text) {
|
|
526
|
+
console.log(`[Opencode] Empty response text, checking info...`);
|
|
527
|
+
if (result?.info) {
|
|
528
|
+
console.log(`[Opencode] Response info:`, JSON.stringify(result.info).slice(0, 500));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return { text };
|
|
532
|
+
} catch (err) {
|
|
533
|
+
console.error(`[Opencode] Prompt error:`, err);
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function closeOpencodeClient() {
|
|
538
|
+
if (instance) {
|
|
539
|
+
instance.server.close();
|
|
540
|
+
instance = null;
|
|
541
|
+
console.log("[Opencode] Client closed");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/handlers/session.ts
|
|
546
|
+
async function handleSessionNew() {
|
|
547
|
+
try {
|
|
548
|
+
const session = await createSession();
|
|
549
|
+
return {
|
|
550
|
+
text: `\u5DF2\u521B\u5EFA\u65B0\u4F1A\u8BDD: ${session.id}
|
|
551
|
+
\u6807\u9898: ${session.title || "\u65E0"}`,
|
|
552
|
+
success: true
|
|
553
|
+
};
|
|
554
|
+
} catch (err) {
|
|
555
|
+
return {
|
|
556
|
+
text: `\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`,
|
|
557
|
+
success: false
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function handleSessionSwitch(sessionId2) {
|
|
562
|
+
if (!sessionId2 || sessionId2.trim() === "") {
|
|
563
|
+
return {
|
|
564
|
+
text: "\u8BF7\u63D0\u4F9B\u4F1A\u8BDD ID\uFF0C\u4F8B\u5982: /session-switch <id>",
|
|
565
|
+
success: false
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
const session = await switchSession(sessionId2.trim());
|
|
570
|
+
return {
|
|
571
|
+
text: `\u5DF2\u5207\u6362\u5230\u4F1A\u8BDD: ${session.id}
|
|
572
|
+
\u6807\u9898: ${session.title || "\u65E0"}`,
|
|
573
|
+
success: true
|
|
574
|
+
};
|
|
575
|
+
} catch (err) {
|
|
576
|
+
return {
|
|
577
|
+
text: `\u5207\u6362\u4F1A\u8BDD\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`,
|
|
578
|
+
success: false
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function handleSessionList() {
|
|
583
|
+
try {
|
|
584
|
+
const sessions = await listSessions();
|
|
585
|
+
if (sessions.length === 0) {
|
|
586
|
+
return {
|
|
587
|
+
text: "\u6682\u65E0\u4F1A\u8BDD",
|
|
588
|
+
success: true
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
const currentSession = await getCurrentSession();
|
|
592
|
+
const currentId = currentSession?.id;
|
|
593
|
+
const lines = sessions.map((s, i) => {
|
|
594
|
+
const marker = s.id === currentId ? " [\u5F53\u524D]" : "";
|
|
595
|
+
const title = s.title ? ` - ${s.title}` : "";
|
|
596
|
+
return `${i + 1}. ${s.id}${title}${marker}`;
|
|
597
|
+
});
|
|
598
|
+
return {
|
|
599
|
+
text: `\u4F1A\u8BDD\u5217\u8868:
|
|
600
|
+
${lines.join("\n")}`,
|
|
601
|
+
success: true
|
|
602
|
+
};
|
|
603
|
+
} catch (err) {
|
|
604
|
+
return {
|
|
605
|
+
text: `\u83B7\u53D6\u4F1A\u8BDD\u5217\u8868\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`,
|
|
606
|
+
success: false
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async function handleSessionCurrent() {
|
|
611
|
+
try {
|
|
612
|
+
const session = await getCurrentSession();
|
|
613
|
+
if (!session) {
|
|
614
|
+
return {
|
|
615
|
+
text: "\u5F53\u524D\u6CA1\u6709\u6D3B\u8DC3\u7684\u4F1A\u8BDD",
|
|
616
|
+
success: true
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
text: `\u5F53\u524D\u4F1A\u8BDD:
|
|
621
|
+
ID: ${session.id}
|
|
622
|
+
\u6807\u9898: ${session.title || "\u65E0"}`,
|
|
623
|
+
success: true
|
|
624
|
+
};
|
|
625
|
+
} catch (err) {
|
|
626
|
+
return {
|
|
627
|
+
text: `\u83B7\u53D6\u5F53\u524D\u4F1A\u8BDD\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`,
|
|
628
|
+
success: false
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function parseSessionCommand(content) {
|
|
633
|
+
const trimmed = content.trim();
|
|
634
|
+
if (trimmed === "/session-new") {
|
|
635
|
+
return { command: "new", args: "" };
|
|
636
|
+
}
|
|
637
|
+
if (trimmed.startsWith("/session-switch ")) {
|
|
638
|
+
const args = trimmed.slice("/session-switch ".length).trim();
|
|
639
|
+
return { command: "switch", args };
|
|
640
|
+
}
|
|
641
|
+
if (trimmed === "/session-list") {
|
|
642
|
+
return { command: "list", args: "" };
|
|
643
|
+
}
|
|
644
|
+
if (trimmed === "/session-current") {
|
|
645
|
+
return { command: "current", args: "" };
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/handlers/message.ts
|
|
651
|
+
async function handleMessage(content, parsedMessage) {
|
|
652
|
+
const sessionCmd = parseSessionCommand(content);
|
|
653
|
+
if (sessionCmd) {
|
|
654
|
+
return handleSessionCommand(sessionCmd.command, sessionCmd.args);
|
|
655
|
+
}
|
|
656
|
+
return handleAIMessage(content, parsedMessage);
|
|
657
|
+
}
|
|
658
|
+
async function handleSessionCommand(command, args) {
|
|
659
|
+
let result;
|
|
660
|
+
switch (command) {
|
|
661
|
+
case "new":
|
|
662
|
+
result = await handleSessionNew();
|
|
663
|
+
break;
|
|
664
|
+
case "switch":
|
|
665
|
+
result = await handleSessionSwitch(args);
|
|
666
|
+
break;
|
|
667
|
+
case "list":
|
|
668
|
+
result = await handleSessionList();
|
|
669
|
+
break;
|
|
670
|
+
case "current":
|
|
671
|
+
result = await handleSessionCurrent();
|
|
672
|
+
break;
|
|
673
|
+
default:
|
|
674
|
+
result = {
|
|
675
|
+
text: "\u672A\u77E5\u7684 session \u547D\u4EE4",
|
|
676
|
+
success: false
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
...result,
|
|
681
|
+
isSessionCommand: true
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async function handleAIMessage(content, parsedMessage) {
|
|
685
|
+
try {
|
|
686
|
+
const result = await sendPrompt(content, parsedMessage.imageUrls);
|
|
687
|
+
if (!result.text || result.text.trim() === "") {
|
|
688
|
+
return {
|
|
689
|
+
text: "AI \u8FD4\u56DE\u4E86\u7A7A\u54CD\u5E94",
|
|
690
|
+
success: true
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
text: result.text,
|
|
695
|
+
success: true
|
|
696
|
+
};
|
|
697
|
+
} catch (err) {
|
|
698
|
+
return {
|
|
699
|
+
text: `\u5904\u7406\u6D88\u606F\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`,
|
|
700
|
+
success: false
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/app.ts
|
|
706
|
+
var TEXT_CHUNK_LIMIT = 500;
|
|
707
|
+
function parseArgs() {
|
|
708
|
+
const args = process.argv.slice(2);
|
|
709
|
+
const result = {};
|
|
710
|
+
for (let i = 0; i < args.length; i++) {
|
|
711
|
+
if (args[i] === "--config" && i + 1 < args.length) {
|
|
712
|
+
result.config = args[i + 1];
|
|
713
|
+
i++;
|
|
714
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
715
|
+
printHelp();
|
|
716
|
+
process.exit(0);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return result;
|
|
720
|
+
}
|
|
721
|
+
function printHelp() {
|
|
722
|
+
console.log(`
|
|
723
|
+
QQ Bot with OpenCode SDK
|
|
724
|
+
|
|
725
|
+
Usage:
|
|
726
|
+
npx tsx src/app.ts --config <config.yaml>
|
|
727
|
+
|
|
728
|
+
Options:
|
|
729
|
+
--config <path> Path to config.yaml file (required)
|
|
730
|
+
--help, -h Show this help message
|
|
731
|
+
|
|
732
|
+
Example:
|
|
733
|
+
npx tsx src/app.ts --config ./config.yaml
|
|
734
|
+
`);
|
|
735
|
+
}
|
|
736
|
+
async function main() {
|
|
737
|
+
const args = parseArgs();
|
|
738
|
+
if (!args.config) {
|
|
739
|
+
console.error("Error: --config option is required");
|
|
740
|
+
printHelp();
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
console.log("[App] Loading configuration...");
|
|
744
|
+
let config;
|
|
745
|
+
try {
|
|
746
|
+
config = loadConfig(args.config);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
console.error(`[App] Failed to load config: ${err instanceof Error ? err.message : String(err)}`);
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
const expandedConfig = expandEnvVariables(config);
|
|
752
|
+
console.log("[App] Initializing OpenCode client...");
|
|
753
|
+
try {
|
|
754
|
+
await initOpencodeClient(expandedConfig.opencode);
|
|
755
|
+
console.log("[App] OpenCode client initialized");
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.error(`[App] Failed to initialize OpenCode: ${err instanceof Error ? err.message : String(err)}`);
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
await createSession();
|
|
762
|
+
} catch (err) {
|
|
763
|
+
console.error(`[App] Failed to create initial session: ${err instanceof Error ? err.message : String(err)}`);
|
|
764
|
+
}
|
|
765
|
+
console.log("[App] Starting QQ connection...");
|
|
766
|
+
try {
|
|
767
|
+
await startQQConnection({
|
|
768
|
+
qq: expandedConfig.qq,
|
|
769
|
+
onMessage: async (event) => {
|
|
770
|
+
console.log(`[App] Received message from ${event.author.user_openid}: ${event.content.slice(0, 50)}...`);
|
|
771
|
+
try {
|
|
772
|
+
const parsed = parseMessage(event);
|
|
773
|
+
const result = await handleMessage(parsed.content, parsed);
|
|
774
|
+
const chunks = chunkText(result.text, TEXT_CHUNK_LIMIT);
|
|
775
|
+
for (const chunk of chunks) {
|
|
776
|
+
await sendC2CMessage(expandedConfig.qq, {
|
|
777
|
+
toOpenid: event.author.user_openid,
|
|
778
|
+
content: chunk,
|
|
779
|
+
messageId: event.id
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
console.log(`[App] Sent response to ${event.author.user_openid}`);
|
|
783
|
+
} catch (err) {
|
|
784
|
+
console.error(`[App] Error handling message: ${err instanceof Error ? err.message : String(err)}`);
|
|
785
|
+
const errorText = `\u5904\u7406\u6D88\u606F\u65F6\u51FA\u9519: ${err instanceof Error ? err.message : String(err)}`;
|
|
786
|
+
try {
|
|
787
|
+
await sendC2CMessage(expandedConfig.qq, {
|
|
788
|
+
toOpenid: event.author.user_openid,
|
|
789
|
+
content: errorText,
|
|
790
|
+
messageId: event.id
|
|
791
|
+
});
|
|
792
|
+
} catch (sendErr) {
|
|
793
|
+
console.error(`[App] Failed to send error message: ${sendErr}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
onReady: () => {
|
|
798
|
+
console.log("[App] QQ Bot is ready!");
|
|
799
|
+
},
|
|
800
|
+
onError: (err) => {
|
|
801
|
+
console.error(`[App] QQ connection error: ${err.message}`);
|
|
802
|
+
},
|
|
803
|
+
onDisconnect: () => {
|
|
804
|
+
console.log("[App] QQ disconnected, will attempt to reconnect...");
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
} catch (err) {
|
|
808
|
+
console.error(`[App] Failed to start QQ connection: ${err instanceof Error ? err.message : String(err)}`);
|
|
809
|
+
await closeOpencodeClient();
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
console.log("[App] Bot is running. Press Ctrl+C to stop.");
|
|
813
|
+
const shutdown = async () => {
|
|
814
|
+
console.log("\n[App] Shutting down...");
|
|
815
|
+
clearTokenCache();
|
|
816
|
+
await closeOpencodeClient();
|
|
817
|
+
console.log("[App] Goodbye!");
|
|
818
|
+
process.exit(0);
|
|
819
|
+
};
|
|
820
|
+
process.on("SIGINT", shutdown);
|
|
821
|
+
process.on("SIGTERM", shutdown);
|
|
822
|
+
}
|
|
823
|
+
main().catch((err) => {
|
|
824
|
+
console.error(`[App] Fatal error: ${err}`);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
});
|