cortico-world-astrbot 1.0.2 → 1.0.4

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.4
4
+
5
+ - README 修正协议说明:`capabilities.images` 只在 `hello` 帧里自报,`ready` 帧不带;并说明它只在重连时刷新。
6
+ - 补上实机验证环境(Cortico 0.2.8 / Coopanion 0.2.12)。
7
+
8
+ ## 1.0.3
9
+
10
+ - 加心跳与静默检测:休眠唤醒、切换网络、对端进程消失时,socket 可能「开着但永远没有下文」,而 `close` 事件不会来。现在每 20 秒 ping 一次,60 秒没有任何往来就主动重建,不再静默丢消息。
11
+
3
12
  ## 1.0.2
4
13
 
5
14
  - 桥的 `error` 事件只认现役 socket:重启时旧连接迟到的错误不再被当成新连接的错误记一条。
package/README.md CHANGED
@@ -4,10 +4,12 @@
4
4
 
5
5
  让 Cortico 的人格 Bot 进 QQ 群说话,同时把 QQ 账号与插件能力留在 AstrBot 手里。
6
6
 
7
- **当前版本:1.0.2**(更新内容见 [CHANGELOG.md](CHANGELOG.md))
7
+ **当前版本:1.0.4**(更新内容见 [CHANGELOG.md](CHANGELOG.md))
8
8
 
9
9
  项目地址:[as124da1231/cortico-world-astrbot](https://github.com/as124da1231/cortico-world-astrbot)。
10
10
 
11
+ 已在 **Cortico 0.2.8 / Coopanion 0.2.12** 上实机验证:文字、图片(单图与多图)、撤回/戳一戳通知、断线重连。
12
+
11
13
  ## 为什么是这样一个形状
12
14
 
13
15
  Cortico 自带的 `qq` World 能直接连 OneBot 协议端,但它只做收发消息。如果你的 QQ 号上已经有一整套 AstrBot 插件(画图、点歌、战绩查询、媒体解析……),换掉那套生态的代价很高。
@@ -96,12 +98,12 @@ corepack pnpm add cortico-world-astrbot
96
98
  ```
97
99
 
98
100
  `capabilities.images` 表示本端模型能直接读图;为真时桥才附原图,否则自己转成文字。
101
+ 这个值由 Cortico 侧按当前端点的多模态能力算出来,只在**每次重连握手时**刷新——中途改了端点设置要等重连(或重启进程)才生效。
99
102
 
100
103
  桥推给客户端:
101
104
 
102
105
  ```json
103
- { "t": "ready", "v": 1, "self": { "id": "…", "name": "…" },
104
- "admins": ["42"], "capabilities": { "images": false } }
106
+ { "t": "ready", "v": 1, "self": { "id": "…", "name": "…" }, "admins": ["42"] }
105
107
 
106
108
  { "t": "msg", "id": "消息id", "ts": "2026-10-26T10:00:00+08:00",
107
109
  "conv": { "kind": "group", "id": "123456", "name": "群名" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-astrbot",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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
@@ -94,6 +94,10 @@ export interface BridgeDriverOptions {
94
94
  reconnectMaxMs?: number;
95
95
  /** 一次发送等确认的超时。 */
96
96
  sendTimeoutMs?: number;
97
+ /** 心跳间隔;0 表示不做活性检测。 */
98
+ heartbeatMs?: number;
99
+ /** 超过这么久没有任何往来就重建连接(休眠唤醒后的死连接靠它发现)。 */
100
+ idleTimeoutMs?: number;
97
101
  log?: Logger;
98
102
  }
99
103
 
@@ -105,6 +109,12 @@ interface PendingSend {
105
109
  /** 发送确认的默认超时。 */
106
110
  export const DEFAULT_SEND_TIMEOUT_MS = 15_000;
107
111
 
112
+ /** 心跳间隔:主动 ping 一次,顺便刷新活跃时间。 */
113
+ export const DEFAULT_HEARTBEAT_MS = 20_000;
114
+
115
+ /** 多久没有任何往来就认定连接已死。桥每 20 秒 ping 一次,留三倍余量。 */
116
+ export const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
117
+
108
118
  export class AstrbotBridgeDriver implements BridgeDriver {
109
119
  private readonly opts: BridgeDriverOptions;
110
120
  private readonly log: Logger;
@@ -112,6 +122,8 @@ export class AstrbotBridgeDriver implements BridgeDriver {
112
122
  private stopped = true;
113
123
  private backoffMs: number;
114
124
  private reconnectTimer?: ReturnType<typeof setTimeout>;
125
+ private heartbeat?: ReturnType<typeof setInterval>;
126
+ private lastActivity = 0;
115
127
  private ridSeq = 0;
116
128
  private pending = new Map<string, PendingSend>();
117
129
  private messageCbs: Array<(m: BridgeMessage) => void> = [];
@@ -167,6 +179,7 @@ export class AstrbotBridgeDriver implements BridgeDriver {
167
179
 
168
180
  async stop(): Promise<void> {
169
181
  this.stopped = true;
182
+ this.stopHeartbeat();
170
183
  if (this.reconnectTimer) {
171
184
  clearTimeout(this.reconnectTimer);
172
185
  this.reconnectTimer = undefined;
@@ -273,11 +286,24 @@ export class AstrbotBridgeDriver implements BridgeDriver {
273
286
  });
274
287
  }
275
288
  this.emitState(true);
289
+ this.startHeartbeat(ws);
276
290
  settle();
277
291
  });
278
292
 
279
293
  ws.on('message', (data) => {
280
- if (!this.stopped && this.ws === ws) this.handleFrame(String(data));
294
+ if (!this.stopped && this.ws === ws) {
295
+ this.lastActivity = Date.now();
296
+ this.handleFrame(String(data));
297
+ }
298
+ });
299
+
300
+ // 桥每 20 秒 ping 一次;收到任何一帧都算「还活着」。
301
+ ws.on('ping', () => {
302
+ if (this.ws === ws) this.lastActivity = Date.now();
303
+ });
304
+
305
+ ws.on('pong', () => {
306
+ if (this.ws === ws) this.lastActivity = Date.now();
281
307
  });
282
308
 
283
309
  ws.on('error', (err) => {
@@ -290,6 +316,7 @@ export class AstrbotBridgeDriver implements BridgeDriver {
290
316
  const isCurrent = this.ws === ws;
291
317
  const wasConnected = this.connected;
292
318
  if (isCurrent) {
319
+ this.stopHeartbeat();
293
320
  this.ws = undefined;
294
321
  this.connected = false;
295
322
  this.rejectAllPending('与桥的连接已断开');
@@ -303,6 +330,43 @@ export class AstrbotBridgeDriver implements BridgeDriver {
303
330
  });
304
331
  }
305
332
 
333
+ /**
334
+ * 连接死掉时 socket 不一定给我们 close:机器休眠、网络切换、对端进程消失都可能留下
335
+ * 一条「开着但永远没有下文」的连接。定期 ping,超过 idleTimeoutMs 没有任何往来就主动
336
+ * terminate,让 close 走正常重连。
337
+ */
338
+ private startHeartbeat(ws: WebSocket): void {
339
+ this.stopHeartbeat();
340
+ const every = this.opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
341
+ if (every <= 0) return;
342
+ const idleLimit = this.opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
343
+ this.lastActivity = Date.now();
344
+ this.heartbeat = setInterval(() => {
345
+ if (this.ws !== ws) return;
346
+ const idle = Date.now() - this.lastActivity;
347
+ if (idle > idleLimit) {
348
+ this.log.warn('桥连接长时间没有任何动静,重建连接', {
349
+ idleMs: idle,
350
+ idleLimitMs: idleLimit,
351
+ });
352
+ ws.terminate();
353
+ return;
354
+ }
355
+ try {
356
+ ws.ping();
357
+ } catch {
358
+ // 发不出去就等 close 接手,这里不再多记一条。
359
+ }
360
+ }, every);
361
+ }
362
+
363
+ private stopHeartbeat(): void {
364
+ if (this.heartbeat) {
365
+ clearInterval(this.heartbeat);
366
+ this.heartbeat = undefined;
367
+ }
368
+ }
369
+
306
370
  private scheduleReconnect(): void {
307
371
  if (this.reconnectTimer || this.stopped) return;
308
372
  const delay = this.backoffMs;