@zxh126/pi-qqbot 2.3.0 → 2.4.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/qq/gateway.ts CHANGED
@@ -1,265 +1,265 @@
1
- import WebSocket from "ws";
2
- import { GATEWAY_PATH } from "../constants";
3
- import { log } from "../logger";
4
-
5
- const OP_DISPATCH = 0;
6
- const OP_HEARTBEAT = 1;
7
- const OP_IDENTIFY = 2;
8
- const OP_RESUME = 6;
9
- const OP_HELLO = 10;
10
- const OP_HEARTBEAT_ACK = 11;
11
-
12
- export interface QQAttachment {
13
- /** 签名下载地址(带 rkey,有效期很短,收到就要下) */
14
- url: string;
15
- /** content_type:image/*、audio/*、video/*、file…;缺省时按扩展名推断 */
16
- contentType: string;
17
- filename?: string;
18
- size?: number;
19
- }
20
-
21
- export interface QQMessage {
22
- openid: string;
23
- content: string;
24
- msgId: string;
25
- timestamp: number;
26
- attachments: QQAttachment[];
27
- quotedText?: string;
28
- }
29
-
30
- export interface QQGatewayOptions {
31
- getGatewayUrl: () => Promise<string>;
32
- getToken: () => Promise<string>;
33
- intents: number;
34
- connected: () => void;
35
- disconnected: () => void;
36
- message: (msg: QQMessage) => Promise<void> | void;
37
- reconnectBaseMs?: number;
38
- maxSeen?: number;
39
- }
40
-
41
- export class QQGateway {
42
- private ws: WebSocket | null = null;
43
- private sessionId: string | null = null;
44
- private lastSeq: number | null = null;
45
- private resumeAttempted = false;
46
- private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
47
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
48
- private reconnectAttempt = 0;
49
- private stopped = false;
50
- private seenIds = new Set<string>();
51
- private seenOrder: string[] = [];
52
-
53
- constructor(private opts: QQGatewayOptions) {}
54
-
55
- private isDuplicate(key: string): boolean {
56
- if (!key) return false;
57
- if (this.seenIds.has(key)) return true;
58
- this.seenIds.add(key);
59
- this.seenOrder.push(key);
60
- const cap = this.opts.maxSeen ?? 1000;
61
- if (this.seenOrder.length > cap) {
62
- const oldest = this.seenOrder.shift();
63
- if (oldest !== undefined) this.seenIds.delete(oldest);
64
- }
65
- return false;
66
- }
67
-
68
- start() {
69
- this.stopped = false;
70
- this.connect().catch(() => this.scheduleReconnect());
71
- }
72
-
73
- stop() {
74
- this.stopped = true;
75
- this.cleanup();
76
- }
77
-
78
- private cleanup() {
79
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
80
- this.heartbeatTimer = null;
81
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
82
- this.reconnectTimer = null;
83
- if (this.ws) {
84
- this.ws.removeAllListeners();
85
- try {
86
- this.ws.close();
87
- } catch {
88
- /* ignore */
89
- }
90
- }
91
- this.ws = null;
92
- }
93
-
94
- private scheduleReconnect() {
95
- if (this.stopped) return;
96
- if (this.resumeAttempted) {
97
- this.resumeAttempted = false;
98
- this.sessionId = null;
99
- this.lastSeq = null;
100
- }
101
- const base = this.opts.reconnectBaseMs ?? 1000;
102
- const delay = Math.min(base * 2 ** this.reconnectAttempt, 60_000);
103
- this.reconnectAttempt++;
104
- this.reconnectTimer = setTimeout(() => {
105
- if (this.stopped) return;
106
- this.connect().catch(() => this.scheduleReconnect());
107
- }, delay);
108
- }
109
-
110
- private async connect() {
111
- log("[gateway] 开始连接...");
112
- const url = await this.opts.getGatewayUrl();
113
- log("[gateway] 网关地址:", url);
114
- const ws = new WebSocket(url);
115
- this.ws = ws;
116
- ws.on("open", () => log("[gateway] WebSocket 已连接,等待 HELLO..."));
117
- ws.on("message", (raw: any) => {
118
- let pkt: any;
119
- try {
120
- pkt = JSON.parse(raw.toString());
121
- } catch (e) {
122
- log("[gateway] 丢弃非 JSON 网关帧:", String(e).slice(0, 120));
123
- return;
124
- }
125
- this.handlePacket(pkt);
126
- });
127
- ws.on("close", (code: number) => {
128
- log("[gateway] WebSocket 关闭 code=", code);
129
- this.opts.disconnected();
130
- this.scheduleReconnect();
131
- });
132
- ws.on("error", (e: any) => {
133
- log("[gateway] WebSocket 错误:", String(e?.message ?? e));
134
- ws.terminate();
135
- });
136
- }
137
-
138
- private handlePacket(pkt: any) {
139
- switch (pkt.op) {
140
- case OP_HELLO: {
141
- const interval = pkt.d.heartbeat_interval;
142
- this.startHeartbeat(interval);
143
- if (this.sessionId !== null && this.lastSeq !== null) {
144
- this.resumeAttempted = true;
145
- void this.sendResume().catch(() => this.ws?.terminate());
146
- } else {
147
- this.resumeAttempted = false;
148
- void this.sendIdentify().catch(() => this.ws?.terminate());
149
- }
150
- break;
151
- }
152
- case OP_HEARTBEAT_ACK:
153
- break;
154
- case OP_DISPATCH: {
155
- this.lastSeq = pkt.s ?? this.lastSeq;
156
- const d = pkt.d ?? {};
157
- if (this.isDuplicate(pkt.id ?? String(d.msg_id ?? d.id ?? ""))) return;
158
- this.handleDispatch(pkt.t ?? "", d);
159
- break;
160
- }
161
- }
162
- }
163
-
164
- private async sendIdentify() {
165
- const token = await this.opts.getToken();
166
- log("[gateway] 发送 identify...");
167
- this.ws?.send(
168
- JSON.stringify({
169
- op: OP_IDENTIFY,
170
- d: { token: `QQBot ${token}`, intents: this.opts.intents, shard: [0, 1] },
171
- }),
172
- );
173
- }
174
-
175
- private async sendResume() {
176
- const token = await this.opts.getToken();
177
- this.ws?.send(
178
- JSON.stringify({
179
- op: OP_RESUME,
180
- d: { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.lastSeq },
181
- }),
182
- );
183
- }
184
-
185
- private startHeartbeat(intervalMs: number) {
186
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
187
- this.heartbeatTimer = setInterval(() => {
188
- this.ws?.send(JSON.stringify({ op: OP_HEARTBEAT, d: this.lastSeq }));
189
- }, intervalMs);
190
- }
191
-
192
- private handleDispatch(t: string, d: any) {
193
- if (t === "READY") {
194
- this.sessionId = String(d.session_id);
195
- this.resumeAttempted = false;
196
- this.reconnectAttempt = 0;
197
- log("[gateway] ✅ READY,bot 已上线 session=", this.sessionId);
198
- this.opts.connected();
199
- return;
200
- }
201
- if (t === "RESUMED") {
202
- this.resumeAttempted = false;
203
- this.reconnectAttempt = 0;
204
- log("[gateway] RESUMED 会话恢复");
205
- this.opts.connected();
206
- return;
207
- }
208
- if (t === "C2C_MESSAGE_CREATE") {
209
- const rawAttachments = Array.isArray(d.attachments) ? d.attachments : [];
210
- const attachments = rawAttachments
211
- .filter((a: any) => !!a && typeof a === "object")
212
- // 语音消息常常只给 voice_wav_url;其余附件用 url
213
- .map((a: any) => ({
214
- url: String(a.url ?? a.voice_wav_url ?? "").trim(),
215
- contentType: String(a.content_type ?? a.contentType ?? "").trim(),
216
- filename: a.filename ? String(a.filename) : undefined,
217
- size: Number.isFinite(Number(a.size)) ? Number(a.size) : undefined,
218
- }))
219
- .filter((a: any) => a.url);
220
- // 下载地址带 rkey(临时凭据),日志只留类别与来源主机
221
- log(
222
- "[gateway] C2C 消息 openid=",
223
- String(d.author?.user_openid ?? d.author?.id ?? d.openid ?? ""),
224
- "content=",
225
- String(d.content ?? "").slice(0, 80),
226
- "attachments=",
227
- attachments.length,
228
- attachments.length > 0
229
- ? attachments
230
- .map((a: any) => {
231
- try {
232
- return new URL(a.url).host;
233
- } catch {
234
- return "?";
235
- }
236
- })
237
- .join(",")
238
- : "",
239
- );
240
- this.opts.message({
241
- openid: String(d.author?.user_openid ?? d.author?.id ?? d.openid ?? ""),
242
- content: String(d.content ?? ""),
243
- msgId: String(d.id ?? d.msg_id ?? ""),
244
- timestamp: Date.parse(String(d.timestamp ?? "")) || Date.now(),
245
- attachments,
246
- quotedText: undefined,
247
- });
248
- }
249
- }
250
- }
251
-
252
- export function createGatewayUrlFetcher(
253
- restBase: string,
254
- getToken: () => Promise<string>,
255
- fetchFn: typeof fetch = fetch,
256
- ): () => Promise<string> {
257
- return async () => {
258
- const token = await getToken();
259
- const res = await fetchFn(`${restBase}${GATEWAY_PATH}`, {
260
- headers: { Authorization: `QQBot ${token}` },
261
- });
262
- if (!res.ok) throw new Error(`get gateway failed: HTTP ${res.status}`);
263
- return (await res.json()).url;
264
- };
265
- }
1
+ import WebSocket from "ws";
2
+ import { GATEWAY_PATH } from "../constants";
3
+ import { log } from "../logger";
4
+
5
+ const OP_DISPATCH = 0;
6
+ const OP_HEARTBEAT = 1;
7
+ const OP_IDENTIFY = 2;
8
+ const OP_RESUME = 6;
9
+ const OP_HELLO = 10;
10
+ const OP_HEARTBEAT_ACK = 11;
11
+
12
+ export interface QQAttachment {
13
+ /** 签名下载地址(带 rkey,有效期很短,收到就要下) */
14
+ url: string;
15
+ /** content_type:image/*、audio/*、video/*、file…;缺省时按扩展名推断 */
16
+ contentType: string;
17
+ filename?: string;
18
+ size?: number;
19
+ }
20
+
21
+ export interface QQMessage {
22
+ openid: string;
23
+ content: string;
24
+ msgId: string;
25
+ timestamp: number;
26
+ attachments: QQAttachment[];
27
+ quotedText?: string;
28
+ }
29
+
30
+ export interface QQGatewayOptions {
31
+ getGatewayUrl: () => Promise<string>;
32
+ getToken: () => Promise<string>;
33
+ intents: number;
34
+ connected: () => void;
35
+ disconnected: () => void;
36
+ message: (msg: QQMessage) => Promise<void> | void;
37
+ reconnectBaseMs?: number;
38
+ maxSeen?: number;
39
+ }
40
+
41
+ export class QQGateway {
42
+ private ws: WebSocket | null = null;
43
+ private sessionId: string | null = null;
44
+ private lastSeq: number | null = null;
45
+ private resumeAttempted = false;
46
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
47
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
48
+ private reconnectAttempt = 0;
49
+ private stopped = false;
50
+ private seenIds = new Set<string>();
51
+ private seenOrder: string[] = [];
52
+
53
+ constructor(private opts: QQGatewayOptions) {}
54
+
55
+ private isDuplicate(key: string): boolean {
56
+ if (!key) return false;
57
+ if (this.seenIds.has(key)) return true;
58
+ this.seenIds.add(key);
59
+ this.seenOrder.push(key);
60
+ const cap = this.opts.maxSeen ?? 1000;
61
+ if (this.seenOrder.length > cap) {
62
+ const oldest = this.seenOrder.shift();
63
+ if (oldest !== undefined) this.seenIds.delete(oldest);
64
+ }
65
+ return false;
66
+ }
67
+
68
+ start() {
69
+ this.stopped = false;
70
+ this.connect().catch(() => this.scheduleReconnect());
71
+ }
72
+
73
+ stop() {
74
+ this.stopped = true;
75
+ this.cleanup();
76
+ }
77
+
78
+ private cleanup() {
79
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
80
+ this.heartbeatTimer = null;
81
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
82
+ this.reconnectTimer = null;
83
+ if (this.ws) {
84
+ this.ws.removeAllListeners();
85
+ try {
86
+ this.ws.close();
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ }
91
+ this.ws = null;
92
+ }
93
+
94
+ private scheduleReconnect() {
95
+ if (this.stopped) return;
96
+ if (this.resumeAttempted) {
97
+ this.resumeAttempted = false;
98
+ this.sessionId = null;
99
+ this.lastSeq = null;
100
+ }
101
+ const base = this.opts.reconnectBaseMs ?? 1000;
102
+ const delay = Math.min(base * 2 ** this.reconnectAttempt, 60_000);
103
+ this.reconnectAttempt++;
104
+ this.reconnectTimer = setTimeout(() => {
105
+ if (this.stopped) return;
106
+ this.connect().catch(() => this.scheduleReconnect());
107
+ }, delay);
108
+ }
109
+
110
+ private async connect() {
111
+ log("[gateway] 开始连接...");
112
+ const url = await this.opts.getGatewayUrl();
113
+ log("[gateway] 网关地址:", url);
114
+ const ws = new WebSocket(url);
115
+ this.ws = ws;
116
+ ws.on("open", () => log("[gateway] WebSocket 已连接,等待 HELLO..."));
117
+ ws.on("message", (raw: any) => {
118
+ let pkt: any;
119
+ try {
120
+ pkt = JSON.parse(raw.toString());
121
+ } catch (e) {
122
+ log("[gateway] 丢弃非 JSON 网关帧:", String(e).slice(0, 120));
123
+ return;
124
+ }
125
+ this.handlePacket(pkt);
126
+ });
127
+ ws.on("close", (code: number) => {
128
+ log("[gateway] WebSocket 关闭 code=", code);
129
+ this.opts.disconnected();
130
+ this.scheduleReconnect();
131
+ });
132
+ ws.on("error", (e: any) => {
133
+ log("[gateway] WebSocket 错误:", String(e?.message ?? e));
134
+ ws.terminate();
135
+ });
136
+ }
137
+
138
+ private handlePacket(pkt: any) {
139
+ switch (pkt.op) {
140
+ case OP_HELLO: {
141
+ const interval = pkt.d.heartbeat_interval;
142
+ this.startHeartbeat(interval);
143
+ if (this.sessionId !== null && this.lastSeq !== null) {
144
+ this.resumeAttempted = true;
145
+ void this.sendResume().catch(() => this.ws?.terminate());
146
+ } else {
147
+ this.resumeAttempted = false;
148
+ void this.sendIdentify().catch(() => this.ws?.terminate());
149
+ }
150
+ break;
151
+ }
152
+ case OP_HEARTBEAT_ACK:
153
+ break;
154
+ case OP_DISPATCH: {
155
+ this.lastSeq = pkt.s ?? this.lastSeq;
156
+ const d = pkt.d ?? {};
157
+ if (this.isDuplicate(pkt.id ?? String(d.msg_id ?? d.id ?? ""))) return;
158
+ this.handleDispatch(pkt.t ?? "", d);
159
+ break;
160
+ }
161
+ }
162
+ }
163
+
164
+ private async sendIdentify() {
165
+ const token = await this.opts.getToken();
166
+ log("[gateway] 发送 identify...");
167
+ this.ws?.send(
168
+ JSON.stringify({
169
+ op: OP_IDENTIFY,
170
+ d: { token: `QQBot ${token}`, intents: this.opts.intents, shard: [0, 1] },
171
+ }),
172
+ );
173
+ }
174
+
175
+ private async sendResume() {
176
+ const token = await this.opts.getToken();
177
+ this.ws?.send(
178
+ JSON.stringify({
179
+ op: OP_RESUME,
180
+ d: { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.lastSeq },
181
+ }),
182
+ );
183
+ }
184
+
185
+ private startHeartbeat(intervalMs: number) {
186
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
187
+ this.heartbeatTimer = setInterval(() => {
188
+ this.ws?.send(JSON.stringify({ op: OP_HEARTBEAT, d: this.lastSeq }));
189
+ }, intervalMs);
190
+ }
191
+
192
+ private handleDispatch(t: string, d: any) {
193
+ if (t === "READY") {
194
+ this.sessionId = String(d.session_id);
195
+ this.resumeAttempted = false;
196
+ this.reconnectAttempt = 0;
197
+ log("[gateway] ✅ READY,bot 已上线 session=", this.sessionId);
198
+ this.opts.connected();
199
+ return;
200
+ }
201
+ if (t === "RESUMED") {
202
+ this.resumeAttempted = false;
203
+ this.reconnectAttempt = 0;
204
+ log("[gateway] RESUMED 会话恢复");
205
+ this.opts.connected();
206
+ return;
207
+ }
208
+ if (t === "C2C_MESSAGE_CREATE") {
209
+ const rawAttachments = Array.isArray(d.attachments) ? d.attachments : [];
210
+ const attachments = rawAttachments
211
+ .filter((a: any) => !!a && typeof a === "object")
212
+ // 语音消息常常只给 voice_wav_url;其余附件用 url
213
+ .map((a: any) => ({
214
+ url: String(a.url ?? a.voice_wav_url ?? "").trim(),
215
+ contentType: String(a.content_type ?? a.contentType ?? "").trim(),
216
+ filename: a.filename ? String(a.filename) : undefined,
217
+ size: Number.isFinite(Number(a.size)) ? Number(a.size) : undefined,
218
+ }))
219
+ .filter((a: any) => a.url);
220
+ // 下载地址带 rkey(临时凭据),日志只留类别与来源主机
221
+ log(
222
+ "[gateway] C2C 消息 openid=",
223
+ String(d.author?.user_openid ?? d.author?.id ?? d.openid ?? ""),
224
+ "content=",
225
+ String(d.content ?? "").slice(0, 80),
226
+ "attachments=",
227
+ attachments.length,
228
+ attachments.length > 0
229
+ ? attachments
230
+ .map((a: any) => {
231
+ try {
232
+ return new URL(a.url).host;
233
+ } catch {
234
+ return "?";
235
+ }
236
+ })
237
+ .join(",")
238
+ : "",
239
+ );
240
+ this.opts.message({
241
+ openid: String(d.author?.user_openid ?? d.author?.id ?? d.openid ?? ""),
242
+ content: String(d.content ?? ""),
243
+ msgId: String(d.id ?? d.msg_id ?? ""),
244
+ timestamp: Date.parse(String(d.timestamp ?? "")) || Date.now(),
245
+ attachments,
246
+ quotedText: undefined,
247
+ });
248
+ }
249
+ }
250
+ }
251
+
252
+ export function createGatewayUrlFetcher(
253
+ restBase: string,
254
+ getToken: () => Promise<string>,
255
+ fetchFn: typeof fetch = fetch,
256
+ ): () => Promise<string> {
257
+ return async () => {
258
+ const token = await getToken();
259
+ const res = await fetchFn(`${restBase}${GATEWAY_PATH}`, {
260
+ headers: { Authorization: `QQBot ${token}` },
261
+ });
262
+ if (!res.ok) throw new Error(`get gateway failed: HTTP ${res.status}`);
263
+ return (await res.json()).url;
264
+ };
265
+ }