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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -0
  3. package/bin/qqbot.js +16 -0
  4. package/dist/app.d.ts +2 -0
  5. package/dist/app.d.ts.map +1 -0
  6. package/dist/app.js +154 -0
  7. package/dist/app.js.map +1 -0
  8. package/dist/bundle.cjs +850 -0
  9. package/dist/bundle.js +826 -0
  10. package/dist/config.d.ts +8 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +179 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/handlers/index.d.ts +3 -0
  15. package/dist/handlers/index.d.ts.map +1 -0
  16. package/dist/handlers/index.js +3 -0
  17. package/dist/handlers/index.js.map +1 -0
  18. package/dist/handlers/message.d.ts +8 -0
  19. package/dist/handlers/message.d.ts.map +1 -0
  20. package/dist/handlers/message.js +57 -0
  21. package/dist/handlers/message.js.map +1 -0
  22. package/dist/handlers/session.d.ts +13 -0
  23. package/dist/handlers/session.d.ts.map +1 -0
  24. package/dist/handlers/session.js +104 -0
  25. package/dist/handlers/session.js.map +1 -0
  26. package/dist/opencode/client.d.ts +23 -0
  27. package/dist/opencode/client.d.ts.map +1 -0
  28. package/dist/opencode/client.js +141 -0
  29. package/dist/opencode/client.js.map +1 -0
  30. package/dist/opencode/index.d.ts +2 -0
  31. package/dist/opencode/index.d.ts.map +1 -0
  32. package/dist/opencode/index.js +2 -0
  33. package/dist/opencode/index.js.map +1 -0
  34. package/dist/qq/connection.d.ts +23 -0
  35. package/dist/qq/connection.d.ts.map +1 -0
  36. package/dist/qq/connection.js +188 -0
  37. package/dist/qq/connection.js.map +1 -0
  38. package/dist/qq/index.d.ts +5 -0
  39. package/dist/qq/index.d.ts.map +1 -0
  40. package/dist/qq/index.js +4 -0
  41. package/dist/qq/index.js.map +1 -0
  42. package/dist/qq/parser.d.ts +4 -0
  43. package/dist/qq/parser.d.ts.map +1 -0
  44. package/dist/qq/parser.js +99 -0
  45. package/dist/qq/parser.js.map +1 -0
  46. package/dist/qq/sender.d.ts +28 -0
  47. package/dist/qq/sender.d.ts.map +1 -0
  48. package/dist/qq/sender.js +123 -0
  49. package/dist/qq/sender.js.map +1 -0
  50. package/dist/types.d.ts +47 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +2 -0
  53. package/dist/types.js.map +1 -0
  54. package/package.json +58 -0
  55. package/src/app.ts +204 -0
  56. package/src/config.ts +200 -0
  57. package/src/handlers/index.ts +2 -0
  58. package/src/handlers/message.ts +86 -0
  59. package/src/handlers/session.ts +130 -0
  60. package/src/opencode/client.ts +204 -0
  61. package/src/opencode/index.ts +1 -0
  62. package/src/qq/connection.ts +252 -0
  63. package/src/qq/index.ts +9 -0
  64. package/src/qq/parser.ts +126 -0
  65. package/src/qq/sender.ts +215 -0
  66. package/src/types.ts +52 -0
@@ -0,0 +1,204 @@
1
+ import { createOpencode } from "@opencode-ai/sdk";
2
+ import type { OpencodeConfig } from "../types.js";
3
+
4
+ interface OpencodeClientInstance {
5
+ client: Awaited<ReturnType<typeof createOpencode>>["client"];
6
+ server: Awaited<ReturnType<typeof createOpencode>>["server"];
7
+ currentSessionId: string | null;
8
+ }
9
+
10
+ interface SessionInfo {
11
+ id: string;
12
+ title?: string;
13
+ }
14
+
15
+ let instance: OpencodeClientInstance | null = null;
16
+
17
+ export async function initOpencodeClient(
18
+ config: OpencodeConfig,
19
+ ): Promise<OpencodeClientInstance> {
20
+ const opencodeConfig = config.config || {};
21
+
22
+ const { client, server } = await createOpencode({
23
+ hostname: config.hostname,
24
+ port: config.port,
25
+ config: opencodeConfig,
26
+ });
27
+
28
+ instance = {
29
+ client,
30
+ server,
31
+ currentSessionId: null,
32
+ };
33
+
34
+ const sessionsResponse = await client.session.list();
35
+ const sessions = sessionsResponse.data;
36
+ if (sessions && sessions.length > 0) {
37
+ instance.currentSessionId = sessions[0].id;
38
+ console.log(`[Opencode] Resumed session: ${sessions[0].id}`);
39
+ }
40
+
41
+ return instance;
42
+ }
43
+
44
+ export async function createSession(): Promise<SessionInfo> {
45
+ if (!instance) {
46
+ throw new Error("Opencode client not initialized");
47
+ }
48
+
49
+ const sessionResponse = await instance.client.session.create({
50
+ body: {},
51
+ });
52
+
53
+ const session = sessionResponse.data;
54
+ if (!session) {
55
+ throw new Error("Failed to create session");
56
+ }
57
+
58
+ instance.currentSessionId = session.id;
59
+ console.log(`[Opencode] Created new session: ${session.id}`);
60
+
61
+ return {
62
+ id: session.id,
63
+ title: session.title,
64
+ };
65
+ }
66
+
67
+ export async function switchSession(sessionId: string): Promise<SessionInfo> {
68
+ if (!instance) {
69
+ throw new Error("Opencode client not initialized");
70
+ }
71
+
72
+ const sessionResponse = await instance.client.session.get({
73
+ path: { id: sessionId },
74
+ });
75
+
76
+ const session = sessionResponse.data;
77
+ if (!session) {
78
+ throw new Error(`Session not found: ${sessionId}`);
79
+ }
80
+
81
+ instance.currentSessionId = sessionId;
82
+ console.log(`[Opencode] Switched to session: ${sessionId}`);
83
+
84
+ return {
85
+ id: session.id,
86
+ title: session.title,
87
+ };
88
+ }
89
+
90
+ export async function listSessions(): Promise<SessionInfo[]> {
91
+ if (!instance) {
92
+ throw new Error("Opencode client not initialized");
93
+ }
94
+
95
+ const sessionsResponse = await instance.client.session.list();
96
+ const sessions = sessionsResponse.data;
97
+
98
+ if (!sessions) {
99
+ return [];
100
+ }
101
+
102
+ return sessions.map((s) => ({
103
+ id: s.id,
104
+ title: s.title,
105
+ }));
106
+ }
107
+
108
+ export async function getCurrentSession(): Promise<SessionInfo | null> {
109
+ if (!instance || !instance.currentSessionId) {
110
+ return null;
111
+ }
112
+
113
+ const sessionResponse = await instance.client.session.get({
114
+ path: { id: instance.currentSessionId },
115
+ });
116
+
117
+ const session = sessionResponse.data;
118
+ if (!session) {
119
+ return null;
120
+ }
121
+
122
+ return {
123
+ id: session.id,
124
+ title: session.title,
125
+ };
126
+ }
127
+
128
+ export interface PromptResult {
129
+ text: string;
130
+ }
131
+
132
+ export async function sendPrompt(
133
+ message: string,
134
+ imageUrls: string[] = [],
135
+ ): Promise<PromptResult> {
136
+ if (!instance || !instance.currentSessionId) {
137
+ throw new Error("Opencode client not initialized or no active session");
138
+ }
139
+
140
+ console.log(
141
+ `[Opencode] Sending prompt to session ${instance.currentSessionId}: "${message.slice(0, 50)}..."`,
142
+ );
143
+
144
+ const parts: Array<
145
+ { type: "text"; text: string } | { type: "image_url"; url: string }
146
+ > = [{ type: "text", text: message }];
147
+
148
+ for (const imageUrl of imageUrls) {
149
+ if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
150
+ parts.push({ type: "image_url", url: imageUrl });
151
+ }
152
+ }
153
+
154
+ try {
155
+ const resultResponse = await instance.client.session.prompt({
156
+ path: { id: instance.currentSessionId },
157
+ body: {
158
+ parts: parts as any,
159
+ },
160
+ });
161
+
162
+ console.log(
163
+ `[Opencode] Prompt response:`,
164
+ JSON.stringify(resultResponse, null, 2).slice(0, 500),
165
+ );
166
+
167
+ const result = resultResponse.data;
168
+ let text = "";
169
+
170
+ if (result?.parts && Array.isArray(result.parts)) {
171
+ for (const part of result.parts) {
172
+ console.log(`[Opencode] Part type: ${part.type}`, part);
173
+ if (part.type === "text" && part.text) {
174
+ text += part.text;
175
+ }
176
+ }
177
+ } else {
178
+ console.log(`[Opencode] No parts in response or unexpected format`);
179
+ }
180
+
181
+ if (!text) {
182
+ console.log(`[Opencode] Empty response text, checking info...`);
183
+ if (result?.info) {
184
+ console.log(
185
+ `[Opencode] Response info:`,
186
+ JSON.stringify(result.info).slice(0, 500),
187
+ );
188
+ }
189
+ }
190
+
191
+ return { text };
192
+ } catch (err) {
193
+ console.error(`[Opencode] Prompt error:`, err);
194
+ throw err;
195
+ }
196
+ }
197
+
198
+ export async function closeOpencodeClient(): Promise<void> {
199
+ if (instance) {
200
+ instance.server.close();
201
+ instance = null;
202
+ console.log("[Opencode] Client closed");
203
+ }
204
+ }
@@ -0,0 +1 @@
1
+ export * from "./client.js";
@@ -0,0 +1,252 @@
1
+ import WebSocket from "ws";
2
+ import type { QQConfig } from "../types.js";
3
+
4
+ interface WSPayload {
5
+ op: number;
6
+ d: unknown;
7
+ s?: number;
8
+ t?: string;
9
+ }
10
+
11
+ interface C2CMessageEvent {
12
+ id: string;
13
+ content: string;
14
+ timestamp: number;
15
+ author: {
16
+ user_openid: string;
17
+ username?: string;
18
+ };
19
+ message_scene?: {
20
+ ext?: string;
21
+ };
22
+ }
23
+
24
+ interface ConnectionOptions {
25
+ qq: QQConfig;
26
+ onMessage: (event: C2CMessageEvent) => void;
27
+ onReady?: () => void;
28
+ onError?: (error: Error) => void;
29
+ onDisconnect?: () => void;
30
+ }
31
+
32
+ const INTENTS = {
33
+ GUILDS: 1 << 0,
34
+ GUILD_MEMBERS: 1 << 1,
35
+ PUBLIC_GUILD_MESSAGES: 1 << 30,
36
+ DIRECT_MESSAGE: 1 << 12,
37
+ GROUP_AND_C2C: 1 << 25,
38
+ };
39
+
40
+ const FULL_INTENTS =
41
+ INTENTS.PUBLIC_GUILD_MESSAGES |
42
+ INTENTS.DIRECT_MESSAGE |
43
+ INTENTS.GROUP_AND_C2C;
44
+ const API_BASE = "https://api.sgroup.qq.com";
45
+ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
46
+
47
+ let accessToken: string | null = null;
48
+ let sessionId: string | null = null;
49
+ let lastSeq: number | null = null;
50
+ let reconnectAttempts = 0;
51
+ const MAX_RECONNECT_ATTEMPTS = 10;
52
+ const RECONNECT_DELAY = 5000;
53
+
54
+ async function getAccessToken(
55
+ appId: string,
56
+ clientSecret: string,
57
+ ): Promise<string> {
58
+ const response = await fetch(TOKEN_URL, {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/json",
62
+ },
63
+ body: JSON.stringify({ appId, clientSecret }),
64
+ });
65
+
66
+ if (!response.ok) {
67
+ const errorText = await response.text();
68
+ throw new Error(
69
+ `Failed to get access token: ${response.status} - ${errorText}`,
70
+ );
71
+ }
72
+
73
+ const data = (await response.json()) as {
74
+ access_token?: string;
75
+ expires_in?: number;
76
+ };
77
+
78
+ if (!data.access_token) {
79
+ throw new Error(`Failed to get access token: ${JSON.stringify(data)}`);
80
+ }
81
+
82
+ return data.access_token;
83
+ }
84
+
85
+ async function getGatewayUrl(token: string): Promise<string> {
86
+ const response = await fetch(`${API_BASE}/gateway`, {
87
+ method: "GET",
88
+ headers: {
89
+ Authorization: `QQBot ${token}`,
90
+ },
91
+ });
92
+
93
+ if (!response.ok) {
94
+ throw new Error(`Failed to get gateway URL: ${response.status}`);
95
+ }
96
+
97
+ const data = (await response.json()) as { url: string };
98
+ return data.url;
99
+ }
100
+
101
+ export async function startQQConnection(
102
+ options: ConnectionOptions,
103
+ ): Promise<void> {
104
+ const { qq, onMessage, onReady, onError, onDisconnect } = options;
105
+
106
+ accessToken = await getAccessToken(qq.appId, qq.clientSecret);
107
+
108
+ return new Promise((resolve, reject) => {
109
+ let ws: WebSocket | null = null;
110
+ let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
111
+ let isReconnecting = false;
112
+
113
+ async function connect(): Promise<void> {
114
+ const gatewayUrl = await getGatewayUrl(accessToken!);
115
+ console.log(`[QQ] Connecting to gateway: ${gatewayUrl}`);
116
+ ws = new WebSocket(gatewayUrl);
117
+
118
+ ws.on("open", () => {
119
+ console.log("[QQ] WebSocket connected");
120
+ reconnectAttempts = 0;
121
+ });
122
+
123
+ ws.on("message", async (data) => {
124
+ try {
125
+ const rawData = data.toString();
126
+ const payload = JSON.parse(rawData) as WSPayload;
127
+ const { op, d, s, t } = payload;
128
+
129
+ if (s) {
130
+ lastSeq = s;
131
+ }
132
+
133
+ if (op === 10) {
134
+ console.log("[QQ] Hello received");
135
+
136
+ if (sessionId && lastSeq !== null) {
137
+ console.log("[QQ] Attempting to resume session");
138
+ ws?.send(
139
+ JSON.stringify({
140
+ op: 6,
141
+ d: {
142
+ token: `QQBot ${accessToken}`,
143
+ session_id: sessionId,
144
+ seq: lastSeq,
145
+ },
146
+ }),
147
+ );
148
+ } else {
149
+ console.log("[QQ] Sending identify with intents:", FULL_INTENTS);
150
+ ws?.send(
151
+ JSON.stringify({
152
+ op: 2,
153
+ d: {
154
+ token: `QQBot ${accessToken}`,
155
+ intents: FULL_INTENTS,
156
+ shard: [0, 1],
157
+ },
158
+ }),
159
+ );
160
+ }
161
+
162
+ const interval = (d as { heartbeat_interval: number })
163
+ .heartbeat_interval;
164
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
165
+ heartbeatInterval = setInterval(() => {
166
+ if (ws?.readyState === WebSocket.OPEN) {
167
+ ws.send(JSON.stringify({ op: 1, d: lastSeq }));
168
+ }
169
+ }, interval);
170
+ } else if (op === 0) {
171
+ if (t === "READY") {
172
+ const readyData = d as { session_id: string };
173
+ sessionId = readyData.session_id;
174
+ console.log("[QQ] Ready, session:", sessionId);
175
+ onReady?.();
176
+ } else if (t === "C2C_MESSAGE_CREATE") {
177
+ const event = d as C2CMessageEvent;
178
+ if (event.author?.user_openid) {
179
+ onMessage(event);
180
+ }
181
+ }
182
+ } else if (op === 7) {
183
+ console.log("[QQ] Server requested reconnect");
184
+ cleanup();
185
+ scheduleReconnect();
186
+ } else if (op === 9) {
187
+ const canResume = d as boolean;
188
+ console.log("[QQ] Invalid session, can resume:", canResume);
189
+ if (!canResume) {
190
+ sessionId = null;
191
+ lastSeq = null;
192
+ }
193
+ cleanup();
194
+ scheduleReconnect();
195
+ }
196
+ } catch (err) {
197
+ console.error("[QQ] Message parse error:", err);
198
+ }
199
+ });
200
+
201
+ ws.on("close", (code, reason) => {
202
+ console.log(`[QQ] WebSocket closed: ${code} ${reason.toString()}`);
203
+ cleanup();
204
+ if (!isReconnecting) {
205
+ onDisconnect?.();
206
+ scheduleReconnect();
207
+ }
208
+ });
209
+
210
+ ws.on("error", (err) => {
211
+ console.error("[QQ] WebSocket error:", err.message);
212
+ onError?.(new Error(err.message));
213
+ });
214
+ }
215
+
216
+ function cleanup(): void {
217
+ if (heartbeatInterval) {
218
+ clearInterval(heartbeatInterval);
219
+ heartbeatInterval = null;
220
+ }
221
+ }
222
+
223
+ function scheduleReconnect(): void {
224
+ if (isReconnecting) return;
225
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
226
+ console.error("[QQ] Max reconnect attempts reached");
227
+ reject(new Error("Max reconnect attempts reached"));
228
+ return;
229
+ }
230
+
231
+ isReconnecting = true;
232
+ reconnectAttempts++;
233
+ const delay = RECONNECT_DELAY * reconnectAttempts;
234
+ console.log(
235
+ `[QQ] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`,
236
+ );
237
+
238
+ setTimeout(async () => {
239
+ isReconnecting = false;
240
+ try {
241
+ accessToken = await getAccessToken(qq.appId, qq.clientSecret);
242
+ await connect();
243
+ } catch (err) {
244
+ console.error("[QQ] Reconnect failed:", err);
245
+ scheduleReconnect();
246
+ }
247
+ }, delay);
248
+ }
249
+
250
+ connect().catch(reject);
251
+ });
252
+ }
@@ -0,0 +1,9 @@
1
+ export { startQQConnection } from "./connection.js";
2
+ export {
3
+ sendC2CMessage,
4
+ sendC2CImageMessage,
5
+ sendC2CFileMessage,
6
+ clearTokenCache,
7
+ } from "./sender.js";
8
+ export { parseMessage, chunkText } from "./parser.js";
9
+ export type { QQMessageEvent, ParsedMessage } from "../types.js";
@@ -0,0 +1,126 @@
1
+ import type { QQMessageEvent, ParsedMessage } from "../types.js";
2
+
3
+ const FACE_TAG_REGEX = /<face name="([^"]+)"[^/]*\/>/gi;
4
+
5
+ export function parseMessage(event: QQMessageEvent): ParsedMessage {
6
+ let content = event.content;
7
+
8
+ content = parseFaceTags(content);
9
+ content = content.trim();
10
+
11
+ const imageUrls = extractImageUrls(content);
12
+ content = removeImageUrls(content);
13
+
14
+ const { quoteRef, quoteId } = parseQuoteRef(event);
15
+
16
+ return {
17
+ content,
18
+ imageUrls,
19
+ quoteRef,
20
+ quoteId,
21
+ };
22
+ }
23
+
24
+ function parseFaceTags(content: string): string {
25
+ let result = content;
26
+ let match;
27
+
28
+ FACE_TAG_REGEX.lastIndex = 0;
29
+ while ((match = FACE_TAG_REGEX.exec(content)) !== null) {
30
+ const faceName = match[1];
31
+ result = result.replace(match[0], `[表情:${faceName}]`);
32
+ }
33
+
34
+ return result;
35
+ }
36
+
37
+ function extractImageUrls(content: string): string[] {
38
+ const urls: string[] = [];
39
+ let match;
40
+
41
+ const regex =
42
+ /https?:\/\/[^\s"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?/gi;
43
+ while ((match = regex.exec(content)) !== null) {
44
+ urls.push(match[0]);
45
+ }
46
+
47
+ return urls;
48
+ }
49
+
50
+ function removeImageUrls(content: string): string {
51
+ return content
52
+ .replace(
53
+ /https?:\/\/[^\s"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?/gi,
54
+ "",
55
+ )
56
+ .trim();
57
+ }
58
+
59
+ function parseQuoteRef(event: QQMessageEvent): {
60
+ quoteRef?: string;
61
+ quoteId?: string;
62
+ } {
63
+ const ext = event.message_scene?.ext;
64
+ if (!ext) {
65
+ return {};
66
+ }
67
+
68
+ try {
69
+ const scene = JSON.parse(ext);
70
+ if (scene?.refMsgIdx) {
71
+ return {
72
+ quoteRef: String(scene.refMsgIdx),
73
+ quoteId: scene.refMsgIdx,
74
+ };
75
+ }
76
+ } catch {
77
+ // ignore parse errors
78
+ }
79
+
80
+ return {};
81
+ }
82
+
83
+ export function chunkText(text: string, limit: number): string[] {
84
+ if (!text || text.length === 0) {
85
+ return [];
86
+ }
87
+
88
+ if (text.length <= limit) {
89
+ return [text];
90
+ }
91
+
92
+ const chunks: string[] = [];
93
+ const lines = text.split("\n");
94
+ let currentChunk = "";
95
+
96
+ for (const line of lines) {
97
+ if (currentChunk.length + line.length + 1 <= limit) {
98
+ currentChunk += (currentChunk ? "\n" : "") + line;
99
+ } else {
100
+ if (currentChunk) {
101
+ chunks.push(currentChunk);
102
+ }
103
+ if (line.length <= limit) {
104
+ currentChunk = line;
105
+ } else {
106
+ const subChunks = splitLongLine(line, limit);
107
+ chunks.push(...subChunks.slice(0, -1));
108
+ currentChunk = subChunks[subChunks.length - 1] || "";
109
+ }
110
+ }
111
+ }
112
+
113
+ if (currentChunk) {
114
+ chunks.push(currentChunk);
115
+ }
116
+
117
+ return chunks;
118
+ }
119
+
120
+ function splitLongLine(line: string, limit: number): string[] {
121
+ const chunks: string[] = [];
122
+ for (let i = 0; i < line.length; i += limit) {
123
+ chunks.push(line.slice(i, i + limit));
124
+ }
125
+ return chunks;
126
+ }