cortico-world-astrbot 1.0.1 → 1.0.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ - 桥的 `error` 事件只认现役 socket:重启时旧连接迟到的错误不再被当成新连接的错误记一条。
6
+ - `start()` 先撤掉挂起的重连定时器。之前退避等待期间重新连接,会同时留下两条 socket(桥会把旧的踢掉,但会多一次握手)。
7
+
3
8
  ## 1.0.1
4
9
 
5
10
  - 加上扩展图标(`cortico.icon`),首发时市场卡片是空白的。
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  让 Cortico 的人格 Bot 进 QQ 群说话,同时把 QQ 账号与插件能力留在 AstrBot 手里。
6
6
 
7
- **当前版本:1.0.1**(更新内容见 [CHANGELOG.md](CHANGELOG.md))
7
+ **当前版本:1.0.2**(更新内容见 [CHANGELOG.md](CHANGELOG.md))
8
8
 
9
9
  项目地址:[as124da1231/cortico-world-astrbot](https://github.com/as124da1231/cortico-world-astrbot)。
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-astrbot",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "Cortico World: join QQ conversations through an AstrBot bridge, so the bot keeps AstrBot's plugins while a Cortico persona drives the talking",
6
6
  "license": "MIT",
package/src/driver.ts CHANGED
@@ -1,458 +1,466 @@
1
- /**
2
- * AstrbotBridgeDriver —— 薄连接层。
3
- *
4
- * 本 World 作 WS **客户端**连 AstrBot 侧的桥。选这个方向是因为服务器有公网地址、
5
- * 本机没有:让能被动接受连接的一方当服务端,就不必在内网穿透上再搭一层。
6
- *
7
- * 这一层不做语义判断:收到什么形状就交给 World,发送失败一律返回结果对象而不抛。
8
- * 断线按指数退避重连,重连期间 stop() 能干净退出。
9
- */
10
- import WebSocket from 'ws';
11
- import type { Logger } from 'cortico/core/types.ts';
12
- import { nullLogger } from 'cortico/core/util.ts';
13
-
14
- /** 会话种类:群聊或私聊。 */
15
- export type ConvKind = 'group' | 'private';
16
-
17
- /** 会话标识;id 用字符串,群号/QQ 号由桥给什么就存什么。 */
18
- export interface Conv {
19
- kind: ConvKind;
20
- id: string;
21
- /** 群名或对方昵称,仅用于展示。 */
22
- name?: string;
23
- }
24
-
25
- /** 桥推进来的一条消息。字段保持扁平,语义由 World 解释。 */
26
- export interface BridgeMessage {
27
- /** 桥侧的消息唯一标识,用于引用回复与去重。 */
28
- id: string;
29
- /** ISO 8601;缺失时由 World 补当前时间。 */
30
- ts?: string;
31
- conv: Conv;
32
- sender?: { id?: string; name?: string };
33
- text: string;
34
- /** 这条消息是否点了名(@ 或叫到称呼)。由桥判断并如实告知。 */
35
- mentioned?: boolean;
36
- /** 桥允许这条消息的发送者要截图;没有这个字段一律当作不允许。 */
37
- canScreenshot?: boolean;
38
- /** 说话人在 AstrBot 那边是不是管理员(= Cortico 的主人)。 */
39
- role?: 'admin' | 'member';
40
- /** 随消息一起来的图片;只有本端自报能读图时桥才会带。 */
41
- images?: Array<{ mime: string; base64: string }>;
42
- /** 通知类(撤回/入群/戳一戳),不是人说的话,只当上下文。 */
43
- notice?: boolean;
44
- /** 撤回通知指向的消息号。 */
45
- ref?: string;
46
- }
47
-
48
- /** 桥的状态通知,例如 QQ 侧掉线、桥重启。 */
49
- export interface BridgeNotice {
50
- /** 稳定的通知种类,可缺省。 */
51
- kind?: string;
52
- text: string;
53
- ts?: string;
54
- }
55
-
56
- export interface SendResult {
57
- ok: boolean;
58
- /** ok 为 false 时的原因,可直接进工具回执。 */
59
- error?: string;
60
- }
61
-
62
- /** 随消息一起发出的图片。桥按 base64 转给 AstrBot 的消息链。 */
63
- export interface OutgoingImage {
64
- mime: string;
65
- /** 不带 data: 前缀的 base64。 */
66
- base64: string;
67
- }
68
-
69
- /**
70
- * World 依赖的连接层形状。抽成接口是为了让测试塞假驱动进来,
71
- * 不必为了测 World 的投递逻辑去开真的 WebSocket。
72
- */
73
- export interface BridgeDriver {
74
- readonly connected: boolean;
75
- readonly self?: { id?: string; name?: string };
76
- /** 桥报来的管理员 QQ 号;空数组表示还没收到。 */
77
- readonly ownerIds: string[];
78
- onMessage(cb: (m: BridgeMessage) => void): void;
79
- onNotice(cb: (n: BridgeNotice) => void): void;
80
- onState(cb: (connected: boolean) => void): void;
81
- start(): Promise<void>;
82
- stop(): Promise<void>;
83
- send(conv: Conv, text: string, replyTo?: string, image?: OutgoingImage): Promise<SendResult>;
84
- }
85
-
86
- export interface BridgeDriverOptions {
87
- wsUrl: string;
88
- token?: string;
89
- /** 握手时自报的能力;桥据此决定要不要把图片传过来。 */
90
- capabilities?: Record<string, boolean>;
91
- /** 重连等待起点。 */
92
- reconnectBaseMs?: number;
93
- /** 重连等待封顶。 */
94
- reconnectMaxMs?: number;
95
- /** 一次发送等确认的超时。 */
96
- sendTimeoutMs?: number;
97
- log?: Logger;
98
- }
99
-
100
- interface PendingSend {
101
- resolve: (r: SendResult) => void;
102
- timer: ReturnType<typeof setTimeout>;
103
- }
104
-
105
- /** 发送确认的默认超时。 */
106
- export const DEFAULT_SEND_TIMEOUT_MS = 15_000;
107
-
108
- export class AstrbotBridgeDriver implements BridgeDriver {
109
- private readonly opts: BridgeDriverOptions;
110
- private readonly log: Logger;
111
- private ws?: WebSocket;
112
- private stopped = true;
113
- private backoffMs: number;
114
- private reconnectTimer?: ReturnType<typeof setTimeout>;
115
- private ridSeq = 0;
116
- private pending = new Map<string, PendingSend>();
117
- private messageCbs: Array<(m: BridgeMessage) => void> = [];
118
- private noticeCbs: Array<(n: BridgeNotice) => void> = [];
119
- private stateCbs: Array<(connected: boolean) => void> = [];
120
-
121
- /** 最近一次 ready 帧里桥自报的身份。 */
122
- self?: { id?: string; name?: string };
123
- /** 桥报来的管理员 QQ 号:QQ 上这些人是主人本人。 */
124
- ownerIds: string[] = [];
125
-
126
- connected = false;
127
-
128
- constructor(opts: BridgeDriverOptions) {
129
- this.opts = opts;
130
- this.log = opts.log ?? nullLogger();
131
- this.backoffMs = opts.reconnectBaseMs ?? 1000;
132
- }
133
-
134
- onMessage(cb: (m: BridgeMessage) => void): void {
135
- this.messageCbs.push(cb);
136
- }
137
-
138
- onNotice(cb: (n: BridgeNotice) => void): void {
139
- this.noticeCbs.push(cb);
140
- }
141
-
142
- onState(cb: (connected: boolean) => void): void {
143
- this.stateCbs.push(cb);
144
- }
145
-
146
- /**
147
- * 开始连接。首次尝试结束(连上或失败)后 resolve;
148
- * 失败时后台继续按退避重连,**不 reject** —— 装配层不该因为桥没开而挂掉。
149
- */
150
- start(): Promise<void> {
151
- if (!this.opts.wsUrl) {
152
- this.log.info('未配置桥地址,保持未连接');
153
- return Promise.resolve();
154
- }
155
- this.stopped = false;
156
- this.backoffMs = this.opts.reconnectBaseMs ?? 1000;
157
- return new Promise((resolve) => {
158
- this.connectAttempt(resolve);
159
- });
160
- }
161
-
162
- async stop(): Promise<void> {
163
- this.stopped = true;
164
- if (this.reconnectTimer) {
165
- clearTimeout(this.reconnectTimer);
166
- this.reconnectTimer = undefined;
167
- }
168
- this.rejectAllPending('驱动已停止');
169
- const ws = this.ws;
170
- this.ws = undefined;
171
- const was = this.connected;
172
- this.connected = false;
173
- if (ws) ws.terminate();
174
- if (was) this.emitState(false);
175
- }
176
-
177
- /**
178
- * 发一条消息给指定会话。未连接、超时或被桥拒绝都返回 { ok:false },
179
- * 由 World 把它写成失败回执,不抛错。
180
- */
181
- send(conv: Conv, text: string, replyTo?: string, image?: OutgoingImage): Promise<SendResult> {
182
- return new Promise((resolve) => {
183
- const ws = this.ws;
184
- if (!ws || ws.readyState !== WebSocket.OPEN) {
185
- resolve({ ok: false, error: '与 AstrBot 桥的连接未建立' });
186
- return;
187
- }
188
- const rid = `a-${++this.ridSeq}`;
189
- // 带图的帧更大,给桥多一点时间。
190
- const timeout = (this.opts.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS) + (image ? 15_000 : 0);
191
- const timer = setTimeout(() => {
192
- this.pending.delete(rid);
193
- resolve({ ok: false, error: `桥没有在 ${timeout}ms 内确认` });
194
- }, timeout);
195
- this.pending.set(rid, { resolve, timer });
196
- const frame: Record<string, unknown> = {
197
- t: 'send',
198
- rid,
199
- conv: { kind: conv.kind, id: conv.id },
200
- text,
201
- };
202
- if (replyTo) frame.replyTo = replyTo;
203
- if (image) frame.image = { mime: image.mime, base64: image.base64 };
204
- try {
205
- ws.send(JSON.stringify(frame));
206
- } catch (err) {
207
- clearTimeout(timer);
208
- this.pending.delete(rid);
209
- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
210
- }
211
- });
212
- }
213
-
214
- private connectAttempt(onSettled?: () => void): void {
215
- if (this.stopped) {
216
- onSettled?.();
217
- return;
218
- }
219
- let settled = false;
220
- const settle = (): void => {
221
- if (!settled) {
222
- settled = true;
223
- onSettled?.();
224
- }
225
- };
226
-
227
- const token = this.opts.token;
228
- let ws: WebSocket;
229
- try {
230
- ws = new WebSocket(this.opts.wsUrl, {
231
- headers: token ? { Authorization: `Bearer ${token}` } : undefined,
232
- });
233
- } catch (err) {
234
- this.log.warn('桥地址无法解析', {
235
- wsUrl: this.opts.wsUrl,
236
- error: err instanceof Error ? err.message : String(err),
237
- });
238
- settle();
239
- this.scheduleReconnect();
240
- return;
241
- }
242
- this.ws = ws;
243
-
244
- ws.on('open', () => {
245
- if (this.stopped || this.ws !== ws) {
246
- ws.terminate();
247
- settle();
248
- return;
249
- }
250
- this.connected = true;
251
- this.backoffMs = this.opts.reconnectBaseMs ?? 1000;
252
- this.log.info('已连上 AstrBot 桥', { wsUrl: this.opts.wsUrl });
253
- try {
254
- // 握手帧:带上 token 与自己的显示名,桥据此认人。
255
- ws.send(
256
- JSON.stringify({
257
- t: 'hello',
258
- v: 1,
259
- token: token ?? '',
260
- name: 'Cortico',
261
- capabilities: this.opts.capabilities ?? {},
262
- }),
263
- );
264
- } catch (err) {
265
- this.log.warn('握手帧发送失败', {
266
- error: err instanceof Error ? err.message : String(err),
267
- });
268
- }
269
- this.emitState(true);
270
- settle();
271
- });
272
-
273
- ws.on('message', (data) => {
274
- if (!this.stopped && this.ws === ws) this.handleFrame(String(data));
275
- });
276
-
277
- ws.on('error', (err) => {
278
- this.log.warn('桥连接错误', { error: err.message });
279
- });
280
-
281
- ws.on('close', () => {
282
- const isCurrent = this.ws === ws;
283
- const wasConnected = this.connected;
284
- if (isCurrent) {
285
- this.ws = undefined;
286
- this.connected = false;
287
- this.rejectAllPending('与桥的连接已断开');
288
- if (wasConnected) this.log.info('与 AstrBot 桥的连接已断开');
289
- }
290
- settle();
291
- if (isCurrent && !this.stopped) {
292
- if (wasConnected) this.emitState(false);
293
- this.scheduleReconnect();
294
- }
295
- });
296
- }
297
-
298
- private scheduleReconnect(): void {
299
- if (this.reconnectTimer || this.stopped) return;
300
- const delay = this.backoffMs;
301
- this.backoffMs = Math.min(this.backoffMs * 2, this.opts.reconnectMaxMs ?? 30_000);
302
- this.log.debug(`${delay}ms 后重连桥`);
303
- this.reconnectTimer = setTimeout(() => {
304
- this.reconnectTimer = undefined;
305
- if (!this.stopped) this.connectAttempt();
306
- }, delay);
307
- }
308
-
309
- private handleFrame(raw: string): void {
310
- let frame: Record<string, unknown>;
311
- try {
312
- frame = JSON.parse(raw) as Record<string, unknown>;
313
- } catch {
314
- this.log.warn('收到无法解析的帧,已忽略');
315
- return;
316
- }
317
-
318
- const t = typeof frame.t === 'string' ? frame.t : '';
319
-
320
- if (t === 'ack') {
321
- const rid = String(frame.rid ?? '');
322
- const call = this.pending.get(rid);
323
- if (!call) return;
324
- this.pending.delete(rid);
325
- clearTimeout(call.timer);
326
- const ok = frame.ok === true;
327
- call.resolve(
328
- ok ? { ok: true } : { ok: false, error: String(frame.error ?? '桥拒绝发送') },
329
- );
330
- return;
331
- }
332
-
333
- if (t === 'ready') {
334
- const self = frame.self as { id?: unknown; name?: unknown } | undefined;
335
- this.self = {
336
- id: self?.id === undefined ? undefined : String(self.id),
337
- name: self?.name === undefined ? undefined : String(self.name),
338
- };
339
- const admins = frame.admins;
340
- this.ownerIds = Array.isArray(admins)
341
- ? admins.map((v) => String(v).trim()).filter((v) => v !== '')
342
- : [];
343
- this.log.info('桥已就绪', { self: this.self, owners: this.ownerIds.length });
344
- return;
345
- }
346
-
347
- if (t === 'msg') {
348
- const msg = parseMessage(frame);
349
- if (!msg) {
350
- this.log.warn('消息帧形状不符,已忽略');
351
- return;
352
- }
353
- for (const cb of [...this.messageCbs]) {
354
- try {
355
- cb(msg);
356
- } catch (err) {
357
- this.log.error('消息回调抛错', {
358
- error: err instanceof Error ? err.message : String(err),
359
- });
360
- }
361
- }
362
- return;
363
- }
364
-
365
- if (t === 'notice') {
366
- const notice: BridgeNotice = {
367
- kind: typeof frame.kind === 'string' ? frame.kind : undefined,
368
- text: typeof frame.text === 'string' ? frame.text : '',
369
- ts: typeof frame.ts === 'string' ? frame.ts : undefined,
370
- };
371
- if (!notice.text) return;
372
- for (const cb of [...this.noticeCbs]) {
373
- try {
374
- cb(notice);
375
- } catch (err) {
376
- this.log.error('通知回调抛错', {
377
- error: err instanceof Error ? err.message : String(err),
378
- });
379
- }
380
- }
381
- return;
382
- }
383
-
384
- this.log.debug('未知帧类型,已忽略', { t });
385
- }
386
-
387
- private emitState(connected: boolean): void {
388
- for (const cb of [...this.stateCbs]) {
389
- try {
390
- cb(connected);
391
- } catch (err) {
392
- this.log.error('状态回调抛错', {
393
- error: err instanceof Error ? err.message : String(err),
394
- });
395
- }
396
- }
397
- }
398
-
399
- private rejectAllPending(reason: string): void {
400
- for (const call of this.pending.values()) {
401
- clearTimeout(call.timer);
402
- call.resolve({ ok: false, error: reason });
403
- }
404
- this.pending.clear();
405
- }
406
- }
407
-
408
- /** 把一帧 `msg` 收敛成 BridgeMessage;形状不对时返回 null。 */
409
- export function parseMessage(frame: Record<string, unknown>): BridgeMessage | null {
410
- const convRaw = frame.conv as Record<string, unknown> | undefined;
411
- if (!convRaw) return null;
412
- const kind = convRaw.kind === 'private' ? 'private' : convRaw.kind === 'group' ? 'group' : null;
413
- if (!kind) return null;
414
- const id = convRaw.id === undefined || convRaw.id === null ? '' : String(convRaw.id);
415
- if (!id) return null;
416
-
417
- const text = typeof frame.text === 'string' ? frame.text : '';
418
- const senderRaw = frame.sender as Record<string, unknown> | undefined;
419
-
420
- return {
421
- id: frame.id === undefined ? '' : String(frame.id),
422
- ts: typeof frame.ts === 'string' ? frame.ts : undefined,
423
- conv: {
424
- kind,
425
- id,
426
- name: typeof convRaw.name === 'string' ? convRaw.name : undefined,
427
- },
428
- sender: senderRaw
429
- ? {
430
- id: senderRaw.id === undefined ? undefined : String(senderRaw.id),
431
- name: typeof senderRaw.name === 'string' ? senderRaw.name : undefined,
432
- }
433
- : undefined,
434
- text,
435
- mentioned: frame.mentioned === true,
436
- canScreenshot: (frame.can as Record<string, unknown> | undefined)?.screenshot === true,
437
- role: frame.role === 'admin' ? 'admin' : 'member',
438
- images: parseImages(frame.images),
439
- notice: frame.kind === 'notice',
440
- ref: typeof frame.ref === 'string' && frame.ref !== '' ? frame.ref : undefined,
441
- };
442
- }
443
-
444
- /** 帧里的图片数组;形状不对的项直接丢掉。 */
445
- function parseImages(raw: unknown): Array<{ mime: string; base64: string }> | undefined {
446
- if (!Array.isArray(raw)) return undefined;
447
- const out: Array<{ mime: string; base64: string }> = [];
448
- for (const item of raw) {
449
- if (!item || typeof item !== 'object') continue;
450
- const rec = item as Record<string, unknown>;
451
- const mime = typeof rec.mime === 'string' ? rec.mime : '';
452
- const base64 = typeof rec.base64 === 'string' ? rec.base64 : '';
453
- if (!base64 || !mime.startsWith('image/')) continue;
454
- out.push({ mime, base64 });
455
- if (out.length >= 4) break;
456
- }
457
- return out.length > 0 ? out : undefined;
458
- }
1
+ /**
2
+ * AstrbotBridgeDriver —— 薄连接层。
3
+ *
4
+ * 本 World 作 WS **客户端**连 AstrBot 侧的桥。选这个方向是因为服务器有公网地址、
5
+ * 本机没有:让能被动接受连接的一方当服务端,就不必在内网穿透上再搭一层。
6
+ *
7
+ * 这一层不做语义判断:收到什么形状就交给 World,发送失败一律返回结果对象而不抛。
8
+ * 断线按指数退避重连,重连期间 stop() 能干净退出。
9
+ */
10
+ import WebSocket from 'ws';
11
+ import type { Logger } from 'cortico/core/types.ts';
12
+ import { nullLogger } from 'cortico/core/util.ts';
13
+
14
+ /** 会话种类:群聊或私聊。 */
15
+ export type ConvKind = 'group' | 'private';
16
+
17
+ /** 会话标识;id 用字符串,群号/QQ 号由桥给什么就存什么。 */
18
+ export interface Conv {
19
+ kind: ConvKind;
20
+ id: string;
21
+ /** 群名或对方昵称,仅用于展示。 */
22
+ name?: string;
23
+ }
24
+
25
+ /** 桥推进来的一条消息。字段保持扁平,语义由 World 解释。 */
26
+ export interface BridgeMessage {
27
+ /** 桥侧的消息唯一标识,用于引用回复与去重。 */
28
+ id: string;
29
+ /** ISO 8601;缺失时由 World 补当前时间。 */
30
+ ts?: string;
31
+ conv: Conv;
32
+ sender?: { id?: string; name?: string };
33
+ text: string;
34
+ /** 这条消息是否点了名(@ 或叫到称呼)。由桥判断并如实告知。 */
35
+ mentioned?: boolean;
36
+ /** 桥允许这条消息的发送者要截图;没有这个字段一律当作不允许。 */
37
+ canScreenshot?: boolean;
38
+ /** 说话人在 AstrBot 那边是不是管理员(= Cortico 的主人)。 */
39
+ role?: 'admin' | 'member';
40
+ /** 随消息一起来的图片;只有本端自报能读图时桥才会带。 */
41
+ images?: Array<{ mime: string; base64: string }>;
42
+ /** 通知类(撤回/入群/戳一戳),不是人说的话,只当上下文。 */
43
+ notice?: boolean;
44
+ /** 撤回通知指向的消息号。 */
45
+ ref?: string;
46
+ }
47
+
48
+ /** 桥的状态通知,例如 QQ 侧掉线、桥重启。 */
49
+ export interface BridgeNotice {
50
+ /** 稳定的通知种类,可缺省。 */
51
+ kind?: string;
52
+ text: string;
53
+ ts?: string;
54
+ }
55
+
56
+ export interface SendResult {
57
+ ok: boolean;
58
+ /** ok 为 false 时的原因,可直接进工具回执。 */
59
+ error?: string;
60
+ }
61
+
62
+ /** 随消息一起发出的图片。桥按 base64 转给 AstrBot 的消息链。 */
63
+ export interface OutgoingImage {
64
+ mime: string;
65
+ /** 不带 data: 前缀的 base64。 */
66
+ base64: string;
67
+ }
68
+
69
+ /**
70
+ * World 依赖的连接层形状。抽成接口是为了让测试塞假驱动进来,
71
+ * 不必为了测 World 的投递逻辑去开真的 WebSocket。
72
+ */
73
+ export interface BridgeDriver {
74
+ readonly connected: boolean;
75
+ readonly self?: { id?: string; name?: string };
76
+ /** 桥报来的管理员 QQ 号;空数组表示还没收到。 */
77
+ readonly ownerIds: string[];
78
+ onMessage(cb: (m: BridgeMessage) => void): void;
79
+ onNotice(cb: (n: BridgeNotice) => void): void;
80
+ onState(cb: (connected: boolean) => void): void;
81
+ start(): Promise<void>;
82
+ stop(): Promise<void>;
83
+ send(conv: Conv, text: string, replyTo?: string, image?: OutgoingImage): Promise<SendResult>;
84
+ }
85
+
86
+ export interface BridgeDriverOptions {
87
+ wsUrl: string;
88
+ token?: string;
89
+ /** 握手时自报的能力;桥据此决定要不要把图片传过来。 */
90
+ capabilities?: Record<string, boolean>;
91
+ /** 重连等待起点。 */
92
+ reconnectBaseMs?: number;
93
+ /** 重连等待封顶。 */
94
+ reconnectMaxMs?: number;
95
+ /** 一次发送等确认的超时。 */
96
+ sendTimeoutMs?: number;
97
+ log?: Logger;
98
+ }
99
+
100
+ interface PendingSend {
101
+ resolve: (r: SendResult) => void;
102
+ timer: ReturnType<typeof setTimeout>;
103
+ }
104
+
105
+ /** 发送确认的默认超时。 */
106
+ export const DEFAULT_SEND_TIMEOUT_MS = 15_000;
107
+
108
+ export class AstrbotBridgeDriver implements BridgeDriver {
109
+ private readonly opts: BridgeDriverOptions;
110
+ private readonly log: Logger;
111
+ private ws?: WebSocket;
112
+ private stopped = true;
113
+ private backoffMs: number;
114
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
115
+ private ridSeq = 0;
116
+ private pending = new Map<string, PendingSend>();
117
+ private messageCbs: Array<(m: BridgeMessage) => void> = [];
118
+ private noticeCbs: Array<(n: BridgeNotice) => void> = [];
119
+ private stateCbs: Array<(connected: boolean) => void> = [];
120
+
121
+ /** 最近一次 ready 帧里桥自报的身份。 */
122
+ self?: { id?: string; name?: string };
123
+ /** 桥报来的管理员 QQ 号:QQ 上这些人是主人本人。 */
124
+ ownerIds: string[] = [];
125
+
126
+ connected = false;
127
+
128
+ constructor(opts: BridgeDriverOptions) {
129
+ this.opts = opts;
130
+ this.log = opts.log ?? nullLogger();
131
+ this.backoffMs = opts.reconnectBaseMs ?? 1000;
132
+ }
133
+
134
+ onMessage(cb: (m: BridgeMessage) => void): void {
135
+ this.messageCbs.push(cb);
136
+ }
137
+
138
+ onNotice(cb: (n: BridgeNotice) => void): void {
139
+ this.noticeCbs.push(cb);
140
+ }
141
+
142
+ onState(cb: (connected: boolean) => void): void {
143
+ this.stateCbs.push(cb);
144
+ }
145
+
146
+ /**
147
+ * 开始连接。首次尝试结束(连上或失败)后 resolve;
148
+ * 失败时后台继续按退避重连,**不 reject** —— 装配层不该因为桥没开而挂掉。
149
+ */
150
+ start(): Promise<void> {
151
+ if (!this.opts.wsUrl) {
152
+ this.log.info('未配置桥地址,保持未连接');
153
+ return Promise.resolve();
154
+ }
155
+ this.stopped = false;
156
+ this.backoffMs = this.opts.reconnectBaseMs ?? 1000;
157
+ // 上一次的退避还挂着时重新 start,必须先把定时器撤掉:
158
+ // 否则这次手动连接和定时器各建一条 socket,桥那边会看到两次握手。
159
+ if (this.reconnectTimer) {
160
+ clearTimeout(this.reconnectTimer);
161
+ this.reconnectTimer = undefined;
162
+ }
163
+ return new Promise((resolve) => {
164
+ this.connectAttempt(resolve);
165
+ });
166
+ }
167
+
168
+ async stop(): Promise<void> {
169
+ this.stopped = true;
170
+ if (this.reconnectTimer) {
171
+ clearTimeout(this.reconnectTimer);
172
+ this.reconnectTimer = undefined;
173
+ }
174
+ this.rejectAllPending('驱动已停止');
175
+ const ws = this.ws;
176
+ this.ws = undefined;
177
+ const was = this.connected;
178
+ this.connected = false;
179
+ if (ws) ws.terminate();
180
+ if (was) this.emitState(false);
181
+ }
182
+
183
+ /**
184
+ * 发一条消息给指定会话。未连接、超时或被桥拒绝都返回 { ok:false },
185
+ * 由 World 把它写成失败回执,不抛错。
186
+ */
187
+ send(conv: Conv, text: string, replyTo?: string, image?: OutgoingImage): Promise<SendResult> {
188
+ return new Promise((resolve) => {
189
+ const ws = this.ws;
190
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
191
+ resolve({ ok: false, error: '与 AstrBot 桥的连接未建立' });
192
+ return;
193
+ }
194
+ const rid = `a-${++this.ridSeq}`;
195
+ // 带图的帧更大,给桥多一点时间。
196
+ const timeout = (this.opts.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS) + (image ? 15_000 : 0);
197
+ const timer = setTimeout(() => {
198
+ this.pending.delete(rid);
199
+ resolve({ ok: false, error: `桥没有在 ${timeout}ms 内确认` });
200
+ }, timeout);
201
+ this.pending.set(rid, { resolve, timer });
202
+ const frame: Record<string, unknown> = {
203
+ t: 'send',
204
+ rid,
205
+ conv: { kind: conv.kind, id: conv.id },
206
+ text,
207
+ };
208
+ if (replyTo) frame.replyTo = replyTo;
209
+ if (image) frame.image = { mime: image.mime, base64: image.base64 };
210
+ try {
211
+ ws.send(JSON.stringify(frame));
212
+ } catch (err) {
213
+ clearTimeout(timer);
214
+ this.pending.delete(rid);
215
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
216
+ }
217
+ });
218
+ }
219
+
220
+ private connectAttempt(onSettled?: () => void): void {
221
+ if (this.stopped) {
222
+ onSettled?.();
223
+ return;
224
+ }
225
+ let settled = false;
226
+ const settle = (): void => {
227
+ if (!settled) {
228
+ settled = true;
229
+ onSettled?.();
230
+ }
231
+ };
232
+
233
+ const token = this.opts.token;
234
+ let ws: WebSocket;
235
+ try {
236
+ ws = new WebSocket(this.opts.wsUrl, {
237
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
238
+ });
239
+ } catch (err) {
240
+ this.log.warn('桥地址无法解析', {
241
+ wsUrl: this.opts.wsUrl,
242
+ error: err instanceof Error ? err.message : String(err),
243
+ });
244
+ settle();
245
+ this.scheduleReconnect();
246
+ return;
247
+ }
248
+ this.ws = ws;
249
+
250
+ ws.on('open', () => {
251
+ if (this.stopped || this.ws !== ws) {
252
+ ws.terminate();
253
+ settle();
254
+ return;
255
+ }
256
+ this.connected = true;
257
+ this.backoffMs = this.opts.reconnectBaseMs ?? 1000;
258
+ this.log.info('已连上 AstrBot 桥', { wsUrl: this.opts.wsUrl });
259
+ try {
260
+ // 握手帧:带上 token 与自己的显示名,桥据此认人。
261
+ ws.send(
262
+ JSON.stringify({
263
+ t: 'hello',
264
+ v: 1,
265
+ token: token ?? '',
266
+ name: 'Cortico',
267
+ capabilities: this.opts.capabilities ?? {},
268
+ }),
269
+ );
270
+ } catch (err) {
271
+ this.log.warn('握手帧发送失败', {
272
+ error: err instanceof Error ? err.message : String(err),
273
+ });
274
+ }
275
+ this.emitState(true);
276
+ settle();
277
+ });
278
+
279
+ ws.on('message', (data) => {
280
+ if (!this.stopped && this.ws === ws) this.handleFrame(String(data));
281
+ });
282
+
283
+ ws.on('error', (err) => {
284
+ // 旧 socket 在关闭前还会补一条 error;那不是现在的连接状态,别记成新错误。
285
+ if (this.stopped || this.ws !== ws) return;
286
+ this.log.warn('桥连接错误', { error: err.message });
287
+ });
288
+
289
+ ws.on('close', () => {
290
+ const isCurrent = this.ws === ws;
291
+ const wasConnected = this.connected;
292
+ if (isCurrent) {
293
+ this.ws = undefined;
294
+ this.connected = false;
295
+ this.rejectAllPending('与桥的连接已断开');
296
+ if (wasConnected) this.log.info('与 AstrBot 桥的连接已断开');
297
+ }
298
+ settle();
299
+ if (isCurrent && !this.stopped) {
300
+ if (wasConnected) this.emitState(false);
301
+ this.scheduleReconnect();
302
+ }
303
+ });
304
+ }
305
+
306
+ private scheduleReconnect(): void {
307
+ if (this.reconnectTimer || this.stopped) return;
308
+ const delay = this.backoffMs;
309
+ this.backoffMs = Math.min(this.backoffMs * 2, this.opts.reconnectMaxMs ?? 30_000);
310
+ this.log.debug(`${delay}ms 后重连桥`);
311
+ this.reconnectTimer = setTimeout(() => {
312
+ this.reconnectTimer = undefined;
313
+ if (!this.stopped) this.connectAttempt();
314
+ }, delay);
315
+ }
316
+
317
+ private handleFrame(raw: string): void {
318
+ let frame: Record<string, unknown>;
319
+ try {
320
+ frame = JSON.parse(raw) as Record<string, unknown>;
321
+ } catch {
322
+ this.log.warn('收到无法解析的帧,已忽略');
323
+ return;
324
+ }
325
+
326
+ const t = typeof frame.t === 'string' ? frame.t : '';
327
+
328
+ if (t === 'ack') {
329
+ const rid = String(frame.rid ?? '');
330
+ const call = this.pending.get(rid);
331
+ if (!call) return;
332
+ this.pending.delete(rid);
333
+ clearTimeout(call.timer);
334
+ const ok = frame.ok === true;
335
+ call.resolve(
336
+ ok ? { ok: true } : { ok: false, error: String(frame.error ?? '桥拒绝发送') },
337
+ );
338
+ return;
339
+ }
340
+
341
+ if (t === 'ready') {
342
+ const self = frame.self as { id?: unknown; name?: unknown } | undefined;
343
+ this.self = {
344
+ id: self?.id === undefined ? undefined : String(self.id),
345
+ name: self?.name === undefined ? undefined : String(self.name),
346
+ };
347
+ const admins = frame.admins;
348
+ this.ownerIds = Array.isArray(admins)
349
+ ? admins.map((v) => String(v).trim()).filter((v) => v !== '')
350
+ : [];
351
+ this.log.info('桥已就绪', { self: this.self, owners: this.ownerIds.length });
352
+ return;
353
+ }
354
+
355
+ if (t === 'msg') {
356
+ const msg = parseMessage(frame);
357
+ if (!msg) {
358
+ this.log.warn('消息帧形状不符,已忽略');
359
+ return;
360
+ }
361
+ for (const cb of [...this.messageCbs]) {
362
+ try {
363
+ cb(msg);
364
+ } catch (err) {
365
+ this.log.error('消息回调抛错', {
366
+ error: err instanceof Error ? err.message : String(err),
367
+ });
368
+ }
369
+ }
370
+ return;
371
+ }
372
+
373
+ if (t === 'notice') {
374
+ const notice: BridgeNotice = {
375
+ kind: typeof frame.kind === 'string' ? frame.kind : undefined,
376
+ text: typeof frame.text === 'string' ? frame.text : '',
377
+ ts: typeof frame.ts === 'string' ? frame.ts : undefined,
378
+ };
379
+ if (!notice.text) return;
380
+ for (const cb of [...this.noticeCbs]) {
381
+ try {
382
+ cb(notice);
383
+ } catch (err) {
384
+ this.log.error('通知回调抛错', {
385
+ error: err instanceof Error ? err.message : String(err),
386
+ });
387
+ }
388
+ }
389
+ return;
390
+ }
391
+
392
+ this.log.debug('未知帧类型,已忽略', { t });
393
+ }
394
+
395
+ private emitState(connected: boolean): void {
396
+ for (const cb of [...this.stateCbs]) {
397
+ try {
398
+ cb(connected);
399
+ } catch (err) {
400
+ this.log.error('状态回调抛错', {
401
+ error: err instanceof Error ? err.message : String(err),
402
+ });
403
+ }
404
+ }
405
+ }
406
+
407
+ private rejectAllPending(reason: string): void {
408
+ for (const call of this.pending.values()) {
409
+ clearTimeout(call.timer);
410
+ call.resolve({ ok: false, error: reason });
411
+ }
412
+ this.pending.clear();
413
+ }
414
+ }
415
+
416
+ /** 把一帧 `msg` 收敛成 BridgeMessage;形状不对时返回 null。 */
417
+ export function parseMessage(frame: Record<string, unknown>): BridgeMessage | null {
418
+ const convRaw = frame.conv as Record<string, unknown> | undefined;
419
+ if (!convRaw) return null;
420
+ const kind = convRaw.kind === 'private' ? 'private' : convRaw.kind === 'group' ? 'group' : null;
421
+ if (!kind) return null;
422
+ const id = convRaw.id === undefined || convRaw.id === null ? '' : String(convRaw.id);
423
+ if (!id) return null;
424
+
425
+ const text = typeof frame.text === 'string' ? frame.text : '';
426
+ const senderRaw = frame.sender as Record<string, unknown> | undefined;
427
+
428
+ return {
429
+ id: frame.id === undefined ? '' : String(frame.id),
430
+ ts: typeof frame.ts === 'string' ? frame.ts : undefined,
431
+ conv: {
432
+ kind,
433
+ id,
434
+ name: typeof convRaw.name === 'string' ? convRaw.name : undefined,
435
+ },
436
+ sender: senderRaw
437
+ ? {
438
+ id: senderRaw.id === undefined ? undefined : String(senderRaw.id),
439
+ name: typeof senderRaw.name === 'string' ? senderRaw.name : undefined,
440
+ }
441
+ : undefined,
442
+ text,
443
+ mentioned: frame.mentioned === true,
444
+ canScreenshot: (frame.can as Record<string, unknown> | undefined)?.screenshot === true,
445
+ role: frame.role === 'admin' ? 'admin' : 'member',
446
+ images: parseImages(frame.images),
447
+ notice: frame.kind === 'notice',
448
+ ref: typeof frame.ref === 'string' && frame.ref !== '' ? frame.ref : undefined,
449
+ };
450
+ }
451
+
452
+ /** 帧里的图片数组;形状不对的项直接丢掉。 */
453
+ function parseImages(raw: unknown): Array<{ mime: string; base64: string }> | undefined {
454
+ if (!Array.isArray(raw)) return undefined;
455
+ const out: Array<{ mime: string; base64: string }> = [];
456
+ for (const item of raw) {
457
+ if (!item || typeof item !== 'object') continue;
458
+ const rec = item as Record<string, unknown>;
459
+ const mime = typeof rec.mime === 'string' ? rec.mime : '';
460
+ const base64 = typeof rec.base64 === 'string' ? rec.base64 : '';
461
+ if (!base64 || !mime.startsWith('image/')) continue;
462
+ out.push({ mime, base64 });
463
+ if (out.length >= 4) break;
464
+ }
465
+ return out.length > 0 ? out : undefined;
466
+ }