@bilibili-notify/live 0.0.1-alpha.2 → 0.1.0-alpha.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/lib/index.cjs CHANGED
@@ -168,11 +168,16 @@ var RoomContextBase = class {
168
168
  liveSummaryRequester;
169
169
  danmakuCollector;
170
170
  /**
171
- * 真实注入的渲染器引用,private 是因为外部应通过 `imageRenderer` getter 访问 ——
171
+ * 渲染器 provider —— private 是因为外部应通过 `imageRenderer` getter 访问 ——
172
172
  * 后者会在 `config.imageEnabled === false` 时返回 null,让所有
173
173
  * `if (this.imageRenderer?.generateXxx)` 自然落入文字回退分支。
174
+ * provider 形式让 LiveEngine 的 setImageRenderer 无需逐 RoomContext 推。
175
+ *
176
+ * **不要直接调 `this._getImageRenderer()` 绕过 imageEnabled 门控**,业务路径
177
+ * 必须通过 `this.imageRenderer` getter,否则用户在 dashboard 关掉卡片渲染时
178
+ * 这条路径仍会渲图。
174
179
  */
175
- _imageRenderer;
180
+ _getImageRenderer;
176
181
  emitEngineError;
177
182
  _emitLiveState;
178
183
  _emitViewers;
@@ -199,7 +204,7 @@ var RoomContextBase = class {
199
204
  this.wordcloudGenerator = opts.wordcloudGenerator;
200
205
  this.liveSummaryRequester = opts.liveSummaryRequester;
201
206
  this.danmakuCollector = opts.danmakuCollector;
202
- this._imageRenderer = opts.imageRenderer;
207
+ this._getImageRenderer = opts.getImageRenderer;
203
208
  this.config = opts.config;
204
209
  this.emitEngineError = opts.emitEngineError;
205
210
  this._emitLiveState = opts.emitLiveState;
@@ -219,7 +224,7 @@ var RoomContextBase = class {
219
224
  }
220
225
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
221
226
  get imageRenderer() {
222
- return this.config.imageEnabled === false ? null : this._imageRenderer;
227
+ return this.config.imageEnabled === false ? null : this._getImageRenderer();
223
228
  }
224
229
  updateConfig(config) {
225
230
  this.config = config;
@@ -494,37 +499,41 @@ var RoomContext = class extends RoomContextBase {
494
499
  * - `customSpecialDanmakuUsers.msgTemplate`
495
500
  * - `customSpecialUsersEnterTheRoom.msgTemplate`
496
501
  *
497
- * The variable syntax follows the existing `-name` / `-time` / `-watched` style
498
- * (NOT the `{key}` syntax used by `@bilibili-notify/internal`'s `interpolate`),
499
- * because that's what users have in their existing Koishi configs and we keep
500
- * 1:1 backward compatibility.
502
+ * 占位符统一 `{name}` / `{time}` / `{watched}` 语法(与 `@bilibili-notify/internal`
503
+ * `interpolate` 同源)。`applyTemplate` 同时接受 koishi 旧存档里的 legacy
504
+ * `-name` / `-time` 写法 —— 老用户已保存的 `-key` 模板继续生效,新默认与文档
505
+ * 一律走 `{key}`,二者不冲突(单遍正则,longest-first)。
501
506
  */
502
507
  /** Defaults applied when neither sub-level nor global config provides a template. */
503
508
  const DEFAULT_LIVE_TEMPLATES = {
504
- liveStart: "-name 开播啦,当前粉丝数:-follower\n-link",
505
- liveOngoing: "-name 正在直播,已播 -time,累计观看:-watched\n-link",
506
- liveEnd: "-name 下播啦,本次直播了 -time,粉丝变化 -follower_change",
509
+ liveStart: "{name} 开播啦,当前粉丝数:{follower}\n{link}",
510
+ liveOngoing: "{name} 正在直播,已播 {time},累计观看:{watched}\n{link}",
511
+ liveEnd: "{name} 下播啦,本次直播了 {time},粉丝变化 {follower_change}",
507
512
  liveSummaryFallback: "弹幕总结"
508
513
  };
509
514
  function escapeRegExp(s) {
510
515
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
511
516
  }
512
517
  /**
513
- * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。
518
+ * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。`vars` 以**裸键**(`name`/
519
+ * `follower_change`)给出,每个键同时匹配 `{name}`(主)与 legacy `-name`(兼容)。
514
520
  *
515
521
  * P2:此前 `for…replaceAll` 顺序替换有两个缺陷 ——
516
- * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `-link`/`-time` 时
522
+ * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `{link}`/`-link` 时
517
523
  * 会被后续轮次再次替换;
518
- * 2. **前缀吞噬**:`-follower` 先于 `-follower_change` 替换,把后者的
524
+ * 2. **前缀吞噬**:legacy `-follower` 先于 `-follower_change` 替换,把后者的
519
525
  * `-follower` 段吃掉只剩 `_change`。
520
- * 改为基于原始模板的**单遍正则**:token 按长度降序进 alternation(最长优先
521
- * 匹配),每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
526
+ * 改为基于原始模板的**单遍正则**:键按长度降序进 alternation(最长优先匹配),
527
+ * 每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
522
528
  */
523
529
  function applyTemplate(template, vars) {
524
530
  const keys = Object.keys(vars).sort((a, b) => b.length - a.length);
525
531
  if (keys.length === 0) return template.replaceAll("\\n", "\n");
526
- const re = new RegExp(keys.map(escapeRegExp).join("|"), "g");
527
- return template.replace(re, (m) => vars[m] ?? m).replaceAll("\\n", "\n");
532
+ const alts = keys.flatMap((k) => [`\\{${escapeRegExp(k)}\\}`, `-${escapeRegExp(k)}`]);
533
+ const re = new RegExp(alts.join("|"), "g");
534
+ return template.replace(re, (m) => {
535
+ return vars[m.charCodeAt(0) === 123 ? m.slice(1, -1) : m.slice(1)] ?? m;
536
+ }).replaceAll("\\n", "\n");
528
537
  }
529
538
  /**
530
539
  * Format follower-change as a signed magnitude string with a 1万 (10K) cutoff,
@@ -554,27 +563,27 @@ var LiveTemplateRenderer = class {
554
563
  /** Compose the "开播" notification text for a sub. */
555
564
  renderLiveStart(params) {
556
565
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveStart", DEFAULT_LIVE_TEMPLATES.liveStart), {
557
- "-name": params.master.username,
558
- "-time": params.diffTime,
559
- "-follower": params.followerNum,
560
- "-link": params.roomLink
566
+ name: params.master.username,
567
+ time: params.diffTime,
568
+ follower: params.followerNum,
569
+ link: params.roomLink
561
570
  });
562
571
  }
563
572
  /** Compose the periodic "正在直播" notification text. */
564
573
  renderLiveOngoing(params) {
565
574
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLive", DEFAULT_LIVE_TEMPLATES.liveOngoing), {
566
- "-name": params.master.username,
567
- "-time": params.diffTime,
568
- "-watched": params.watched,
569
- "-link": params.roomLink
575
+ name: params.master.username,
576
+ time: params.diffTime,
577
+ watched: params.watched,
578
+ link: params.roomLink
570
579
  });
571
580
  }
572
581
  /** Compose the "下播" notification text. */
573
582
  renderLiveEnd(params) {
574
583
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveEnd", DEFAULT_LIVE_TEMPLATES.liveEnd), {
575
- "-name": params.master.username,
576
- "-time": params.diffTime,
577
- "-follower_change": formatFollowerChange(params.followerChange)
584
+ name: params.master.username,
585
+ time: params.diffTime,
586
+ follower_change: formatFollowerChange(params.followerChange)
578
587
  });
579
588
  }
580
589
  /**
@@ -583,24 +592,24 @@ var LiveTemplateRenderer = class {
583
592
  */
584
593
  renderGuardBuy(params) {
585
594
  return applyTemplate(params.guardBuyConfig.guardBuyMsg ?? "", {
586
- "-uname": params.uname,
587
- "-mname": params.master?.username ?? "",
588
- "-guard": params.giftName
595
+ uname: params.uname,
596
+ mname: params.master?.username ?? "",
597
+ guard: params.giftName
589
598
  });
590
599
  }
591
600
  /** Compose the "特别关注弹幕" notification text. */
592
601
  renderSpecialDanmaku(params) {
593
602
  return applyTemplate(params.template, {
594
- "-mastername": params.master?.username ?? "",
595
- "-uname": params.uname,
596
- "-msg": params.content
603
+ mastername: params.master?.username ?? "",
604
+ uname: params.uname,
605
+ msg: params.content
597
606
  });
598
607
  }
599
608
  /** Compose the "特别关注进入直播间" notification text. */
600
609
  renderSpecialUserEnter(params) {
601
610
  return applyTemplate(params.template, {
602
- "-mastername": params.master?.username ?? "",
603
- "-uname": params.uname
611
+ mastername: params.master?.username ?? "",
612
+ uname: params.uname
604
613
  });
605
614
  }
606
615
  /**
@@ -613,19 +622,19 @@ var LiveTemplateRenderer = class {
613
622
  const top = params.topSenders;
614
623
  const at = (i) => top[i] ?? ["", 0];
615
624
  return applyTemplate(params.template, {
616
- "-dmc": `${params.senderCount}`,
617
- "-mdn": params.master?.medalName ?? "",
618
- "-dca": `${params.danmakuCount}`,
619
- "-un1": at(0)[0],
620
- "-dc1": `${at(0)[1]}`,
621
- "-un2": at(1)[0],
622
- "-dc2": `${at(1)[1]}`,
623
- "-un3": at(2)[0],
624
- "-dc3": `${at(2)[1]}`,
625
- "-un4": at(3)[0],
626
- "-dc4": `${at(3)[1]}`,
627
- "-un5": at(4)[0],
628
- "-dc5": `${at(4)[1]}`
625
+ dmc: `${params.senderCount}`,
626
+ mdn: params.master?.medalName ?? "",
627
+ dca: `${params.danmakuCount}`,
628
+ un1: at(0)[0],
629
+ dc1: `${at(0)[1]}`,
630
+ un2: at(1)[0],
631
+ dc2: `${at(1)[1]}`,
632
+ un3: at(2)[0],
633
+ dc3: `${at(2)[1]}`,
634
+ un4: at(3)[0],
635
+ dc4: `${at(3)[1]}`,
636
+ un5: at(4)[0],
637
+ dc5: `${at(4)[1]}`
629
638
  });
630
639
  }
631
640
  };
@@ -1576,11 +1585,11 @@ const WORDCLOUD_TOP_WORDS = 90;
1576
1585
  * platform (e.g. via `LiveContentBuilder.image`).
1577
1586
  */
1578
1587
  var WordcloudGenerator = class {
1579
- imageRenderer;
1588
+ getImageRenderer;
1580
1589
  isImageEnabled;
1581
1590
  logger;
1582
1591
  constructor(opts) {
1583
- this.imageRenderer = opts.imageRenderer;
1592
+ this.getImageRenderer = opts.getImageRenderer;
1584
1593
  this.isImageEnabled = opts.isImageEnabled ?? (() => true);
1585
1594
  this.logger = opts.logger;
1586
1595
  }
@@ -1602,9 +1611,10 @@ var WordcloudGenerator = class {
1602
1611
  this.logger.debug("[wordcloud] cardStyle.enabled=false,跳过词云图片生成");
1603
1612
  return;
1604
1613
  }
1605
- if (!this.imageRenderer?.generateWordCloudImg) return void 0;
1614
+ const renderer = this.getImageRenderer();
1615
+ if (!renderer?.generateWordCloudImg) return void 0;
1606
1616
  try {
1607
- return await this.imageRenderer.generateWordCloudImg(sortedWords.slice(0, 90), masterName, masterAvatarUrl);
1617
+ return await renderer.generateWordCloudImg(sortedWords.slice(0, 90), masterName, masterAvatarUrl);
1608
1618
  } catch (e) {
1609
1619
  this.logger.error(`[wordcloud] 生成词云失败:${e.message}`);
1610
1620
  return;
@@ -1634,14 +1644,22 @@ var LiveEngine = class {
1634
1644
  danmakuCollector;
1635
1645
  liveSummaryRequester;
1636
1646
  config;
1647
+ /**
1648
+ * Image 渲染器的当前引用 —— 单一可变 state,setImageRenderer 在此更新;
1649
+ * 所有子组件(wordcloud / listener / room-context)通过 provider 函数现取,
1650
+ * 无需逐组件 setter 推送。
1651
+ */
1652
+ currentImageRenderer;
1637
1653
  constructor(opts) {
1638
1654
  this.logger = opts.serviceCtx.logger;
1639
1655
  this.config = opts.config;
1656
+ this.currentImageRenderer = opts.imageRenderer ?? null;
1657
+ const getImageRenderer = () => this.currentImageRenderer;
1640
1658
  const stopwords = mergeStopWords(opts.config.wordcloudStopWords);
1641
1659
  this.danmakuCollector = new DanmakuCollector(stopwords);
1642
1660
  const templateRenderer = new LiveTemplateRenderer();
1643
1661
  const wordcloudGenerator = new WordcloudGenerator({
1644
- imageRenderer: opts.imageRenderer ?? null,
1662
+ getImageRenderer,
1645
1663
  isImageEnabled: () => this.config.imageEnabled !== false,
1646
1664
  logger: this.logger
1647
1665
  });
@@ -1661,7 +1679,7 @@ var LiveEngine = class {
1661
1679
  wordcloudGenerator,
1662
1680
  liveSummaryRequester,
1663
1681
  danmakuCollector: this.danmakuCollector,
1664
- imageRenderer: opts.imageRenderer ?? null,
1682
+ getImageRenderer,
1665
1683
  config: toListenerConfig(opts.config),
1666
1684
  emitEngineError: opts.emitEngineError,
1667
1685
  emitLiveState: opts.emitLiveState,
@@ -1744,6 +1762,17 @@ var LiveEngine = class {
1744
1762
  setCommentary(commentary) {
1745
1763
  this.liveSummaryRequester.setCommentary(commentary);
1746
1764
  }
1765
+ /**
1766
+ * 热替换 ImageRenderer 实例。adapter 在 image 服务上下线时调用。子组件 (词云 /
1767
+ * room-context / 卡片渲染) 都通过共享 provider 现取,这里只需更新单一 state。
1768
+ *
1769
+ * 主要给 koishi adapter 用 —— sibling service (-image) 启停时通过 ctx.inject
1770
+ * 后置注入。独立端 imageRenderer 是 engine 同进程一次性 wire,不会动态消失,
1771
+ * 不需要调用本方法 (cardStyle 热更走 imageRenderer.updateConfig)。
1772
+ */
1773
+ setImageRenderer(imageRenderer) {
1774
+ this.currentImageRenderer = imageRenderer;
1775
+ }
1747
1776
  /** Final dispose; the engine instance must not be reused after this. */
1748
1777
  stop() {
1749
1778
  this.listener.disposeAll();
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
  /**
@@ -433,11 +433,16 @@ declare const WORDCLOUD_TOP_WORDS = 90;
433
433
  * platform (e.g. via `LiveContentBuilder.image`).
434
434
  */
435
435
  declare class WordcloudGenerator {
436
- private readonly imageRenderer;
436
+ private readonly getImageRenderer;
437
437
  private readonly isImageEnabled;
438
438
  private readonly logger;
439
439
  constructor(opts: {
440
- imageRenderer: ImageRenderer | null;
440
+ /**
441
+ * 渲染器 provider —— 每次 generate() 现取最新引用,使 LiveEngine 在 image
442
+ * 服务上下线时通过 setImageRenderer 替换内部状态,词云生成自动同步,无需子组件
443
+ * 接 setter。
444
+ */
445
+ getImageRenderer: () => ImageRenderer | null;
441
446
  /**
442
447
  * 卡片渲染总开关查询。返回 false 时直接跳过 puppeteer 调用,与缺失 imageRenderer
443
448
  * 等价。Adapter 通常用 `() => globals.defaults.cardStyle.enabled` 填充;缺省 () => true。
@@ -502,7 +507,11 @@ interface RoomContextOptions {
502
507
  wordcloudGenerator: WordcloudGenerator;
503
508
  liveSummaryRequester: LiveSummaryRequester;
504
509
  danmakuCollector: DanmakuCollector;
505
- imageRenderer: ImageRenderer | null;
510
+ /**
511
+ * 渲染器 provider —— LiveEngine 在 image 服务上下线时通过 setImageRenderer
512
+ * 替换内部状态;getter `imageRenderer` 每次现取,所有 RoomContext 自动同步。
513
+ */
514
+ getImageRenderer: () => ImageRenderer | null;
506
515
  config: ListenerManagerConfig;
507
516
  emitEngineError: (message: string) => void;
508
517
  /**
@@ -546,11 +555,16 @@ declare class RoomContextBase {
546
555
  readonly liveSummaryRequester: LiveSummaryRequester;
547
556
  readonly danmakuCollector: DanmakuCollector;
548
557
  /**
549
- * 真实注入的渲染器引用,private 是因为外部应通过 `imageRenderer` getter 访问 ——
558
+ * 渲染器 provider —— private 是因为外部应通过 `imageRenderer` getter 访问 ——
550
559
  * 后者会在 `config.imageEnabled === false` 时返回 null,让所有
551
560
  * `if (this.imageRenderer?.generateXxx)` 自然落入文字回退分支。
561
+ * provider 形式让 LiveEngine 的 setImageRenderer 无需逐 RoomContext 推。
562
+ *
563
+ * **不要直接调 `this._getImageRenderer()` 绕过 imageEnabled 门控**,业务路径
564
+ * 必须通过 `this.imageRenderer` getter,否则用户在 dashboard 关掉卡片渲染时
565
+ * 这条路径仍会渲图。
552
566
  */
553
- private readonly _imageRenderer;
567
+ private readonly _getImageRenderer;
554
568
  readonly emitEngineError: (message: string) => void;
555
569
  private readonly _emitLiveState;
556
570
  private readonly _emitViewers;
@@ -950,6 +964,12 @@ declare class LiveEngine {
950
964
  private readonly danmakuCollector;
951
965
  private readonly liveSummaryRequester;
952
966
  private config;
967
+ /**
968
+ * Image 渲染器的当前引用 —— 单一可变 state,setImageRenderer 在此更新;
969
+ * 所有子组件(wordcloud / listener / room-context)通过 provider 函数现取,
970
+ * 无需逐组件 setter 推送。
971
+ */
972
+ private currentImageRenderer;
953
973
  constructor(opts: LiveEngineOptions);
954
974
  /**
955
975
  * Bootstrap the engine with the initial subscription set. Idempotent —
@@ -973,6 +993,15 @@ declare class LiveEngine {
973
993
  * 配置后调用,引擎随后的直播总结会立即用新实例 (或回退到模板) ,无需重启 server。
974
994
  */
975
995
  setCommentary(commentary: CommentaryGenerator | null): void;
996
+ /**
997
+ * 热替换 ImageRenderer 实例。adapter 在 image 服务上下线时调用。子组件 (词云 /
998
+ * room-context / 卡片渲染) 都通过共享 provider 现取,这里只需更新单一 state。
999
+ *
1000
+ * 主要给 koishi adapter 用 —— sibling service (-image) 启停时通过 ctx.inject
1001
+ * 后置注入。独立端 imageRenderer 是 engine 同进程一次性 wire,不会动态消失,
1002
+ * 不需要调用本方法 (cardStyle 热更走 imageRenderer.updateConfig)。
1003
+ */
1004
+ setImageRenderer(imageRenderer: ImageRenderer | null): void;
976
1005
  /** Final dispose; the engine instance must not be reused after this. */
977
1006
  stop(): void;
978
1007
  /** Diagnostic accessor, used by the koishi shell for `[conn] state` logging. */
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
  /**
@@ -433,11 +433,16 @@ declare const WORDCLOUD_TOP_WORDS = 90;
433
433
  * platform (e.g. via `LiveContentBuilder.image`).
434
434
  */
435
435
  declare class WordcloudGenerator {
436
- private readonly imageRenderer;
436
+ private readonly getImageRenderer;
437
437
  private readonly isImageEnabled;
438
438
  private readonly logger;
439
439
  constructor(opts: {
440
- imageRenderer: ImageRenderer | null;
440
+ /**
441
+ * 渲染器 provider —— 每次 generate() 现取最新引用,使 LiveEngine 在 image
442
+ * 服务上下线时通过 setImageRenderer 替换内部状态,词云生成自动同步,无需子组件
443
+ * 接 setter。
444
+ */
445
+ getImageRenderer: () => ImageRenderer | null;
441
446
  /**
442
447
  * 卡片渲染总开关查询。返回 false 时直接跳过 puppeteer 调用,与缺失 imageRenderer
443
448
  * 等价。Adapter 通常用 `() => globals.defaults.cardStyle.enabled` 填充;缺省 () => true。
@@ -502,7 +507,11 @@ interface RoomContextOptions {
502
507
  wordcloudGenerator: WordcloudGenerator;
503
508
  liveSummaryRequester: LiveSummaryRequester;
504
509
  danmakuCollector: DanmakuCollector;
505
- imageRenderer: ImageRenderer | null;
510
+ /**
511
+ * 渲染器 provider —— LiveEngine 在 image 服务上下线时通过 setImageRenderer
512
+ * 替换内部状态;getter `imageRenderer` 每次现取,所有 RoomContext 自动同步。
513
+ */
514
+ getImageRenderer: () => ImageRenderer | null;
506
515
  config: ListenerManagerConfig;
507
516
  emitEngineError: (message: string) => void;
508
517
  /**
@@ -546,11 +555,16 @@ declare class RoomContextBase {
546
555
  readonly liveSummaryRequester: LiveSummaryRequester;
547
556
  readonly danmakuCollector: DanmakuCollector;
548
557
  /**
549
- * 真实注入的渲染器引用,private 是因为外部应通过 `imageRenderer` getter 访问 ——
558
+ * 渲染器 provider —— private 是因为外部应通过 `imageRenderer` getter 访问 ——
550
559
  * 后者会在 `config.imageEnabled === false` 时返回 null,让所有
551
560
  * `if (this.imageRenderer?.generateXxx)` 自然落入文字回退分支。
561
+ * provider 形式让 LiveEngine 的 setImageRenderer 无需逐 RoomContext 推。
562
+ *
563
+ * **不要直接调 `this._getImageRenderer()` 绕过 imageEnabled 门控**,业务路径
564
+ * 必须通过 `this.imageRenderer` getter,否则用户在 dashboard 关掉卡片渲染时
565
+ * 这条路径仍会渲图。
552
566
  */
553
- private readonly _imageRenderer;
567
+ private readonly _getImageRenderer;
554
568
  readonly emitEngineError: (message: string) => void;
555
569
  private readonly _emitLiveState;
556
570
  private readonly _emitViewers;
@@ -950,6 +964,12 @@ declare class LiveEngine {
950
964
  private readonly danmakuCollector;
951
965
  private readonly liveSummaryRequester;
952
966
  private config;
967
+ /**
968
+ * Image 渲染器的当前引用 —— 单一可变 state,setImageRenderer 在此更新;
969
+ * 所有子组件(wordcloud / listener / room-context)通过 provider 函数现取,
970
+ * 无需逐组件 setter 推送。
971
+ */
972
+ private currentImageRenderer;
953
973
  constructor(opts: LiveEngineOptions);
954
974
  /**
955
975
  * Bootstrap the engine with the initial subscription set. Idempotent —
@@ -973,6 +993,15 @@ declare class LiveEngine {
973
993
  * 配置后调用,引擎随后的直播总结会立即用新实例 (或回退到模板) ,无需重启 server。
974
994
  */
975
995
  setCommentary(commentary: CommentaryGenerator | null): void;
996
+ /**
997
+ * 热替换 ImageRenderer 实例。adapter 在 image 服务上下线时调用。子组件 (词云 /
998
+ * room-context / 卡片渲染) 都通过共享 provider 现取,这里只需更新单一 state。
999
+ *
1000
+ * 主要给 koishi adapter 用 —— sibling service (-image) 启停时通过 ctx.inject
1001
+ * 后置注入。独立端 imageRenderer 是 engine 同进程一次性 wire,不会动态消失,
1002
+ * 不需要调用本方法 (cardStyle 热更走 imageRenderer.updateConfig)。
1003
+ */
1004
+ setImageRenderer(imageRenderer: ImageRenderer | null): void;
976
1005
  /** Final dispose; the engine instance must not be reused after this. */
977
1006
  stop(): void;
978
1007
  /** Diagnostic accessor, used by the koishi shell for `[conn] state` logging. */
package/lib/index.mjs CHANGED
@@ -144,11 +144,16 @@ var RoomContextBase = class {
144
144
  liveSummaryRequester;
145
145
  danmakuCollector;
146
146
  /**
147
- * 真实注入的渲染器引用,private 是因为外部应通过 `imageRenderer` getter 访问 ——
147
+ * 渲染器 provider —— private 是因为外部应通过 `imageRenderer` getter 访问 ——
148
148
  * 后者会在 `config.imageEnabled === false` 时返回 null,让所有
149
149
  * `if (this.imageRenderer?.generateXxx)` 自然落入文字回退分支。
150
+ * provider 形式让 LiveEngine 的 setImageRenderer 无需逐 RoomContext 推。
151
+ *
152
+ * **不要直接调 `this._getImageRenderer()` 绕过 imageEnabled 门控**,业务路径
153
+ * 必须通过 `this.imageRenderer` getter,否则用户在 dashboard 关掉卡片渲染时
154
+ * 这条路径仍会渲图。
150
155
  */
151
- _imageRenderer;
156
+ _getImageRenderer;
152
157
  emitEngineError;
153
158
  _emitLiveState;
154
159
  _emitViewers;
@@ -175,7 +180,7 @@ var RoomContextBase = class {
175
180
  this.wordcloudGenerator = opts.wordcloudGenerator;
176
181
  this.liveSummaryRequester = opts.liveSummaryRequester;
177
182
  this.danmakuCollector = opts.danmakuCollector;
178
- this._imageRenderer = opts.imageRenderer;
183
+ this._getImageRenderer = opts.getImageRenderer;
179
184
  this.config = opts.config;
180
185
  this.emitEngineError = opts.emitEngineError;
181
186
  this._emitLiveState = opts.emitLiveState;
@@ -195,7 +200,7 @@ var RoomContextBase = class {
195
200
  }
196
201
  /** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
197
202
  get imageRenderer() {
198
- return this.config.imageEnabled === false ? null : this._imageRenderer;
203
+ return this.config.imageEnabled === false ? null : this._getImageRenderer();
199
204
  }
200
205
  updateConfig(config) {
201
206
  this.config = config;
@@ -470,37 +475,41 @@ var RoomContext = class extends RoomContextBase {
470
475
  * - `customSpecialDanmakuUsers.msgTemplate`
471
476
  * - `customSpecialUsersEnterTheRoom.msgTemplate`
472
477
  *
473
- * The variable syntax follows the existing `-name` / `-time` / `-watched` style
474
- * (NOT the `{key}` syntax used by `@bilibili-notify/internal`'s `interpolate`),
475
- * because that's what users have in their existing Koishi configs and we keep
476
- * 1:1 backward compatibility.
478
+ * 占位符统一 `{name}` / `{time}` / `{watched}` 语法(与 `@bilibili-notify/internal`
479
+ * `interpolate` 同源)。`applyTemplate` 同时接受 koishi 旧存档里的 legacy
480
+ * `-name` / `-time` 写法 —— 老用户已保存的 `-key` 模板继续生效,新默认与文档
481
+ * 一律走 `{key}`,二者不冲突(单遍正则,longest-first)。
477
482
  */
478
483
  /** Defaults applied when neither sub-level nor global config provides a template. */
479
484
  const DEFAULT_LIVE_TEMPLATES = {
480
- liveStart: "-name 开播啦,当前粉丝数:-follower\n-link",
481
- liveOngoing: "-name 正在直播,已播 -time,累计观看:-watched\n-link",
482
- liveEnd: "-name 下播啦,本次直播了 -time,粉丝变化 -follower_change",
485
+ liveStart: "{name} 开播啦,当前粉丝数:{follower}\n{link}",
486
+ liveOngoing: "{name} 正在直播,已播 {time},累计观看:{watched}\n{link}",
487
+ liveEnd: "{name} 下播啦,本次直播了 {time},粉丝变化 {follower_change}",
483
488
  liveSummaryFallback: "弹幕总结"
484
489
  };
485
490
  function escapeRegExp(s) {
486
491
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
487
492
  }
488
493
  /**
489
- * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。
494
+ * 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。`vars` 以**裸键**(`name`/
495
+ * `follower_change`)给出,每个键同时匹配 `{name}`(主)与 legacy `-name`(兼容)。
490
496
  *
491
497
  * P2:此前 `for…replaceAll` 顺序替换有两个缺陷 ——
492
- * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `-link`/`-time` 时
498
+ * 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `{link}`/`-link` 时
493
499
  * 会被后续轮次再次替换;
494
- * 2. **前缀吞噬**:`-follower` 先于 `-follower_change` 替换,把后者的
500
+ * 2. **前缀吞噬**:legacy `-follower` 先于 `-follower_change` 替换,把后者的
495
501
  * `-follower` 段吃掉只剩 `_change`。
496
- * 改为基于原始模板的**单遍正则**:token 按长度降序进 alternation(最长优先
497
- * 匹配),每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
502
+ * 改为基于原始模板的**单遍正则**:键按长度降序进 alternation(最长优先匹配),
503
+ * 每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
498
504
  */
499
505
  function applyTemplate(template, vars) {
500
506
  const keys = Object.keys(vars).sort((a, b) => b.length - a.length);
501
507
  if (keys.length === 0) return template.replaceAll("\\n", "\n");
502
- const re = new RegExp(keys.map(escapeRegExp).join("|"), "g");
503
- return template.replace(re, (m) => vars[m] ?? m).replaceAll("\\n", "\n");
508
+ const alts = keys.flatMap((k) => [`\\{${escapeRegExp(k)}\\}`, `-${escapeRegExp(k)}`]);
509
+ const re = new RegExp(alts.join("|"), "g");
510
+ return template.replace(re, (m) => {
511
+ return vars[m.charCodeAt(0) === 123 ? m.slice(1, -1) : m.slice(1)] ?? m;
512
+ }).replaceAll("\\n", "\n");
504
513
  }
505
514
  /**
506
515
  * Format follower-change as a signed magnitude string with a 1万 (10K) cutoff,
@@ -530,27 +539,27 @@ var LiveTemplateRenderer = class {
530
539
  /** Compose the "开播" notification text for a sub. */
531
540
  renderLiveStart(params) {
532
541
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveStart", DEFAULT_LIVE_TEMPLATES.liveStart), {
533
- "-name": params.master.username,
534
- "-time": params.diffTime,
535
- "-follower": params.followerNum,
536
- "-link": params.roomLink
542
+ name: params.master.username,
543
+ time: params.diffTime,
544
+ follower: params.followerNum,
545
+ link: params.roomLink
537
546
  });
538
547
  }
539
548
  /** Compose the periodic "正在直播" notification text. */
540
549
  renderLiveOngoing(params) {
541
550
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLive", DEFAULT_LIVE_TEMPLATES.liveOngoing), {
542
- "-name": params.master.username,
543
- "-time": params.diffTime,
544
- "-watched": params.watched,
545
- "-link": params.roomLink
551
+ name: params.master.username,
552
+ time: params.diffTime,
553
+ watched: params.watched,
554
+ link: params.roomLink
546
555
  });
547
556
  }
548
557
  /** Compose the "下播" notification text. */
549
558
  renderLiveEnd(params) {
550
559
  return applyTemplate(resolveCustomLive(params.sub.customLiveMsg, params.globalCustom, "customLiveEnd", DEFAULT_LIVE_TEMPLATES.liveEnd), {
551
- "-name": params.master.username,
552
- "-time": params.diffTime,
553
- "-follower_change": formatFollowerChange(params.followerChange)
560
+ name: params.master.username,
561
+ time: params.diffTime,
562
+ follower_change: formatFollowerChange(params.followerChange)
554
563
  });
555
564
  }
556
565
  /**
@@ -559,24 +568,24 @@ var LiveTemplateRenderer = class {
559
568
  */
560
569
  renderGuardBuy(params) {
561
570
  return applyTemplate(params.guardBuyConfig.guardBuyMsg ?? "", {
562
- "-uname": params.uname,
563
- "-mname": params.master?.username ?? "",
564
- "-guard": params.giftName
571
+ uname: params.uname,
572
+ mname: params.master?.username ?? "",
573
+ guard: params.giftName
565
574
  });
566
575
  }
567
576
  /** Compose the "特别关注弹幕" notification text. */
568
577
  renderSpecialDanmaku(params) {
569
578
  return applyTemplate(params.template, {
570
- "-mastername": params.master?.username ?? "",
571
- "-uname": params.uname,
572
- "-msg": params.content
579
+ mastername: params.master?.username ?? "",
580
+ uname: params.uname,
581
+ msg: params.content
573
582
  });
574
583
  }
575
584
  /** Compose the "特别关注进入直播间" notification text. */
576
585
  renderSpecialUserEnter(params) {
577
586
  return applyTemplate(params.template, {
578
- "-mastername": params.master?.username ?? "",
579
- "-uname": params.uname
587
+ mastername: params.master?.username ?? "",
588
+ uname: params.uname
580
589
  });
581
590
  }
582
591
  /**
@@ -589,19 +598,19 @@ var LiveTemplateRenderer = class {
589
598
  const top = params.topSenders;
590
599
  const at = (i) => top[i] ?? ["", 0];
591
600
  return applyTemplate(params.template, {
592
- "-dmc": `${params.senderCount}`,
593
- "-mdn": params.master?.medalName ?? "",
594
- "-dca": `${params.danmakuCount}`,
595
- "-un1": at(0)[0],
596
- "-dc1": `${at(0)[1]}`,
597
- "-un2": at(1)[0],
598
- "-dc2": `${at(1)[1]}`,
599
- "-un3": at(2)[0],
600
- "-dc3": `${at(2)[1]}`,
601
- "-un4": at(3)[0],
602
- "-dc4": `${at(3)[1]}`,
603
- "-un5": at(4)[0],
604
- "-dc5": `${at(4)[1]}`
601
+ dmc: `${params.senderCount}`,
602
+ mdn: params.master?.medalName ?? "",
603
+ dca: `${params.danmakuCount}`,
604
+ un1: at(0)[0],
605
+ dc1: `${at(0)[1]}`,
606
+ un2: at(1)[0],
607
+ dc2: `${at(1)[1]}`,
608
+ un3: at(2)[0],
609
+ dc3: `${at(2)[1]}`,
610
+ un4: at(3)[0],
611
+ dc4: `${at(3)[1]}`,
612
+ un5: at(4)[0],
613
+ dc5: `${at(4)[1]}`
605
614
  });
606
615
  }
607
616
  };
@@ -1552,11 +1561,11 @@ const WORDCLOUD_TOP_WORDS = 90;
1552
1561
  * platform (e.g. via `LiveContentBuilder.image`).
1553
1562
  */
1554
1563
  var WordcloudGenerator = class {
1555
- imageRenderer;
1564
+ getImageRenderer;
1556
1565
  isImageEnabled;
1557
1566
  logger;
1558
1567
  constructor(opts) {
1559
- this.imageRenderer = opts.imageRenderer;
1568
+ this.getImageRenderer = opts.getImageRenderer;
1560
1569
  this.isImageEnabled = opts.isImageEnabled ?? (() => true);
1561
1570
  this.logger = opts.logger;
1562
1571
  }
@@ -1578,9 +1587,10 @@ var WordcloudGenerator = class {
1578
1587
  this.logger.debug("[wordcloud] cardStyle.enabled=false,跳过词云图片生成");
1579
1588
  return;
1580
1589
  }
1581
- if (!this.imageRenderer?.generateWordCloudImg) return void 0;
1590
+ const renderer = this.getImageRenderer();
1591
+ if (!renderer?.generateWordCloudImg) return void 0;
1582
1592
  try {
1583
- return await this.imageRenderer.generateWordCloudImg(sortedWords.slice(0, 90), masterName, masterAvatarUrl);
1593
+ return await renderer.generateWordCloudImg(sortedWords.slice(0, 90), masterName, masterAvatarUrl);
1584
1594
  } catch (e) {
1585
1595
  this.logger.error(`[wordcloud] 生成词云失败:${e.message}`);
1586
1596
  return;
@@ -1610,14 +1620,22 @@ var LiveEngine = class {
1610
1620
  danmakuCollector;
1611
1621
  liveSummaryRequester;
1612
1622
  config;
1623
+ /**
1624
+ * Image 渲染器的当前引用 —— 单一可变 state,setImageRenderer 在此更新;
1625
+ * 所有子组件(wordcloud / listener / room-context)通过 provider 函数现取,
1626
+ * 无需逐组件 setter 推送。
1627
+ */
1628
+ currentImageRenderer;
1613
1629
  constructor(opts) {
1614
1630
  this.logger = opts.serviceCtx.logger;
1615
1631
  this.config = opts.config;
1632
+ this.currentImageRenderer = opts.imageRenderer ?? null;
1633
+ const getImageRenderer = () => this.currentImageRenderer;
1616
1634
  const stopwords = mergeStopWords(opts.config.wordcloudStopWords);
1617
1635
  this.danmakuCollector = new DanmakuCollector(stopwords);
1618
1636
  const templateRenderer = new LiveTemplateRenderer();
1619
1637
  const wordcloudGenerator = new WordcloudGenerator({
1620
- imageRenderer: opts.imageRenderer ?? null,
1638
+ getImageRenderer,
1621
1639
  isImageEnabled: () => this.config.imageEnabled !== false,
1622
1640
  logger: this.logger
1623
1641
  });
@@ -1637,7 +1655,7 @@ var LiveEngine = class {
1637
1655
  wordcloudGenerator,
1638
1656
  liveSummaryRequester,
1639
1657
  danmakuCollector: this.danmakuCollector,
1640
- imageRenderer: opts.imageRenderer ?? null,
1658
+ getImageRenderer,
1641
1659
  config: toListenerConfig(opts.config),
1642
1660
  emitEngineError: opts.emitEngineError,
1643
1661
  emitLiveState: opts.emitLiveState,
@@ -1720,6 +1738,17 @@ var LiveEngine = class {
1720
1738
  setCommentary(commentary) {
1721
1739
  this.liveSummaryRequester.setCommentary(commentary);
1722
1740
  }
1741
+ /**
1742
+ * 热替换 ImageRenderer 实例。adapter 在 image 服务上下线时调用。子组件 (词云 /
1743
+ * room-context / 卡片渲染) 都通过共享 provider 现取,这里只需更新单一 state。
1744
+ *
1745
+ * 主要给 koishi adapter 用 —— sibling service (-image) 启停时通过 ctx.inject
1746
+ * 后置注入。独立端 imageRenderer 是 engine 同进程一次性 wire,不会动态消失,
1747
+ * 不需要调用本方法 (cardStyle 热更走 imageRenderer.updateConfig)。
1748
+ */
1749
+ setImageRenderer(imageRenderer) {
1750
+ this.currentImageRenderer = imageRenderer;
1751
+ }
1723
1752
  /** Final dispose; the engine instance must not be reused after this. */
1724
1753
  stop() {
1725
1754
  this.listener.disposeAll();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bilibili-notify/live",
3
- "version": "0.0.1-alpha.2",
3
+ "version": "0.1.0-alpha.4",
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
34
  "@bilibili-notify/image": "^0.0.1-alpha.2",
36
- "@bilibili-notify/internal": "^0.1.0-alpha.2"
35
+ "@bilibili-notify/api": "^0.2.0-alpha.2",
36
+ "@bilibili-notify/internal": "^0.1.0-alpha.3"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/luxon": "^3.4.2",