@bilibili-notify/live 0.0.1-alpha.3 → 0.1.0-alpha.5

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/lib/index.cjs CHANGED
@@ -185,6 +185,8 @@ var RoomContextBase = class {
185
185
  listenerRecord = {};
186
186
  livePushTimerManager = /* @__PURE__ */ new Map();
187
187
  disposed = false;
188
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
189
+ intentionalCloseRooms = /* @__PURE__ */ new Set();
188
190
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
189
191
  interactWord;
190
192
  /**
@@ -222,6 +224,11 @@ var RoomContextBase = class {
222
224
  emitViewers(uid, viewers) {
223
225
  this._emitViewers?.(uid, viewers);
224
226
  }
227
+ consumeIntentionalClose(roomId) {
228
+ const hit = this.intentionalCloseRooms.has(roomId);
229
+ this.intentionalCloseRooms.delete(roomId);
230
+ return hit;
231
+ }
225
232
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
226
233
  get imageRenderer() {
227
234
  return this.config.imageEnabled === false ? null : this._getImageRenderer();
@@ -253,10 +260,12 @@ var RoomContextBase = class {
253
260
  closeListener(roomId) {
254
261
  const listener = this.listenerRecord[roomId];
255
262
  if (!listener) {
263
+ this.intentionalCloseRooms.delete(roomId);
256
264
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接不存在,跳过关闭`);
257
265
  return;
258
266
  }
259
267
  if (listener.closed) {
268
+ this.intentionalCloseRooms.delete(roomId);
260
269
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接已被远端断开`);
261
270
  delete this.listenerRecord[roomId];
262
271
  return;
@@ -280,6 +289,7 @@ var RoomContextBase = class {
280
289
  stopMonitoring(reason, roomId) {
281
290
  if (roomId) {
282
291
  this.logger.error(`[conn] [${roomId}] ${reason},已停止该房间的监测`);
292
+ this.intentionalCloseRooms.add(roomId);
283
293
  this.closeListener(roomId);
284
294
  const timer = this.livePushTimerManager.get(roomId);
285
295
  if (timer) {
@@ -337,6 +347,7 @@ var RoomContext = class extends RoomContextBase {
337
347
  this.logger.warn(`[conn] 直播间 [${roomId}] 连接已存在,跳过创建`);
338
348
  return true;
339
349
  }
350
+ this.consumeIntentionalClose(roomId);
340
351
  const cookiesStr = this.api.getCookiesHeader();
341
352
  let mySelfInfo;
342
353
  try {
@@ -499,37 +510,41 @@ var RoomContext = class extends RoomContextBase {
499
510
  * - `customSpecialDanmakuUsers.msgTemplate`
500
511
  * - `customSpecialUsersEnterTheRoom.msgTemplate`
501
512
  *
502
- * The variable syntax follows the existing `-name` / `-time` / `-watched` style
503
- * (NOT the `{key}` syntax used by `@bilibili-notify/internal`'s `interpolate`),
504
- * because that's what users have in their existing Koishi configs and we keep
505
- * 1:1 backward compatibility.
513
+ * 占位符统一 `{name}` / `{time}` / `{watched}` 语法(与 `@bilibili-notify/internal`
514
+ * `interpolate` 同源)。`applyTemplate` 同时接受 koishi 旧存档里的 legacy
515
+ * `-name` / `-time` 写法 —— 老用户已保存的 `-key` 模板继续生效,新默认与文档
516
+ * 一律走 `{key}`,二者不冲突(单遍正则,longest-first)。
506
517
  */
507
518
  /** Defaults applied when neither sub-level nor global config provides a template. */
508
519
  const DEFAULT_LIVE_TEMPLATES = {
509
- liveStart: "-name 开播啦,当前粉丝数:-follower\n-link",
510
- liveOngoing: "-name 正在直播,已播 -time,累计观看:-watched\n-link",
511
- liveEnd: "-name 下播啦,本次直播了 -time,粉丝变化 -follower_change",
520
+ liveStart: "{name} 开播啦,当前粉丝数:{follower}\n{link}",
521
+ liveOngoing: "{name} 正在直播,已播 {time},累计观看:{watched}\n{link}",
522
+ liveEnd: "{name} 下播啦,本次直播了 {time},粉丝变化 {follower_change}",
512
523
  liveSummaryFallback: "弹幕总结"
513
524
  };
514
525
  function escapeRegExp(s) {
515
526
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
516
527
  }
517
528
  /**
518
- * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。
529
+ * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。`vars` 以**裸键**(`name`/
530
+ * `follower_change`)给出,每个键同时匹配 `{name}`(主)与 legacy `-name`(兼容)。
519
531
  *
520
532
  * P2:此前 `for…replaceAll` 顺序替换有两个缺陷 ——
521
- * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `-link`/`-time` 时
533
+ * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `{link}`/`-link` 时
522
534
  * 会被后续轮次再次替换;
523
- * 2. **前缀吞噬**:`-follower` 先于 `-follower_change` 替换,把后者的
535
+ * 2. **前缀吞噬**:legacy `-follower` 先于 `-follower_change` 替换,把后者的
524
536
  * `-follower` 段吃掉只剩 `_change`。
525
- * 改为基于原始模板的**单遍正则**:token 按长度降序进 alternation(最长优先
526
- * 匹配),每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
537
+ * 改为基于原始模板的**单遍正则**:键按长度降序进 alternation(最长优先匹配),
538
+ * 每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
527
539
  */
528
540
  function applyTemplate(template, vars) {
529
541
  const keys = Object.keys(vars).sort((a, b) => b.length - a.length);
530
542
  if (keys.length === 0) return template.replaceAll("\\n", "\n");
531
- const re = new RegExp(keys.map(escapeRegExp).join("|"), "g");
532
- return template.replace(re, (m) => vars[m] ?? m).replaceAll("\\n", "\n");
543
+ const alts = keys.flatMap((k) => [`\\{${escapeRegExp(k)}\\}`, `-${escapeRegExp(k)}`]);
544
+ const re = new RegExp(alts.join("|"), "g");
545
+ return template.replace(re, (m) => {
546
+ return vars[m.charCodeAt(0) === 123 ? m.slice(1, -1) : m.slice(1)] ?? m;
547
+ }).replaceAll("\\n", "\n");
533
548
  }
534
549
  /**
535
550
  * Format follower-change as a signed magnitude string with a 1万 (10K) cutoff,
@@ -559,27 +574,27 @@ var LiveTemplateRenderer = class {
559
574
  /** Compose the "开播" notification text for a sub. */
560
575
  renderLiveStart(params) {
561
576
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveStart", DEFAULT_LIVE_TEMPLATES.liveStart), {
562
- "-name": params.master.username,
563
- "-time": params.diffTime,
564
- "-follower": params.followerNum,
565
- "-link": params.roomLink
577
+ name: params.master.username,
578
+ time: params.diffTime,
579
+ follower: params.followerNum,
580
+ link: params.roomLink
566
581
  });
567
582
  }
568
583
  /** Compose the periodic "正在直播" notification text. */
569
584
  renderLiveOngoing(params) {
570
585
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLive", DEFAULT_LIVE_TEMPLATES.liveOngoing), {
571
- "-name": params.master.username,
572
- "-time": params.diffTime,
573
- "-watched": params.watched,
574
- "-link": params.roomLink
586
+ name: params.master.username,
587
+ time: params.diffTime,
588
+ watched: params.watched,
589
+ link: params.roomLink
575
590
  });
576
591
  }
577
592
  /** Compose the "下播" notification text. */
578
593
  renderLiveEnd(params) {
579
594
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveEnd", DEFAULT_LIVE_TEMPLATES.liveEnd), {
580
- "-name": params.master.username,
581
- "-time": params.diffTime,
582
- "-follower_change": formatFollowerChange(params.followerChange)
595
+ name: params.master.username,
596
+ time: params.diffTime,
597
+ follower_change: formatFollowerChange(params.followerChange)
583
598
  });
584
599
  }
585
600
  /**
@@ -588,24 +603,24 @@ var LiveTemplateRenderer = class {
588
603
  */
589
604
  renderGuardBuy(params) {
590
605
  return applyTemplate(params.guardBuyConfig.guardBuyMsg ?? "", {
591
- "-uname": params.uname,
592
- "-mname": params.master?.username ?? "",
593
- "-guard": params.giftName
606
+ uname: params.uname,
607
+ mname: params.master?.username ?? "",
608
+ guard: params.giftName
594
609
  });
595
610
  }
596
611
  /** Compose the "特别关注弹幕" notification text. */
597
612
  renderSpecialDanmaku(params) {
598
613
  return applyTemplate(params.template, {
599
- "-mastername": params.master?.username ?? "",
600
- "-uname": params.uname,
601
- "-msg": params.content
614
+ mastername: params.master?.username ?? "",
615
+ uname: params.uname,
616
+ msg: params.content
602
617
  });
603
618
  }
604
619
  /** Compose the "特别关注进入直播间" notification text. */
605
620
  renderSpecialUserEnter(params) {
606
621
  return applyTemplate(params.template, {
607
- "-mastername": params.master?.username ?? "",
608
- "-uname": params.uname
622
+ mastername: params.master?.username ?? "",
623
+ uname: params.uname
609
624
  });
610
625
  }
611
626
  /**
@@ -618,19 +633,19 @@ var LiveTemplateRenderer = class {
618
633
  const top = params.topSenders;
619
634
  const at = (i) => top[i] ?? ["", 0];
620
635
  return applyTemplate(params.template, {
621
- "-dmc": `${params.senderCount}`,
622
- "-mdn": params.master?.medalName ?? "",
623
- "-dca": `${params.danmakuCount}`,
624
- "-un1": at(0)[0],
625
- "-dc1": `${at(0)[1]}`,
626
- "-un2": at(1)[0],
627
- "-dc2": `${at(1)[1]}`,
628
- "-un3": at(2)[0],
629
- "-dc3": `${at(2)[1]}`,
630
- "-un4": at(3)[0],
631
- "-dc4": `${at(3)[1]}`,
632
- "-un5": at(4)[0],
633
- "-dc5": `${at(4)[1]}`
636
+ dmc: `${params.senderCount}`,
637
+ mdn: params.master?.medalName ?? "",
638
+ dca: `${params.danmakuCount}`,
639
+ un1: at(0)[0],
640
+ dc1: `${at(0)[1]}`,
641
+ un2: at(1)[0],
642
+ dc2: `${at(1)[1]}`,
643
+ un3: at(2)[0],
644
+ dc3: `${at(2)[1]}`,
645
+ un4: at(3)[0],
646
+ dc4: `${at(3)[1]}`,
647
+ un5: at(4)[0],
648
+ dc5: `${at(4)[1]}`
634
649
  });
635
650
  }
636
651
  };
@@ -707,14 +722,17 @@ var RoomSessionBase = class {
707
722
  async bootstrap() {
708
723
  if (!await this.ctx.startLiveRoomListener(this.sub.roomId, this.buildHandler())) {
709
724
  await this.ctx.push.sendPrivateMsg(`直播间 [${this.sub.roomId}] 弹幕连接建立失败,已停止该房间监测`);
725
+ this.onMonitoringStopped();
710
726
  this.ctx.closeListener(this.sub.roomId);
711
727
  return;
712
728
  }
713
729
  if (!await this.useLiveRoomInfo(4) || !await this.useMasterInfo(4) || !this.liveRoomInfo || !this.masterInfo) {
714
730
  await this.ctx.push.sendPrivateMsg("获取直播间信息失败,启动直播间弹幕检测失败");
731
+ this.onMonitoringStopped();
715
732
  this.ctx.closeListener(this.sub.roomId);
716
733
  return;
717
734
  }
735
+ this.onListenerStarted();
718
736
  this.ctx.logger.debug(`[stat] 当前粉丝数:${this.masterInfo.liveOpenFollowerNum}`);
719
737
  if (this.liveRoomInfo.live_status === 1) {
720
738
  this.liveTime = this.liveRoomInfo.live_time;
@@ -743,6 +761,10 @@ var RoomSessionBase = class {
743
761
  this.armPeriodicTimer();
744
762
  }
745
763
  }
764
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
765
+ onListenerStarted() {}
766
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
767
+ onMonitoringStopped() {}
746
768
  async useLiveRoomInfo(liveType) {
747
769
  const data = await this.ctx.getLiveRoomInfo(this.sub.roomId);
748
770
  if (!data?.uid) return false;
@@ -792,6 +814,7 @@ var RoomSessionBase = class {
792
814
  /** Periodic "正在直播" tick (callback for `setInterval`). */
793
815
  async tickPushAtTime() {
794
816
  if (!await this.useLiveRoomInfo(2) || !this.liveRoomInfo) {
817
+ this.onMonitoringStopped();
795
818
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播卡片失败", this.sub.roomId);
796
819
  return;
797
820
  }
@@ -842,6 +865,7 @@ var RoomSessionBase = class {
842
865
  this.setLiveStatus(false);
843
866
  this.ctx.danmakuCollector.clear(this.sub.roomId);
844
867
  if (this.ctx.isDisposed()) return;
868
+ this.onMonitoringStopped();
845
869
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播下播卡片失败", this.sub.roomId);
846
870
  return;
847
871
  }
@@ -926,6 +950,8 @@ const RECONNECT_BACKOFF_MS = [
926
950
  8e3,
927
951
  16e3
928
952
  ];
953
+ /** B 站 live WS 静默自愈:每分钟检查一次,3 分钟无 heartbeat/消息即主动重连。 */
954
+ const LIVE_WS_WATCHDOG_INTERVAL_MS = 6e4;
929
955
  var RoomSession = class extends RoomSessionBase {
930
956
  lastViewersEmitMs = 0;
931
957
  /**
@@ -944,10 +970,15 @@ var RoomSession = class extends RoomSessionBase {
944
970
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
945
971
  reconnectTimer;
946
972
  reconnectWake;
947
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
973
+ lastLiveWsActivityAt = 0;
974
+ lastLiveWsActivityReason = "connected";
975
+ watchdogTimer;
976
+ watchdogReconnectCount = 0;
977
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
948
978
  cancel() {
949
979
  this.cancelled = true;
950
980
  this.reconnecting = false;
981
+ this.stopLiveWsWatchdog();
951
982
  this.clearReconnectSleep();
952
983
  }
953
984
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
@@ -957,12 +988,62 @@ var RoomSession = class extends RoomSessionBase {
957
988
  this.reconnectWake?.();
958
989
  this.reconnectWake = void 0;
959
990
  }
991
+ onListenerStarted() {
992
+ this.markLiveWsActivity("connected");
993
+ this.startLiveWsWatchdog();
994
+ }
995
+ onMonitoringStopped() {
996
+ this.cancel();
997
+ }
998
+ getWsHealthSnapshot() {
999
+ return {
1000
+ lastActivityAt: this.lastLiveWsActivityAt,
1001
+ lastActivityReason: this.lastLiveWsActivityReason,
1002
+ watchdogReconnectCount: this.watchdogReconnectCount
1003
+ };
1004
+ }
1005
+ markLiveWsActivity(reason) {
1006
+ this.lastLiveWsActivityAt = Date.now();
1007
+ this.lastLiveWsActivityReason = reason;
1008
+ }
1009
+ startLiveWsWatchdog() {
1010
+ if (this.watchdogTimer || this.cancelled || this.ctx.isDisposed()) return;
1011
+ this.watchdogTimer = this.ctx.serviceCtx.setInterval(() => this.checkLiveWsWatchdog(), LIVE_WS_WATCHDOG_INTERVAL_MS);
1012
+ }
1013
+ stopLiveWsWatchdog() {
1014
+ this.watchdogTimer?.dispose();
1015
+ this.watchdogTimer = void 0;
1016
+ }
1017
+ checkLiveWsWatchdog() {
1018
+ if (this.cancelled || this.ctx.isDisposed() || this.reconnecting) return;
1019
+ if (this.lastLiveWsActivityAt <= 0) return;
1020
+ const staleMs = Date.now() - this.lastLiveWsActivityAt;
1021
+ if (staleMs < 18e4) return;
1022
+ this.watchdogReconnectCount++;
1023
+ this.reconnect("watchdog", `${Math.floor(staleMs / 1e3)}s 无 heartbeat/消息(last=${this.lastLiveWsActivityReason},watchdog=${this.watchdogReconnectCount})`);
1024
+ }
960
1025
  buildHandler() {
961
1026
  const base = {
1027
+ onOpen: () => this.markLiveWsActivity("open"),
1028
+ onStartListen: () => this.markLiveWsActivity("start-listen"),
1029
+ onClose: () => {
1030
+ if (this.cancelled || this.ctx.isDisposed()) return;
1031
+ if (this.ctx.consumeIntentionalClose(this.sub.roomId)) return;
1032
+ this.markLiveWsActivity("close");
1033
+ this.reconnect("close");
1034
+ },
962
1035
  onError: () => this.onError(),
963
- onIncomeDanmu: ({ body }) => this.onIncomeDanmu(body),
964
- onIncomeSuperChat: ({ body }) => this.onIncomeSuperChat(body),
1036
+ onAttentionChange: () => this.markLiveWsActivity("heartbeat"),
1037
+ onIncomeDanmu: ({ body }) => {
1038
+ this.markLiveWsActivity("danmu");
1039
+ this.onIncomeDanmu(body);
1040
+ },
1041
+ onIncomeSuperChat: ({ body }) => {
1042
+ this.markLiveWsActivity("superchat");
1043
+ return this.onIncomeSuperChat(body);
1044
+ },
965
1045
  onWatchedChange: ({ body }) => {
1046
+ this.markLiveWsActivity("watched");
966
1047
  this.liveData.watchedNum = body.text_small;
967
1048
  const now = Date.now();
968
1049
  if (now - this.lastViewersEmitMs >= VIEWERS_EMIT_THROTTLE_MS) {
@@ -971,42 +1052,61 @@ var RoomSession = class extends RoomSessionBase {
971
1052
  }
972
1053
  },
973
1054
  onLikedChange: ({ body }) => {
1055
+ this.markLiveWsActivity("liked");
974
1056
  this.liveData.likedNum = body.count;
975
1057
  },
976
- onGuardBuy: ({ body }) => this.onGuardBuy(body),
977
- onLiveStart: () => this.onLiveStart(),
978
- onLiveEnd: () => this.onLiveEnd()
1058
+ onGuardBuy: ({ body }) => {
1059
+ this.markLiveWsActivity("guard");
1060
+ return this.onGuardBuy(body);
1061
+ },
1062
+ onLiveStart: () => {
1063
+ this.markLiveWsActivity("live-start");
1064
+ return this.onLiveStart();
1065
+ },
1066
+ onLiveEnd: () => {
1067
+ this.markLiveWsActivity("live-end");
1068
+ return this.onLiveEnd();
1069
+ }
979
1070
  };
980
1071
  if (!this.sub.customSpecialUsersEnterTheRoom.enable) return base;
981
1072
  return {
982
1073
  ...base,
983
- raw: { INTERACT_WORD_V2: (msg) => this.onInteractWordV2(msg) }
1074
+ raw: { INTERACT_WORD_V2: (msg) => {
1075
+ this.markLiveWsActivity("interact");
1076
+ return this.onInteractWordV2(msg);
1077
+ } }
984
1078
  };
985
1079
  }
986
- async onError() {
1080
+ onError() {
1081
+ return this.reconnect("error");
1082
+ }
1083
+ async reconnect(reason, detail) {
987
1084
  if (this.cancelled || this.ctx.isDisposed()) return;
988
1085
  if (this.reconnecting) return;
989
1086
  this.reconnecting = true;
990
1087
  try {
991
- await this.reconnectLoop();
1088
+ await this.reconnectLoop(reason, detail);
992
1089
  } finally {
993
1090
  this.reconnecting = false;
994
1091
  }
995
1092
  }
996
1093
  /**
997
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
1094
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
998
1095
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
999
1096
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
1000
1097
  */
1001
- async reconnectLoop() {
1098
+ async reconnectLoop(reason, detail) {
1002
1099
  while (this.reconnectAttempts < RECONNECT_BACKOFF_MS.length) {
1003
1100
  if (this.cancelled || this.ctx.isDisposed()) return;
1004
- this.setLiveStatus(false);
1005
- this.cancelPeriodicTimer();
1101
+ if (reason === "error") {
1102
+ this.setLiveStatus(false);
1103
+ this.cancelPeriodicTimer();
1104
+ }
1006
1105
  this.ctx.closeListener(this.sub.roomId);
1007
1106
  const delay = RECONNECT_BACKOFF_MS[this.reconnectAttempts];
1008
1107
  this.reconnectAttempts++;
1009
- this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 连接错误,${delay / 1e3}s 后重连( ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
1108
+ const reasonText = this.describeReconnectReason(reason, detail);
1109
+ this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] ${reasonText},${delay / 1e3}s 后重连(第 ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
1010
1110
  await this.sleepReconnect(delay);
1011
1111
  if (this.cancelled || this.ctx.isDisposed()) return;
1012
1112
  let ok = false;
@@ -1020,6 +1120,7 @@ var RoomSession = class extends RoomSessionBase {
1020
1120
  return;
1021
1121
  }
1022
1122
  if (ok) {
1123
+ this.onListenerStarted();
1023
1124
  this.ctx.logger.info(`[conn] 直播间 [${this.sub.roomId}] 重连成功`);
1024
1125
  this.reconnectAttempts = 0;
1025
1126
  return;
@@ -1027,9 +1128,15 @@ var RoomSession = class extends RoomSessionBase {
1027
1128
  this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 重连未成功,继续退避`);
1028
1129
  }
1029
1130
  this.reconnectAttempts = 0;
1030
- const msg = `直播间 [${this.sub.roomId}] 连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1131
+ const msg = `直播间 [${this.sub.roomId}] ${this.describeReconnectReason(reason, detail)}后连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1031
1132
  this.ctx.logger.error(`[conn] ${msg}`);
1032
1133
  this.ctx.emitEngineError(msg);
1134
+ this.cancel();
1135
+ }
1136
+ describeReconnectReason(reason, detail) {
1137
+ if (reason === "error") return "连接错误";
1138
+ if (reason === "close") return "连接关闭";
1139
+ return detail ? `连接静默(${detail})` : "连接静默";
1033
1140
  }
1034
1141
  /**
1035
1142
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
@@ -1152,6 +1259,7 @@ var RoomSession = class extends RoomSessionBase {
1152
1259
  if (!await this.useLiveRoomInfo(1) || !await this.useMasterInfo(1) || !this.liveRoomInfo || !this.masterInfo) {
1153
1260
  this.setLiveStatus(false);
1154
1261
  if (this.ctx.isDisposed()) return;
1262
+ this.onMonitoringStopped();
1155
1263
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播开播卡片失败", this.sub.roomId);
1156
1264
  return;
1157
1265
  }
@@ -1693,9 +1801,7 @@ var LiveEngine = class {
1693
1801
  /** Tear down all listeners + per-room state, leaving the engine instance reusable. */
1694
1802
  teardown() {
1695
1803
  this.logger.info("[live] 关闭所有直播间监听");
1696
- this.listener.clearPushTimers();
1697
- this.listener.clearListeners();
1698
- this.danmakuCollector.clearAll();
1804
+ this.listener.disposeAll();
1699
1805
  }
1700
1806
  /** Full rebootstrap. Used after auth-restored. */
1701
1807
  rebuildFromSubs(subs) {
package/lib/index.d.cts CHANGED
@@ -274,16 +274,16 @@ type LivePushTimerManager = Map<string, () => void>;
274
274
  * - `customSpecialDanmakuUsers.msgTemplate`
275
275
  * - `customSpecialUsersEnterTheRoom.msgTemplate`
276
276
  *
277
- * The variable syntax follows the existing `-name` / `-time` / `-watched` style
278
- * (NOT the `{key}` syntax used by `@bilibili-notify/internal`'s `interpolate`),
279
- * because that's what users have in their existing Koishi configs and we keep
280
- * 1:1 backward compatibility.
277
+ * 占位符统一 `{name}` / `{time}` / `{watched}` 语法(与 `@bilibili-notify/internal`
278
+ * `interpolate` 同源)。`applyTemplate` 同时接受 koishi 旧存档里的 legacy
279
+ * `-name` / `-time` 写法 —— 老用户已保存的 `-key` 模板继续生效,新默认与文档
280
+ * 一律走 `{key}`,二者不冲突(单遍正则,longest-first)。
281
281
  */
282
282
  /** Defaults applied when neither sub-level nor global config provides a template. */
283
283
  declare const DEFAULT_LIVE_TEMPLATES: {
284
- readonly liveStart: "-name 开播啦,当前粉丝数:-follower\n-link";
285
- readonly liveOngoing: "-name 正在直播,已播 -time,累计观看:-watched\n-link";
286
- readonly liveEnd: "-name 下播啦,本次直播了 -time,粉丝变化 -follower_change";
284
+ readonly liveStart: "{name} 开播啦,当前粉丝数:{follower}\n{link}";
285
+ readonly liveOngoing: "{name} 正在直播,已播 {time},累计观看:{watched}\n{link}";
286
+ readonly liveEnd: "{name} 下播啦,本次直播了 {time},粉丝变化 {follower_change}";
287
287
  readonly liveSummaryFallback: "弹幕总结";
288
288
  };
289
289
  /**
@@ -572,6 +572,8 @@ declare class RoomContextBase {
572
572
  readonly listenerRecord: Record<string, MessageListener>;
573
573
  readonly livePushTimerManager: Map<string, () => void>;
574
574
  private disposed;
575
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
576
+ private readonly intentionalCloseRooms;
575
577
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
576
578
  protected interactWord?: protobuf.Type;
577
579
  /**
@@ -590,6 +592,7 @@ declare class RoomContextBase {
590
592
  * 同型 no-op 安全调用方。room-session 已做 per-UID 节流,这里只是分发。
591
593
  */
592
594
  emitViewers(uid: string, viewers: string): void;
595
+ consumeIntentionalClose(roomId: string): boolean;
593
596
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
594
597
  get imageRenderer(): ImageRenderer | null;
595
598
  updateConfig(config: ListenerManagerConfig): void;
@@ -737,6 +740,10 @@ declare abstract class RoomSessionBase {
737
740
  bootstrap(): Promise<void>;
738
741
  /** Build the platform-specific {@link MsgHandler}; provided by the subclass. */
739
742
  protected abstract buildHandler(): MsgHandler;
743
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
744
+ protected onListenerStarted(): void;
745
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
746
+ protected onMonitoringStopped(): void;
740
747
  protected useLiveRoomInfo(liveType: LiveType): Promise<boolean>;
741
748
  protected useMasterInfo(liveType: LiveType): Promise<boolean>;
742
749
  /**
@@ -766,6 +773,7 @@ declare abstract class RoomSessionBase {
766
773
  }
767
774
  //#endregion
768
775
  //#region src/room-session.d.ts
776
+ type LiveWsActivityReason = "connected" | "open" | "start-listen" | "heartbeat" | "danmu" | "superchat" | "watched" | "liked" | "guard" | "live-start" | "live-end" | "interact" | "close";
769
777
  declare class RoomSession extends RoomSessionBase {
770
778
  private lastViewersEmitMs;
771
779
  /**
@@ -784,18 +792,35 @@ declare class RoomSession extends RoomSessionBase {
784
792
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
785
793
  private reconnectTimer?;
786
794
  private reconnectWake?;
787
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
795
+ private lastLiveWsActivityAt;
796
+ private lastLiveWsActivityReason;
797
+ private watchdogTimer?;
798
+ private watchdogReconnectCount;
799
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
788
800
  cancel(): void;
789
801
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
790
802
  private clearReconnectSleep;
803
+ protected onListenerStarted(): void;
804
+ protected onMonitoringStopped(): void;
805
+ getWsHealthSnapshot(): {
806
+ lastActivityAt: number;
807
+ lastActivityReason: LiveWsActivityReason;
808
+ watchdogReconnectCount: number;
809
+ };
810
+ private markLiveWsActivity;
811
+ private startLiveWsWatchdog;
812
+ private stopLiveWsWatchdog;
813
+ private checkLiveWsWatchdog;
791
814
  protected buildHandler(): MsgHandler;
792
815
  private onError;
816
+ private reconnect;
793
817
  /**
794
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
818
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
795
819
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
796
820
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
797
821
  */
798
822
  private reconnectLoop;
823
+ private describeReconnectReason;
799
824
  /**
800
825
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
801
826
  * resolve,让 reconnectLoop 醒来重校 cancelled/disposed 后退出 —— 不再留
package/lib/index.d.mts CHANGED
@@ -274,16 +274,16 @@ type LivePushTimerManager = Map<string, () => void>;
274
274
  * - `customSpecialDanmakuUsers.msgTemplate`
275
275
  * - `customSpecialUsersEnterTheRoom.msgTemplate`
276
276
  *
277
- * The variable syntax follows the existing `-name` / `-time` / `-watched` style
278
- * (NOT the `{key}` syntax used by `@bilibili-notify/internal`'s `interpolate`),
279
- * because that's what users have in their existing Koishi configs and we keep
280
- * 1:1 backward compatibility.
277
+ * 占位符统一 `{name}` / `{time}` / `{watched}` 语法(与 `@bilibili-notify/internal`
278
+ * `interpolate` 同源)。`applyTemplate` 同时接受 koishi 旧存档里的 legacy
279
+ * `-name` / `-time` 写法 —— 老用户已保存的 `-key` 模板继续生效,新默认与文档
280
+ * 一律走 `{key}`,二者不冲突(单遍正则,longest-first)。
281
281
  */
282
282
  /** Defaults applied when neither sub-level nor global config provides a template. */
283
283
  declare const DEFAULT_LIVE_TEMPLATES: {
284
- readonly liveStart: "-name 开播啦,当前粉丝数:-follower\n-link";
285
- readonly liveOngoing: "-name 正在直播,已播 -time,累计观看:-watched\n-link";
286
- readonly liveEnd: "-name 下播啦,本次直播了 -time,粉丝变化 -follower_change";
284
+ readonly liveStart: "{name} 开播啦,当前粉丝数:{follower}\n{link}";
285
+ readonly liveOngoing: "{name} 正在直播,已播 {time},累计观看:{watched}\n{link}";
286
+ readonly liveEnd: "{name} 下播啦,本次直播了 {time},粉丝变化 {follower_change}";
287
287
  readonly liveSummaryFallback: "弹幕总结";
288
288
  };
289
289
  /**
@@ -572,6 +572,8 @@ declare class RoomContextBase {
572
572
  readonly listenerRecord: Record<string, MessageListener>;
573
573
  readonly livePushTimerManager: Map<string, () => void>;
574
574
  private disposed;
575
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
576
+ private readonly intentionalCloseRooms;
575
577
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
576
578
  protected interactWord?: protobuf.Type;
577
579
  /**
@@ -590,6 +592,7 @@ declare class RoomContextBase {
590
592
  * 同型 no-op 安全调用方。room-session 已做 per-UID 节流,这里只是分发。
591
593
  */
592
594
  emitViewers(uid: string, viewers: string): void;
595
+ consumeIntentionalClose(roomId: string): boolean;
593
596
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
594
597
  get imageRenderer(): ImageRenderer | null;
595
598
  updateConfig(config: ListenerManagerConfig): void;
@@ -737,6 +740,10 @@ declare abstract class RoomSessionBase {
737
740
  bootstrap(): Promise<void>;
738
741
  /** Build the platform-specific {@link MsgHandler}; provided by the subclass. */
739
742
  protected abstract buildHandler(): MsgHandler;
743
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
744
+ protected onListenerStarted(): void;
745
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
746
+ protected onMonitoringStopped(): void;
740
747
  protected useLiveRoomInfo(liveType: LiveType): Promise<boolean>;
741
748
  protected useMasterInfo(liveType: LiveType): Promise<boolean>;
742
749
  /**
@@ -766,6 +773,7 @@ declare abstract class RoomSessionBase {
766
773
  }
767
774
  //#endregion
768
775
  //#region src/room-session.d.ts
776
+ type LiveWsActivityReason = "connected" | "open" | "start-listen" | "heartbeat" | "danmu" | "superchat" | "watched" | "liked" | "guard" | "live-start" | "live-end" | "interact" | "close";
769
777
  declare class RoomSession extends RoomSessionBase {
770
778
  private lastViewersEmitMs;
771
779
  /**
@@ -784,18 +792,35 @@ declare class RoomSession extends RoomSessionBase {
784
792
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
785
793
  private reconnectTimer?;
786
794
  private reconnectWake?;
787
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
795
+ private lastLiveWsActivityAt;
796
+ private lastLiveWsActivityReason;
797
+ private watchdogTimer?;
798
+ private watchdogReconnectCount;
799
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
788
800
  cancel(): void;
789
801
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
790
802
  private clearReconnectSleep;
803
+ protected onListenerStarted(): void;
804
+ protected onMonitoringStopped(): void;
805
+ getWsHealthSnapshot(): {
806
+ lastActivityAt: number;
807
+ lastActivityReason: LiveWsActivityReason;
808
+ watchdogReconnectCount: number;
809
+ };
810
+ private markLiveWsActivity;
811
+ private startLiveWsWatchdog;
812
+ private stopLiveWsWatchdog;
813
+ private checkLiveWsWatchdog;
791
814
  protected buildHandler(): MsgHandler;
792
815
  private onError;
816
+ private reconnect;
793
817
  /**
794
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
818
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
795
819
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
796
820
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
797
821
  */
798
822
  private reconnectLoop;
823
+ private describeReconnectReason;
799
824
  /**
800
825
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
801
826
  * resolve,让 reconnectLoop 醒来重校 cancelled/disposed 后退出 —— 不再留
package/lib/index.mjs CHANGED
@@ -161,6 +161,8 @@ var RoomContextBase = class {
161
161
  listenerRecord = {};
162
162
  livePushTimerManager = /* @__PURE__ */ new Map();
163
163
  disposed = false;
164
+ /** stopMonitoring 主动关闭 listener 时置位;RoomSession.onClose 消费后不做自愈重连。 */
165
+ intentionalCloseRooms = /* @__PURE__ */ new Set();
164
166
  /** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
165
167
  interactWord;
166
168
  /**
@@ -198,6 +200,11 @@ var RoomContextBase = class {
198
200
  emitViewers(uid, viewers) {
199
201
  this._emitViewers?.(uid, viewers);
200
202
  }
203
+ consumeIntentionalClose(roomId) {
204
+ const hit = this.intentionalCloseRooms.has(roomId);
205
+ this.intentionalCloseRooms.delete(roomId);
206
+ return hit;
207
+ }
201
208
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
202
209
  get imageRenderer() {
203
210
  return this.config.imageEnabled === false ? null : this._getImageRenderer();
@@ -229,10 +236,12 @@ var RoomContextBase = class {
229
236
  closeListener(roomId) {
230
237
  const listener = this.listenerRecord[roomId];
231
238
  if (!listener) {
239
+ this.intentionalCloseRooms.delete(roomId);
232
240
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接不存在,跳过关闭`);
233
241
  return;
234
242
  }
235
243
  if (listener.closed) {
244
+ this.intentionalCloseRooms.delete(roomId);
236
245
  this.logger.debug(`[conn] 直播间 [${roomId}] 连接已被远端断开`);
237
246
  delete this.listenerRecord[roomId];
238
247
  return;
@@ -256,6 +265,7 @@ var RoomContextBase = class {
256
265
  stopMonitoring(reason, roomId) {
257
266
  if (roomId) {
258
267
  this.logger.error(`[conn] [${roomId}] ${reason},已停止该房间的监测`);
268
+ this.intentionalCloseRooms.add(roomId);
259
269
  this.closeListener(roomId);
260
270
  const timer = this.livePushTimerManager.get(roomId);
261
271
  if (timer) {
@@ -313,6 +323,7 @@ var RoomContext = class extends RoomContextBase {
313
323
  this.logger.warn(`[conn] 直播间 [${roomId}] 连接已存在,跳过创建`);
314
324
  return true;
315
325
  }
326
+ this.consumeIntentionalClose(roomId);
316
327
  const cookiesStr = this.api.getCookiesHeader();
317
328
  let mySelfInfo;
318
329
  try {
@@ -475,37 +486,41 @@ var RoomContext = class extends RoomContextBase {
475
486
  * - `customSpecialDanmakuUsers.msgTemplate`
476
487
  * - `customSpecialUsersEnterTheRoom.msgTemplate`
477
488
  *
478
- * The variable syntax follows the existing `-name` / `-time` / `-watched` style
479
- * (NOT the `{key}` syntax used by `@bilibili-notify/internal`'s `interpolate`),
480
- * because that's what users have in their existing Koishi configs and we keep
481
- * 1:1 backward compatibility.
489
+ * 占位符统一 `{name}` / `{time}` / `{watched}` 语法(与 `@bilibili-notify/internal`
490
+ * `interpolate` 同源)。`applyTemplate` 同时接受 koishi 旧存档里的 legacy
491
+ * `-name` / `-time` 写法 —— 老用户已保存的 `-key` 模板继续生效,新默认与文档
492
+ * 一律走 `{key}`,二者不冲突(单遍正则,longest-first)。
482
493
  */
483
494
  /** Defaults applied when neither sub-level nor global config provides a template. */
484
495
  const DEFAULT_LIVE_TEMPLATES = {
485
- liveStart: "-name 开播啦,当前粉丝数:-follower\n-link",
486
- liveOngoing: "-name 正在直播,已播 -time,累计观看:-watched\n-link",
487
- liveEnd: "-name 下播啦,本次直播了 -time,粉丝变化 -follower_change",
496
+ liveStart: "{name} 开播啦,当前粉丝数:{follower}\n{link}",
497
+ liveOngoing: "{name} 正在直播,已播 {time},累计观看:{watched}\n{link}",
498
+ liveEnd: "{name} 下播啦,本次直播了 {time},粉丝变化 {follower_change}",
488
499
  liveSummaryFallback: "弹幕总结"
489
500
  };
490
501
  function escapeRegExp(s) {
491
502
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
492
503
  }
493
504
  /**
494
- * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。
505
+ * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。`vars` 以**裸键**(`name`/
506
+ * `follower_change`)给出,每个键同时匹配 `{name}`(主)与 legacy `-name`(兼容)。
495
507
  *
496
508
  * P2:此前 `for…replaceAll` 顺序替换有两个缺陷 ——
497
- * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `-link`/`-time` 时
509
+ * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `{link}`/`-link` 时
498
510
  * 会被后续轮次再次替换;
499
- * 2. **前缀吞噬**:`-follower` 先于 `-follower_change` 替换,把后者的
511
+ * 2. **前缀吞噬**:legacy `-follower` 先于 `-follower_change` 替换,把后者的
500
512
  * `-follower` 段吃掉只剩 `_change`。
501
- * 改为基于原始模板的**单遍正则**:token 按长度降序进 alternation(最长优先
502
- * 匹配),每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
513
+ * 改为基于原始模板的**单遍正则**:键按长度降序进 alternation(最长优先匹配),
514
+ * 每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
503
515
  */
504
516
  function applyTemplate(template, vars) {
505
517
  const keys = Object.keys(vars).sort((a, b) => b.length - a.length);
506
518
  if (keys.length === 0) return template.replaceAll("\\n", "\n");
507
- const re = new RegExp(keys.map(escapeRegExp).join("|"), "g");
508
- return template.replace(re, (m) => vars[m] ?? m).replaceAll("\\n", "\n");
519
+ const alts = keys.flatMap((k) => [`\\{${escapeRegExp(k)}\\}`, `-${escapeRegExp(k)}`]);
520
+ const re = new RegExp(alts.join("|"), "g");
521
+ return template.replace(re, (m) => {
522
+ return vars[m.charCodeAt(0) === 123 ? m.slice(1, -1) : m.slice(1)] ?? m;
523
+ }).replaceAll("\\n", "\n");
509
524
  }
510
525
  /**
511
526
  * Format follower-change as a signed magnitude string with a 1万 (10K) cutoff,
@@ -535,27 +550,27 @@ var LiveTemplateRenderer = class {
535
550
  /** Compose the "开播" notification text for a sub. */
536
551
  renderLiveStart(params) {
537
552
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveStart", DEFAULT_LIVE_TEMPLATES.liveStart), {
538
- "-name": params.master.username,
539
- "-time": params.diffTime,
540
- "-follower": params.followerNum,
541
- "-link": params.roomLink
553
+ name: params.master.username,
554
+ time: params.diffTime,
555
+ follower: params.followerNum,
556
+ link: params.roomLink
542
557
  });
543
558
  }
544
559
  /** Compose the periodic "正在直播" notification text. */
545
560
  renderLiveOngoing(params) {
546
561
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLive", DEFAULT_LIVE_TEMPLATES.liveOngoing), {
547
- "-name": params.master.username,
548
- "-time": params.diffTime,
549
- "-watched": params.watched,
550
- "-link": params.roomLink
562
+ name: params.master.username,
563
+ time: params.diffTime,
564
+ watched: params.watched,
565
+ link: params.roomLink
551
566
  });
552
567
  }
553
568
  /** Compose the "下播" notification text. */
554
569
  renderLiveEnd(params) {
555
570
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveEnd", DEFAULT_LIVE_TEMPLATES.liveEnd), {
556
- "-name": params.master.username,
557
- "-time": params.diffTime,
558
- "-follower_change": formatFollowerChange(params.followerChange)
571
+ name: params.master.username,
572
+ time: params.diffTime,
573
+ follower_change: formatFollowerChange(params.followerChange)
559
574
  });
560
575
  }
561
576
  /**
@@ -564,24 +579,24 @@ var LiveTemplateRenderer = class {
564
579
  */
565
580
  renderGuardBuy(params) {
566
581
  return applyTemplate(params.guardBuyConfig.guardBuyMsg ?? "", {
567
- "-uname": params.uname,
568
- "-mname": params.master?.username ?? "",
569
- "-guard": params.giftName
582
+ uname: params.uname,
583
+ mname: params.master?.username ?? "",
584
+ guard: params.giftName
570
585
  });
571
586
  }
572
587
  /** Compose the "特别关注弹幕" notification text. */
573
588
  renderSpecialDanmaku(params) {
574
589
  return applyTemplate(params.template, {
575
- "-mastername": params.master?.username ?? "",
576
- "-uname": params.uname,
577
- "-msg": params.content
590
+ mastername: params.master?.username ?? "",
591
+ uname: params.uname,
592
+ msg: params.content
578
593
  });
579
594
  }
580
595
  /** Compose the "特别关注进入直播间" notification text. */
581
596
  renderSpecialUserEnter(params) {
582
597
  return applyTemplate(params.template, {
583
- "-mastername": params.master?.username ?? "",
584
- "-uname": params.uname
598
+ mastername: params.master?.username ?? "",
599
+ uname: params.uname
585
600
  });
586
601
  }
587
602
  /**
@@ -594,19 +609,19 @@ var LiveTemplateRenderer = class {
594
609
  const top = params.topSenders;
595
610
  const at = (i) => top[i] ?? ["", 0];
596
611
  return applyTemplate(params.template, {
597
- "-dmc": `${params.senderCount}`,
598
- "-mdn": params.master?.medalName ?? "",
599
- "-dca": `${params.danmakuCount}`,
600
- "-un1": at(0)[0],
601
- "-dc1": `${at(0)[1]}`,
602
- "-un2": at(1)[0],
603
- "-dc2": `${at(1)[1]}`,
604
- "-un3": at(2)[0],
605
- "-dc3": `${at(2)[1]}`,
606
- "-un4": at(3)[0],
607
- "-dc4": `${at(3)[1]}`,
608
- "-un5": at(4)[0],
609
- "-dc5": `${at(4)[1]}`
612
+ dmc: `${params.senderCount}`,
613
+ mdn: params.master?.medalName ?? "",
614
+ dca: `${params.danmakuCount}`,
615
+ un1: at(0)[0],
616
+ dc1: `${at(0)[1]}`,
617
+ un2: at(1)[0],
618
+ dc2: `${at(1)[1]}`,
619
+ un3: at(2)[0],
620
+ dc3: `${at(2)[1]}`,
621
+ un4: at(3)[0],
622
+ dc4: `${at(3)[1]}`,
623
+ un5: at(4)[0],
624
+ dc5: `${at(4)[1]}`
610
625
  });
611
626
  }
612
627
  };
@@ -683,14 +698,17 @@ var RoomSessionBase = class {
683
698
  async bootstrap() {
684
699
  if (!await this.ctx.startLiveRoomListener(this.sub.roomId, this.buildHandler())) {
685
700
  await this.ctx.push.sendPrivateMsg(`直播间 [${this.sub.roomId}] 弹幕连接建立失败,已停止该房间监测`);
701
+ this.onMonitoringStopped();
686
702
  this.ctx.closeListener(this.sub.roomId);
687
703
  return;
688
704
  }
689
705
  if (!await this.useLiveRoomInfo(4) || !await this.useMasterInfo(4) || !this.liveRoomInfo || !this.masterInfo) {
690
706
  await this.ctx.push.sendPrivateMsg("获取直播间信息失败,启动直播间弹幕检测失败");
707
+ this.onMonitoringStopped();
691
708
  this.ctx.closeListener(this.sub.roomId);
692
709
  return;
693
710
  }
711
+ this.onListenerStarted();
694
712
  this.ctx.logger.debug(`[stat] 当前粉丝数:${this.masterInfo.liveOpenFollowerNum}`);
695
713
  if (this.liveRoomInfo.live_status === 1) {
696
714
  this.liveTime = this.liveRoomInfo.live_time;
@@ -719,6 +737,10 @@ var RoomSessionBase = class {
719
737
  this.armPeriodicTimer();
720
738
  }
721
739
  }
740
+ /** Hook for subclass-owned connection-health bookkeeping after listener bootstrap succeeds. */
741
+ onListenerStarted() {}
742
+ /** Hook for subclass-owned cleanup before this session intentionally stops monitoring. */
743
+ onMonitoringStopped() {}
722
744
  async useLiveRoomInfo(liveType) {
723
745
  const data = await this.ctx.getLiveRoomInfo(this.sub.roomId);
724
746
  if (!data?.uid) return false;
@@ -768,6 +790,7 @@ var RoomSessionBase = class {
768
790
  /** Periodic "正在直播" tick (callback for `setInterval`). */
769
791
  async tickPushAtTime() {
770
792
  if (!await this.useLiveRoomInfo(2) || !this.liveRoomInfo) {
793
+ this.onMonitoringStopped();
771
794
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播卡片失败", this.sub.roomId);
772
795
  return;
773
796
  }
@@ -818,6 +841,7 @@ var RoomSessionBase = class {
818
841
  this.setLiveStatus(false);
819
842
  this.ctx.danmakuCollector.clear(this.sub.roomId);
820
843
  if (this.ctx.isDisposed()) return;
844
+ this.onMonitoringStopped();
821
845
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播下播卡片失败", this.sub.roomId);
822
846
  return;
823
847
  }
@@ -902,6 +926,8 @@ const RECONNECT_BACKOFF_MS = [
902
926
  8e3,
903
927
  16e3
904
928
  ];
929
+ /** B 站 live WS 静默自愈:每分钟检查一次,3 分钟无 heartbeat/消息即主动重连。 */
930
+ const LIVE_WS_WATCHDOG_INTERVAL_MS = 6e4;
905
931
  var RoomSession = class extends RoomSessionBase {
906
932
  lastViewersEmitMs = 0;
907
933
  /**
@@ -920,10 +946,15 @@ var RoomSession = class extends RoomSessionBase {
920
946
  /** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
921
947
  reconnectTimer;
922
948
  reconnectWake;
923
- /** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
949
+ lastLiveWsActivityAt = 0;
950
+ lastLiveWsActivityReason = "connected";
951
+ watchdogTimer;
952
+ watchdogReconnectCount = 0;
953
+ /** 外层主动停止 listener 时调用,阻止 onError/onClose/watchdog 触发重连。 */
924
954
  cancel() {
925
955
  this.cancelled = true;
926
956
  this.reconnecting = false;
957
+ this.stopLiveWsWatchdog();
927
958
  this.clearReconnectSleep();
928
959
  }
929
960
  /** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
@@ -933,12 +964,62 @@ var RoomSession = class extends RoomSessionBase {
933
964
  this.reconnectWake?.();
934
965
  this.reconnectWake = void 0;
935
966
  }
967
+ onListenerStarted() {
968
+ this.markLiveWsActivity("connected");
969
+ this.startLiveWsWatchdog();
970
+ }
971
+ onMonitoringStopped() {
972
+ this.cancel();
973
+ }
974
+ getWsHealthSnapshot() {
975
+ return {
976
+ lastActivityAt: this.lastLiveWsActivityAt,
977
+ lastActivityReason: this.lastLiveWsActivityReason,
978
+ watchdogReconnectCount: this.watchdogReconnectCount
979
+ };
980
+ }
981
+ markLiveWsActivity(reason) {
982
+ this.lastLiveWsActivityAt = Date.now();
983
+ this.lastLiveWsActivityReason = reason;
984
+ }
985
+ startLiveWsWatchdog() {
986
+ if (this.watchdogTimer || this.cancelled || this.ctx.isDisposed()) return;
987
+ this.watchdogTimer = this.ctx.serviceCtx.setInterval(() => this.checkLiveWsWatchdog(), LIVE_WS_WATCHDOG_INTERVAL_MS);
988
+ }
989
+ stopLiveWsWatchdog() {
990
+ this.watchdogTimer?.dispose();
991
+ this.watchdogTimer = void 0;
992
+ }
993
+ checkLiveWsWatchdog() {
994
+ if (this.cancelled || this.ctx.isDisposed() || this.reconnecting) return;
995
+ if (this.lastLiveWsActivityAt <= 0) return;
996
+ const staleMs = Date.now() - this.lastLiveWsActivityAt;
997
+ if (staleMs < 18e4) return;
998
+ this.watchdogReconnectCount++;
999
+ this.reconnect("watchdog", `${Math.floor(staleMs / 1e3)}s 无 heartbeat/消息(last=${this.lastLiveWsActivityReason},watchdog=${this.watchdogReconnectCount})`);
1000
+ }
936
1001
  buildHandler() {
937
1002
  const base = {
1003
+ onOpen: () => this.markLiveWsActivity("open"),
1004
+ onStartListen: () => this.markLiveWsActivity("start-listen"),
1005
+ onClose: () => {
1006
+ if (this.cancelled || this.ctx.isDisposed()) return;
1007
+ if (this.ctx.consumeIntentionalClose(this.sub.roomId)) return;
1008
+ this.markLiveWsActivity("close");
1009
+ this.reconnect("close");
1010
+ },
938
1011
  onError: () => this.onError(),
939
- onIncomeDanmu: ({ body }) => this.onIncomeDanmu(body),
940
- onIncomeSuperChat: ({ body }) => this.onIncomeSuperChat(body),
1012
+ onAttentionChange: () => this.markLiveWsActivity("heartbeat"),
1013
+ onIncomeDanmu: ({ body }) => {
1014
+ this.markLiveWsActivity("danmu");
1015
+ this.onIncomeDanmu(body);
1016
+ },
1017
+ onIncomeSuperChat: ({ body }) => {
1018
+ this.markLiveWsActivity("superchat");
1019
+ return this.onIncomeSuperChat(body);
1020
+ },
941
1021
  onWatchedChange: ({ body }) => {
1022
+ this.markLiveWsActivity("watched");
942
1023
  this.liveData.watchedNum = body.text_small;
943
1024
  const now = Date.now();
944
1025
  if (now - this.lastViewersEmitMs >= VIEWERS_EMIT_THROTTLE_MS) {
@@ -947,42 +1028,61 @@ var RoomSession = class extends RoomSessionBase {
947
1028
  }
948
1029
  },
949
1030
  onLikedChange: ({ body }) => {
1031
+ this.markLiveWsActivity("liked");
950
1032
  this.liveData.likedNum = body.count;
951
1033
  },
952
- onGuardBuy: ({ body }) => this.onGuardBuy(body),
953
- onLiveStart: () => this.onLiveStart(),
954
- onLiveEnd: () => this.onLiveEnd()
1034
+ onGuardBuy: ({ body }) => {
1035
+ this.markLiveWsActivity("guard");
1036
+ return this.onGuardBuy(body);
1037
+ },
1038
+ onLiveStart: () => {
1039
+ this.markLiveWsActivity("live-start");
1040
+ return this.onLiveStart();
1041
+ },
1042
+ onLiveEnd: () => {
1043
+ this.markLiveWsActivity("live-end");
1044
+ return this.onLiveEnd();
1045
+ }
955
1046
  };
956
1047
  if (!this.sub.customSpecialUsersEnterTheRoom.enable) return base;
957
1048
  return {
958
1049
  ...base,
959
- raw: { INTERACT_WORD_V2: (msg) => this.onInteractWordV2(msg) }
1050
+ raw: { INTERACT_WORD_V2: (msg) => {
1051
+ this.markLiveWsActivity("interact");
1052
+ return this.onInteractWordV2(msg);
1053
+ } }
960
1054
  };
961
1055
  }
962
- async onError() {
1056
+ onError() {
1057
+ return this.reconnect("error");
1058
+ }
1059
+ async reconnect(reason, detail) {
963
1060
  if (this.cancelled || this.ctx.isDisposed()) return;
964
1061
  if (this.reconnecting) return;
965
1062
  this.reconnecting = true;
966
1063
  try {
967
- await this.reconnectLoop();
1064
+ await this.reconnectLoop(reason, detail);
968
1065
  } finally {
969
1066
  this.reconnecting = false;
970
1067
  }
971
1068
  }
972
1069
  /**
973
- * 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
1070
+ * 退避重连循环(单飞,由 reconnect 持有)。`while` 取代旧的 `setTimeout(0)`
974
1071
  * 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
975
1072
  * 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
976
1073
  */
977
- async reconnectLoop() {
1074
+ async reconnectLoop(reason, detail) {
978
1075
  while (this.reconnectAttempts < RECONNECT_BACKOFF_MS.length) {
979
1076
  if (this.cancelled || this.ctx.isDisposed()) return;
980
- this.setLiveStatus(false);
981
- this.cancelPeriodicTimer();
1077
+ if (reason === "error") {
1078
+ this.setLiveStatus(false);
1079
+ this.cancelPeriodicTimer();
1080
+ }
982
1081
  this.ctx.closeListener(this.sub.roomId);
983
1082
  const delay = RECONNECT_BACKOFF_MS[this.reconnectAttempts];
984
1083
  this.reconnectAttempts++;
985
- this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 连接错误,${delay / 1e3}s 后重连( ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
1084
+ const reasonText = this.describeReconnectReason(reason, detail);
1085
+ this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] ${reasonText},${delay / 1e3}s 后重连(第 ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
986
1086
  await this.sleepReconnect(delay);
987
1087
  if (this.cancelled || this.ctx.isDisposed()) return;
988
1088
  let ok = false;
@@ -996,6 +1096,7 @@ var RoomSession = class extends RoomSessionBase {
996
1096
  return;
997
1097
  }
998
1098
  if (ok) {
1099
+ this.onListenerStarted();
999
1100
  this.ctx.logger.info(`[conn] 直播间 [${this.sub.roomId}] 重连成功`);
1000
1101
  this.reconnectAttempts = 0;
1001
1102
  return;
@@ -1003,9 +1104,15 @@ var RoomSession = class extends RoomSessionBase {
1003
1104
  this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 重连未成功,继续退避`);
1004
1105
  }
1005
1106
  this.reconnectAttempts = 0;
1006
- const msg = `直播间 [${this.sub.roomId}] 连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1107
+ const msg = `直播间 [${this.sub.roomId}] ${this.describeReconnectReason(reason, detail)}后连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
1007
1108
  this.ctx.logger.error(`[conn] ${msg}`);
1008
1109
  this.ctx.emitEngineError(msg);
1110
+ this.cancel();
1111
+ }
1112
+ describeReconnectReason(reason, detail) {
1113
+ if (reason === "error") return "连接错误";
1114
+ if (reason === "close") return "连接关闭";
1115
+ return detail ? `连接静默(${detail})` : "连接静默";
1009
1116
  }
1010
1117
  /**
1011
1118
  * L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
@@ -1128,6 +1235,7 @@ var RoomSession = class extends RoomSessionBase {
1128
1235
  if (!await this.useLiveRoomInfo(1) || !await this.useMasterInfo(1) || !this.liveRoomInfo || !this.masterInfo) {
1129
1236
  this.setLiveStatus(false);
1130
1237
  if (this.ctx.isDisposed()) return;
1238
+ this.onMonitoringStopped();
1131
1239
  this.ctx.stopMonitoring("获取直播间信息失败,推送直播开播卡片失败", this.sub.roomId);
1132
1240
  return;
1133
1241
  }
@@ -1669,9 +1777,7 @@ var LiveEngine = class {
1669
1777
  /** Tear down all listeners + per-room state, leaving the engine instance reusable. */
1670
1778
  teardown() {
1671
1779
  this.logger.info("[live] 关闭所有直播间监听");
1672
- this.listener.clearPushTimers();
1673
- this.listener.clearListeners();
1674
- this.danmakuCollector.clearAll();
1780
+ this.listener.disposeAll();
1675
1781
  }
1676
1782
  /** Full rebootstrap. Used after auth-restored. */
1677
1783
  rebuildFromSubs(subs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bilibili-notify/live",
3
- "version": "0.0.1-alpha.3",
3
+ "version": "0.1.0-alpha.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Akokk0/bilibili-notify"
@@ -30,10 +30,10 @@
30
30
  "jieba-wasm": "^2.4.0",
31
31
  "luxon": "^3.5.0",
32
32
  "protobufjs": "^7.4.0",
33
- "@bilibili-notify/api": "^0.2.0-alpha.2",
34
33
  "@bilibili-notify/ai": "^0.0.1-alpha.1",
35
- "@bilibili-notify/internal": "^0.1.0-alpha.2",
36
- "@bilibili-notify/image": "^0.0.1-alpha.2"
34
+ "@bilibili-notify/image": "^0.0.1-alpha.2",
35
+ "@bilibili-notify/internal": "^0.1.0-alpha.4",
36
+ "@bilibili-notify/api": "^0.2.0-alpha.2"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/luxon": "^3.4.2",