@bilibili-notify/live 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/index.cjs +1809 -0
- package/lib/index.d.cts +1002 -0
- package/lib/index.d.mts +1002 -0
- package/lib/index.mjs +1764 -0
- package/package.json +50 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
import { cut } from "jieba-wasm";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { GuardLevel, startListen } from "blive-message-listener";
|
|
5
|
+
import { DateTime } from "luxon";
|
|
6
|
+
import protobuf from "protobufjs";
|
|
7
|
+
//#region src/danmaku-collector.ts
|
|
8
|
+
/**
|
|
9
|
+
* Per-room danmaku buffer powering the wordcloud + live-summary post-processing.
|
|
10
|
+
*
|
|
11
|
+
* - `recordDanmaku(roomId, content, username)` segments the danmaku via jieba
|
|
12
|
+
* and updates both word-frequency and per-user count maps for that room.
|
|
13
|
+
* - `snapshot(roomId)` returns the sorted word list + raw sender map for
|
|
14
|
+
* passing to {@link WordcloudGenerator} / {@link LiveSummaryRequester}.
|
|
15
|
+
* - `clear(roomId)` is invoked at live-end after the wordcloud + summary have
|
|
16
|
+
* been dispatched (or the start of a new live session for that room).
|
|
17
|
+
*
|
|
18
|
+
* The collector intentionally does NOT decide whether collection is enabled —
|
|
19
|
+
* the listener-manager checks the wordcloud / liveSummary master+target gates
|
|
20
|
+
* before calling `recordDanmaku`. This keeps the collector zero-config.
|
|
21
|
+
*/
|
|
22
|
+
var DanmakuCollector = class {
|
|
23
|
+
/** roomId → { word: count } */
|
|
24
|
+
weightByRoom = /* @__PURE__ */ new Map();
|
|
25
|
+
/** roomId → { username: count } */
|
|
26
|
+
senderByRoom = /* @__PURE__ */ new Map();
|
|
27
|
+
stopwords;
|
|
28
|
+
constructor(stopwords) {
|
|
29
|
+
this.stopwords = new Set(stopwords);
|
|
30
|
+
}
|
|
31
|
+
/** Replace the active stop-word set (called on config update). */
|
|
32
|
+
setStopwords(stopwords) {
|
|
33
|
+
this.stopwords.clear();
|
|
34
|
+
for (const w of stopwords) this.stopwords.add(w);
|
|
35
|
+
}
|
|
36
|
+
/** Make sure a room is being tracked (called when listener starts). */
|
|
37
|
+
registerRoom(roomId) {
|
|
38
|
+
if (!this.weightByRoom.has(roomId)) this.weightByRoom.set(roomId, {});
|
|
39
|
+
if (!this.senderByRoom.has(roomId)) this.senderByRoom.set(roomId, {});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Tokenise an incoming danmaku and update word-frequency + per-user count.
|
|
43
|
+
* Words shorter than 2 characters or in the stop-word set are dropped.
|
|
44
|
+
*/
|
|
45
|
+
recordDanmaku(roomId, content, username) {
|
|
46
|
+
this.registerRoom(roomId);
|
|
47
|
+
const wordRecord = this.weightByRoom.get(roomId);
|
|
48
|
+
const senderRecord = this.senderByRoom.get(roomId);
|
|
49
|
+
if (!wordRecord || !senderRecord) return;
|
|
50
|
+
cut(content, true).filter((word) => word.length >= 2 && !this.stopwords.has(word)).forEach((w) => {
|
|
51
|
+
wordRecord[w] = (wordRecord[w] || 0) + 1;
|
|
52
|
+
});
|
|
53
|
+
senderRecord[username] = (senderRecord[username] || 0) + 1;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read a sorted snapshot of the current buffer for a room.
|
|
57
|
+
*
|
|
58
|
+
* - `sortedWords`: descending by frequency.
|
|
59
|
+
* - `senderRecord`: raw username → count map (consumer decides ordering).
|
|
60
|
+
* - `senderCount`: number of distinct usernames.
|
|
61
|
+
* - `danmakuCount`: total danmaku recorded.
|
|
62
|
+
*/
|
|
63
|
+
snapshot(roomId) {
|
|
64
|
+
const weights = this.weightByRoom.get(roomId) ?? {};
|
|
65
|
+
const senders = this.senderByRoom.get(roomId) ?? {};
|
|
66
|
+
const sortedWords = Object.entries(weights).sort((a, b) => b[1] - a[1]);
|
|
67
|
+
const senderCount = Object.keys(senders).length;
|
|
68
|
+
const danmakuCount = Object.values(senders).reduce((sum, val) => sum + val, 0);
|
|
69
|
+
return {
|
|
70
|
+
sortedWords,
|
|
71
|
+
senderRecord: { ...senders },
|
|
72
|
+
senderCount,
|
|
73
|
+
danmakuCount
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Drop all collected data for a room (called at live-end / room-stop). */
|
|
77
|
+
clear(roomId) {
|
|
78
|
+
this.weightByRoom.delete(roomId);
|
|
79
|
+
this.senderByRoom.delete(roomId);
|
|
80
|
+
}
|
|
81
|
+
/** Drop everything (called on engine stop / auth-lost). */
|
|
82
|
+
clearAll() {
|
|
83
|
+
this.weightByRoom.clear();
|
|
84
|
+
this.senderByRoom.clear();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/push-like.ts
|
|
89
|
+
/** Push category enum — numeric values are the historical bilibili-notify push-type codes. */
|
|
90
|
+
let LivePushType = /* @__PURE__ */ function(LivePushType) {
|
|
91
|
+
LivePushType[LivePushType["Live"] = 0] = "Live";
|
|
92
|
+
LivePushType[LivePushType["StartBroadcasting"] = 3] = "StartBroadcasting";
|
|
93
|
+
LivePushType[LivePushType["LiveGuardBuy"] = 4] = "LiveGuardBuy";
|
|
94
|
+
/** 历史上承载词云+总结合包推送;现在仅用于词云,总结走 {@link LiveSummary}。 */
|
|
95
|
+
LivePushType[LivePushType["WordCloudAndLiveSummary"] = 5] = "WordCloudAndLiveSummary";
|
|
96
|
+
LivePushType[LivePushType["Superchat"] = 6] = "Superchat";
|
|
97
|
+
LivePushType[LivePushType["UserDanmakuMsg"] = 7] = "UserDanmakuMsg";
|
|
98
|
+
LivePushType[LivePushType["UserActions"] = 8] = "UserActions";
|
|
99
|
+
LivePushType[LivePushType["LiveEnd"] = 9] = "LiveEnd";
|
|
100
|
+
LivePushType[LivePushType["LiveSummary"] = 10] = "LiveSummary";
|
|
101
|
+
return LivePushType;
|
|
102
|
+
}({});
|
|
103
|
+
/**
|
|
104
|
+
* Subset of `LiveMasterFeature` whose subscription requires an active live-room
|
|
105
|
+
* WebSocket connection. Mirrors `@bilibili-notify/push`'s `LIVE_ROOM_MASTERS`.
|
|
106
|
+
*/
|
|
107
|
+
const LIVE_ROOM_MASTER_KEYS = [
|
|
108
|
+
"live",
|
|
109
|
+
"liveEnd",
|
|
110
|
+
"liveGuardBuy",
|
|
111
|
+
"superchat",
|
|
112
|
+
"wordcloud",
|
|
113
|
+
"liveSummary"
|
|
114
|
+
];
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/room-context.ts
|
|
117
|
+
/** Guard-level → official Bilibili captain/supervisor/governor image URLs. */
|
|
118
|
+
const GUARD_LEVEL_IMG = {
|
|
119
|
+
[GuardLevel.None]: "",
|
|
120
|
+
[GuardLevel.Jianzhang]: "https://s1.hdslb.com/bfs/static/blive/live-pay-mono/relation/relation/assets/captain-Bjw5Byb5.png",
|
|
121
|
+
[GuardLevel.Tidu]: "https://s1.hdslb.com/bfs/static/blive/live-pay-mono/relation/relation/assets/supervisor-u43ElIjU.png",
|
|
122
|
+
[GuardLevel.Zongdu]: "https://s1.hdslb.com/bfs/static/blive/live-pay-mono/relation/relation/assets/governor-DpDXKEdA.png"
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Shared room-level infrastructure surface. Stores all engine-injected deps,
|
|
126
|
+
* the per-room listener registry, and the periodic-timer registry; offers the
|
|
127
|
+
* lifecycle / predicate / disposal primitives consumed by both
|
|
128
|
+
* {@link import("./listener-manager").ListenerManager} and
|
|
129
|
+
* {@link import("./room-session").RoomSession}.
|
|
130
|
+
*
|
|
131
|
+
* The data-fetch / card-render / time-format helpers live in
|
|
132
|
+
* {@link import("./room-helpers").RoomContextHelpers} (a subclass of this
|
|
133
|
+
* class). The split keeps each file focused: this one handles state + WS
|
|
134
|
+
* lifecycle, the helpers file wraps every external API/IO call.
|
|
135
|
+
*/
|
|
136
|
+
var RoomContextBase = class {
|
|
137
|
+
serviceCtx;
|
|
138
|
+
logger;
|
|
139
|
+
api;
|
|
140
|
+
push;
|
|
141
|
+
contentBuilder;
|
|
142
|
+
templateRenderer;
|
|
143
|
+
wordcloudGenerator;
|
|
144
|
+
liveSummaryRequester;
|
|
145
|
+
danmakuCollector;
|
|
146
|
+
/**
|
|
147
|
+
* 真实注入的渲染器引用,private 是因为外部应通过 `imageRenderer` getter 访问 ——
|
|
148
|
+
* 后者会在 `config.imageEnabled === false` 时返回 null,让所有
|
|
149
|
+
* `if (this.imageRenderer?.generateXxx)` 自然落入文字回退分支。
|
|
150
|
+
*/
|
|
151
|
+
_imageRenderer;
|
|
152
|
+
emitEngineError;
|
|
153
|
+
_emitLiveState;
|
|
154
|
+
_emitViewers;
|
|
155
|
+
config;
|
|
156
|
+
listenerRecord = {};
|
|
157
|
+
livePushTimerManager = /* @__PURE__ */ new Map();
|
|
158
|
+
disposed = false;
|
|
159
|
+
/** Cached protobuf type for INTERACT_WORD_V2 decoding (lazy-loaded). */
|
|
160
|
+
interactWord;
|
|
161
|
+
/**
|
|
162
|
+
* Set once the proto load/lookup has failed (missing/invalid
|
|
163
|
+
* `proto/interact_word.proto`) so we degrade gracefully instead of
|
|
164
|
+
* re-attempting + error-spamming on every INTERACT_WORD_V2 frame.
|
|
165
|
+
*/
|
|
166
|
+
interactWordUnavailable = false;
|
|
167
|
+
instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
168
|
+
constructor(opts) {
|
|
169
|
+
this.serviceCtx = opts.serviceCtx;
|
|
170
|
+
this.logger = opts.serviceCtx.logger;
|
|
171
|
+
this.api = opts.api;
|
|
172
|
+
this.push = opts.push;
|
|
173
|
+
this.contentBuilder = opts.contentBuilder;
|
|
174
|
+
this.templateRenderer = opts.templateRenderer;
|
|
175
|
+
this.wordcloudGenerator = opts.wordcloudGenerator;
|
|
176
|
+
this.liveSummaryRequester = opts.liveSummaryRequester;
|
|
177
|
+
this.danmakuCollector = opts.danmakuCollector;
|
|
178
|
+
this._imageRenderer = opts.imageRenderer;
|
|
179
|
+
this.config = opts.config;
|
|
180
|
+
this.emitEngineError = opts.emitEngineError;
|
|
181
|
+
this._emitLiveState = opts.emitLiveState;
|
|
182
|
+
this._emitViewers = opts.emitViewers;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 安全调用方:adapter 未注入时静默 no-op,业务代码无需在调用点判空。
|
|
186
|
+
*/
|
|
187
|
+
emitLiveState(uid, status) {
|
|
188
|
+
this._emitLiveState?.(uid, status);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 同型 no-op 安全调用方。room-session 已做 per-UID 节流,这里只是分发。
|
|
192
|
+
*/
|
|
193
|
+
emitViewers(uid, viewers) {
|
|
194
|
+
this._emitViewers?.(uid, viewers);
|
|
195
|
+
}
|
|
196
|
+
/** 受 `config.imageEnabled` 门控的渲染器视图;关闭时返回 null。 */
|
|
197
|
+
get imageRenderer() {
|
|
198
|
+
return this.config.imageEnabled === false ? null : this._imageRenderer;
|
|
199
|
+
}
|
|
200
|
+
updateConfig(config) {
|
|
201
|
+
this.config = config;
|
|
202
|
+
}
|
|
203
|
+
isDisposed() {
|
|
204
|
+
return this.disposed;
|
|
205
|
+
}
|
|
206
|
+
setDisposed(value) {
|
|
207
|
+
this.disposed = value;
|
|
208
|
+
}
|
|
209
|
+
getListenerCount() {
|
|
210
|
+
return Object.keys(this.listenerRecord).length;
|
|
211
|
+
}
|
|
212
|
+
logSideEffectState(stage) {
|
|
213
|
+
this.logger.debug(`[conn] [live:${this.instanceId}] ${stage} listeners=${this.getListenerCount()} timers=${this.livePushTimerManager.size} disposed=${this.disposed}`);
|
|
214
|
+
}
|
|
215
|
+
hasTargets(sub, ...types) {
|
|
216
|
+
return types.some((t) => (sub.target?.[t]?.length ?? 0) > 0);
|
|
217
|
+
}
|
|
218
|
+
isSubscribed(sub, type) {
|
|
219
|
+
return sub[type];
|
|
220
|
+
}
|
|
221
|
+
needsLiveMonitor(sub) {
|
|
222
|
+
return LIVE_ROOM_MASTER_KEYS.some((k) => this.isSubscribed(sub, k)) || sub.customSpecialDanmakuUsers.enable || sub.customSpecialUsersEnterTheRoom.enable;
|
|
223
|
+
}
|
|
224
|
+
closeListener(roomId) {
|
|
225
|
+
const listener = this.listenerRecord[roomId];
|
|
226
|
+
if (!listener) {
|
|
227
|
+
this.logger.debug(`[conn] 直播间 [${roomId}] 连接不存在,跳过关闭`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (listener.closed) {
|
|
231
|
+
this.logger.debug(`[conn] 直播间 [${roomId}] 连接已被远端断开`);
|
|
232
|
+
delete this.listenerRecord[roomId];
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
listener.close();
|
|
236
|
+
delete this.listenerRecord[roomId];
|
|
237
|
+
this.logger.info(`[conn] 直播间 [${roomId}] 连接已关闭`);
|
|
238
|
+
this.logSideEffectState(`listener:closed room=${roomId}`);
|
|
239
|
+
}
|
|
240
|
+
clearListeners() {
|
|
241
|
+
this.logSideEffectState("listeners:before-clear");
|
|
242
|
+
for (const key of Object.keys(this.listenerRecord)) this.closeListener(key);
|
|
243
|
+
this.logSideEffectState("listeners:after-clear");
|
|
244
|
+
}
|
|
245
|
+
clearPushTimers() {
|
|
246
|
+
this.logSideEffectState("timers:before-clear");
|
|
247
|
+
for (const [, timer] of this.livePushTimerManager) timer?.();
|
|
248
|
+
this.livePushTimerManager.clear();
|
|
249
|
+
this.logSideEffectState("timers:after-clear");
|
|
250
|
+
}
|
|
251
|
+
stopMonitoring(reason, roomId) {
|
|
252
|
+
if (roomId) {
|
|
253
|
+
this.logger.error(`[conn] [${roomId}] ${reason},已停止该房间的监测`);
|
|
254
|
+
this.closeListener(roomId);
|
|
255
|
+
const timer = this.livePushTimerManager.get(roomId);
|
|
256
|
+
if (timer) {
|
|
257
|
+
timer();
|
|
258
|
+
this.livePushTimerManager.delete(roomId);
|
|
259
|
+
}
|
|
260
|
+
this.emitEngineError(`[${roomId}] ${reason}`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.logger.error(`[conn] ${reason},直播监测已停止`);
|
|
264
|
+
this.clearListeners();
|
|
265
|
+
this.clearPushTimers();
|
|
266
|
+
this.emitEngineError(reason);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/types.ts
|
|
271
|
+
let LiveType = /* @__PURE__ */ function(LiveType) {
|
|
272
|
+
LiveType[LiveType["NotLiveBroadcast"] = 0] = "NotLiveBroadcast";
|
|
273
|
+
LiveType[LiveType["StartBroadcasting"] = 1] = "StartBroadcasting";
|
|
274
|
+
LiveType[LiveType["LiveBroadcast"] = 2] = "LiveBroadcast";
|
|
275
|
+
LiveType[LiveType["StopBroadcast"] = 3] = "StopBroadcast";
|
|
276
|
+
LiveType[LiveType["FirstLiveBroadcast"] = 4] = "FirstLiveBroadcast";
|
|
277
|
+
return LiveType;
|
|
278
|
+
}({});
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/room-helpers.ts
|
|
281
|
+
/**
|
|
282
|
+
* Extends {@link RoomContextBase} with the data-fetch / card-render /
|
|
283
|
+
* time-format helpers — every call here either hits the Bilibili HTTP API or
|
|
284
|
+
* the optional `ImageRenderer`. Keeping them on a separate class keeps the
|
|
285
|
+
* base file focused on state / lifecycle while preserving the inheritance
|
|
286
|
+
* chain so {@link RoomSession} sees a single `ctx.foo()` API surface.
|
|
287
|
+
*/
|
|
288
|
+
var RoomContext = class extends RoomContextBase {
|
|
289
|
+
/**
|
|
290
|
+
* Bring up the WebSocket listener for `roomId`.
|
|
291
|
+
*
|
|
292
|
+
* L4: returns `true` iff there is an active listener for the room *after*
|
|
293
|
+
* this call — either freshly created OR already present (the latter lets a
|
|
294
|
+
* reconnect that races with a backoff-window restore treat the room as
|
|
295
|
+
* recovered). Returns `false` on every failure mode so the reconnect caller
|
|
296
|
+
* only resets its backoff on a real success instead of the old
|
|
297
|
+
* void-swallow that recorded "reconnected" with no listener attached.
|
|
298
|
+
*/
|
|
299
|
+
async startLiveRoomListener(roomId, handler, shouldAbort) {
|
|
300
|
+
const aborted = () => this.isDisposed() || shouldAbort?.() === true;
|
|
301
|
+
if (aborted()) return false;
|
|
302
|
+
const roomIdNum = Number.parseInt(roomId, 10);
|
|
303
|
+
if (!Number.isFinite(roomIdNum) || roomIdNum <= 0) {
|
|
304
|
+
this.logger.error(`[conn] roomId 非法("${roomId}"),跳过 listener 创建。请检查订阅配置或用户是否开通直播间`);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (this.listenerRecord[roomId]) {
|
|
308
|
+
this.logger.warn(`[conn] 直播间 [${roomId}] 连接已存在,跳过创建`);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
const cookiesStr = this.api.getCookiesHeader();
|
|
312
|
+
let mySelfInfo;
|
|
313
|
+
try {
|
|
314
|
+
mySelfInfo = await this.api.getMyselfInfo();
|
|
315
|
+
} catch (e) {
|
|
316
|
+
const message = e.message ?? String(e);
|
|
317
|
+
this.logger.warn(`[conn] 获取个人信息异常,房间 [${roomId}]:${message}`);
|
|
318
|
+
this.emitEngineError(`[${roomId}] 获取个人信息异常:${message}`);
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
if (mySelfInfo.code !== 0 || !mySelfInfo.data) {
|
|
322
|
+
this.logger.warn(`[conn] 获取个人信息失败 code=${mySelfInfo.code},无法创建直播间 [${roomId}] 连接`);
|
|
323
|
+
this.emitEngineError(`[${roomId}] 获取个人信息失败 code=${mySelfInfo.code}`);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
if (aborted()) return false;
|
|
327
|
+
const listener = startListen(roomIdNum, handler, { ws: {
|
|
328
|
+
headers: { Cookie: cookiesStr },
|
|
329
|
+
uid: mySelfInfo.data.mid
|
|
330
|
+
} });
|
|
331
|
+
if (aborted()) {
|
|
332
|
+
listener.close();
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
this.listenerRecord[roomId] = listener;
|
|
336
|
+
this.logger.info(`[conn] 直播间 [${roomId}] 连接已建立`);
|
|
337
|
+
this.logSideEffectState(`listener:created room=${roomId}`);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
/** Fetch live-room info; on failure, notifies admin + tears down this room. */
|
|
341
|
+
async getLiveRoomInfo(roomId) {
|
|
342
|
+
try {
|
|
343
|
+
return (await this.api.getLiveRoomInfo(roomId)).data;
|
|
344
|
+
} catch (e) {
|
|
345
|
+
this.logger.error(`[conn] 获取直播间信息失败:${e.message}`);
|
|
346
|
+
await this.push.sendPrivateMsg(`获取直播间 [${roomId}] 信息失败:${e.message},已停止该房间监测`);
|
|
347
|
+
this.stopMonitoring("获取直播间信息失败", roomId);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Fetch + project a `MasterInfo` snapshot. Carries forward `liveOpenFollowerNum`
|
|
353
|
+
* across mid-session refreshes so that the live-end card reports an accurate
|
|
354
|
+
* follower delta.
|
|
355
|
+
*/
|
|
356
|
+
async getMasterInfo(uid, previous, liveType) {
|
|
357
|
+
const data = (await this.api.getMasterInfo(uid)).data;
|
|
358
|
+
let liveOpenFollowerNum;
|
|
359
|
+
let liveEndFollowerNum;
|
|
360
|
+
let liveFollowerChange;
|
|
361
|
+
if (liveType === 1 || liveType === 4) {
|
|
362
|
+
liveOpenFollowerNum = data.follower_num;
|
|
363
|
+
liveEndFollowerNum = data.follower_num;
|
|
364
|
+
liveFollowerChange = 0;
|
|
365
|
+
} else {
|
|
366
|
+
liveOpenFollowerNum = previous?.liveOpenFollowerNum ?? data.follower_num;
|
|
367
|
+
liveEndFollowerNum = data.follower_num;
|
|
368
|
+
liveFollowerChange = liveEndFollowerNum - liveOpenFollowerNum;
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
username: data.info.uname,
|
|
372
|
+
userface: data.info.face,
|
|
373
|
+
roomId: data.room_id,
|
|
374
|
+
liveOpenFollowerNum,
|
|
375
|
+
liveEndFollowerNum,
|
|
376
|
+
liveFollowerChange,
|
|
377
|
+
medalName: data.medal_name
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/** Fire-and-forget push wrapper; logs + drops any rejection. */
|
|
381
|
+
safeBroadcast(uid, content, type) {
|
|
382
|
+
this.push.broadcastToTargets(uid, content, type).catch((e) => {
|
|
383
|
+
this.logger.error(`[push] 推送失败 uid=${uid} type=${type}:${e.message}`);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Push a "live start / live ongoing / live end" notification card. Generates
|
|
388
|
+
* an image via {@link ImageRenderer.generateLiveCard} when available; falls
|
|
389
|
+
* back to plain text on failure.
|
|
390
|
+
*/
|
|
391
|
+
async sendLiveNotifyCard(params) {
|
|
392
|
+
const { liveType, liveData, liveRoomInfo, master, cardStyle, uid, notifyMsg } = params;
|
|
393
|
+
let buffer;
|
|
394
|
+
if (this.imageRenderer?.generateLiveCard) try {
|
|
395
|
+
buffer = await this.imageRenderer.generateLiveCard(liveRoomInfo, master.username, master.userface, liveData, liveType, cardStyle?.enable ? cardStyle : void 0);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
this.logger.error(`[image] 生成直播图片失败:${e.message},降级为文字推送`);
|
|
398
|
+
}
|
|
399
|
+
if (this.isDisposed()) return;
|
|
400
|
+
const pushType = liveType === 1 ? 3 : liveType === 3 ? 9 : 0;
|
|
401
|
+
if (!buffer) {
|
|
402
|
+
this.logger.debug(`[push] [${master.username}] 无图片,降级为文字推送`);
|
|
403
|
+
const fallbackMsg = this.contentBuilder.message([this.contentBuilder.text(notifyMsg || `直播通知 - ${master.username}`)]);
|
|
404
|
+
await this.push.broadcastToTargets(uid, fallbackMsg, pushType);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const msg = this.contentBuilder.message([this.contentBuilder.image(buffer, "image/jpeg"), this.contentBuilder.text(notifyMsg || "")]);
|
|
408
|
+
await this.push.broadcastToTargets(uid, msg, pushType);
|
|
409
|
+
}
|
|
410
|
+
/** Format `dateString` (yyyy-MM-dd HH:mm:ss UTC+8) as elapsed-time text. */
|
|
411
|
+
async getTimeDifference(dateString) {
|
|
412
|
+
if (this.imageRenderer?.getTimeDifference) return this.imageRenderer.getTimeDifference(dateString);
|
|
413
|
+
const start = DateTime.fromFormat(dateString, "yyyy-MM-dd HH:mm:ss");
|
|
414
|
+
const diff = DateTime.now().diff(start, ["hours", "minutes"]);
|
|
415
|
+
const hours = Math.floor(diff.hours);
|
|
416
|
+
const minutes = Math.floor(diff.minutes % 60);
|
|
417
|
+
return hours > 0 ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Decode a base64-encoded INTERACT_WORD_V2 protobuf payload.
|
|
421
|
+
*
|
|
422
|
+
* P0-4: 此前用 `resolve(__dirname, "./proto/interact_word.proto")` —— (a) 该
|
|
423
|
+
* .proto 文件从未随包提交、tsdown 也不拷进 lib;(b) ESM(.mjs)产物里裸
|
|
424
|
+
* `__dirname` 为 undefined。两者叠加导致每个 INTERACT_WORD_V2 帧必抛,
|
|
425
|
+
* "特别关注用户进房"特性在双端构建里全死。
|
|
426
|
+
*
|
|
427
|
+
* 现:`__dirname` 改 `import.meta.url`(与 routes/health.ts 同款,tsdown
|
|
428
|
+
* cjs/esm 双产物都正确);proto 缺失/损坏时**优雅降级**——只在首次告警一
|
|
429
|
+
* 次并置 `interactWordUnavailable`,后续帧直接返回 `{}`(调用方
|
|
430
|
+
* onInteractWordV2 对空对象天然 no-op:`msgType==="1"` 为 false,零误推),
|
|
431
|
+
* 不再每帧崩/刷屏。
|
|
432
|
+
*
|
|
433
|
+
* 注:让该特性真正可用仍需在 `src/proto/interact_word.proto` 放入**经核实
|
|
434
|
+
* 的**权威 schema(`bilibili.live.xuserreward.v1.InteractWord`)并在打包时
|
|
435
|
+
* 拷进 `lib/proto/`——字段号必须来自可信源,不可臆造,故作为独立后续任务。
|
|
436
|
+
*/
|
|
437
|
+
async decodeBase64PB(base64) {
|
|
438
|
+
if (this.interactWordUnavailable) return {};
|
|
439
|
+
if (!this.interactWord) try {
|
|
440
|
+
const protoPath = resolve(dirname(fileURLToPath(import.meta.url)), "./proto/interact_word.proto");
|
|
441
|
+
const root = await protobuf.load(protoPath);
|
|
442
|
+
this.interactWord = root.lookupType("bilibili.live.xuserreward.v1.InteractWord");
|
|
443
|
+
} catch (e) {
|
|
444
|
+
this.interactWordUnavailable = true;
|
|
445
|
+
this.logger.warn(`[live] INTERACT_WORD_V2 解码不可用,"特别关注用户进房"已禁用:缺少或无法加载 proto/interact_word.proto (${e.message})`);
|
|
446
|
+
return {};
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const buffer = Uint8Array.from(Buffer.from(base64, "base64"));
|
|
450
|
+
const message = this.interactWord.decode(buffer);
|
|
451
|
+
return this.interactWord.toObject(message, {
|
|
452
|
+
longs: String,
|
|
453
|
+
enums: String,
|
|
454
|
+
defaults: true
|
|
455
|
+
});
|
|
456
|
+
} catch (e) {
|
|
457
|
+
this.logger.warn(`[live] INTERACT_WORD_V2 protobuf 解码失败,跳过该帧: ${e.message}`);
|
|
458
|
+
return {};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/template-renderer.ts
|
|
464
|
+
/**
|
|
465
|
+
* Plain string-substitution based template renderer for live-related notification
|
|
466
|
+
* text. Mirrors the per-occurrence templates supported in the koishi schema:
|
|
467
|
+
*
|
|
468
|
+
* - `customLiveStart` / `customLive` / `customLiveEnd`
|
|
469
|
+
* - `customGuardBuy.guardBuyMsg`
|
|
470
|
+
* - `customSpecialDanmakuUsers.msgTemplate`
|
|
471
|
+
* - `customSpecialUsersEnterTheRoom.msgTemplate`
|
|
472
|
+
*
|
|
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.
|
|
477
|
+
*/
|
|
478
|
+
/** Defaults applied when neither sub-level nor global config provides a template. */
|
|
479
|
+
const DEFAULT_LIVE_TEMPLATES = {
|
|
480
|
+
liveStart: "-name 开播啦,当前粉丝数:-follower\n-link",
|
|
481
|
+
liveOngoing: "-name 正在直播,已播 -time,累计观看:-watched\n-link",
|
|
482
|
+
liveEnd: "-name 下播啦,本次直播了 -time,粉丝变化 -follower_change",
|
|
483
|
+
liveSummaryFallback: "弹幕总结"
|
|
484
|
+
};
|
|
485
|
+
function escapeRegExp(s) {
|
|
486
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* 单遍替换所有变量 token,再把 `\n` 转义展开为真换行。
|
|
490
|
+
*
|
|
491
|
+
* P2:此前 `for…replaceAll` 顺序替换有两个缺陷 ——
|
|
492
|
+
* 1. **token 注入**:用户可控值(uname / 弹幕内容)含 `-link`/`-time` 时
|
|
493
|
+
* 会被后续轮次再次替换;
|
|
494
|
+
* 2. **前缀吞噬**:`-follower` 先于 `-follower_change` 替换,把后者的
|
|
495
|
+
* `-follower` 段吃掉只剩 `_change`。
|
|
496
|
+
* 改为基于原始模板的**单遍正则**:token 按长度降序进 alternation(最长优先
|
|
497
|
+
* 匹配),每个 token 恰好替换一次且替换值不再被回扫 → 杜绝注入与吞噬。
|
|
498
|
+
*/
|
|
499
|
+
function applyTemplate(template, vars) {
|
|
500
|
+
const keys = Object.keys(vars).sort((a, b) => b.length - a.length);
|
|
501
|
+
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");
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Format follower-change as a signed magnitude string with a 1万 (10K) cutoff,
|
|
507
|
+
* mirroring live-service's inline formatting.
|
|
508
|
+
*/
|
|
509
|
+
function formatFollowerChange(n) {
|
|
510
|
+
if (n > 0) return n >= 1e4 ? `+${(n / 1e4).toFixed(1)}万` : `+${n}`;
|
|
511
|
+
if (n <= -1e4) return `${(n / 1e4).toFixed(1)}万`;
|
|
512
|
+
return n.toString();
|
|
513
|
+
}
|
|
514
|
+
/** Format follower count, abbreviating ≥10K to `X.X万`. */
|
|
515
|
+
function formatFollowerCount(n) {
|
|
516
|
+
return n >= 1e4 ? `${(n / 1e4).toFixed(1)}万` : n.toString();
|
|
517
|
+
}
|
|
518
|
+
/** Build the canonical room link from `LiveRoomInfo.data` style fields. */
|
|
519
|
+
function buildRoomLink(info) {
|
|
520
|
+
return `https://live.bilibili.com/${info.short_id === 0 ? info.room_id : info.short_id}`;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Resolve the effective template string for a sub at a given occurrence,
|
|
524
|
+
* preferring per-sub override → global config → built-in default.
|
|
525
|
+
*/
|
|
526
|
+
function resolveCustomLive(subCustom, globalCustom, field, fallback) {
|
|
527
|
+
return subCustom[field] ?? globalCustom?.[field] ?? fallback;
|
|
528
|
+
}
|
|
529
|
+
var LiveTemplateRenderer = class {
|
|
530
|
+
/** Compose the "开播" notification text for a sub. */
|
|
531
|
+
renderLiveStart(params) {
|
|
532
|
+
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
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/** Compose the periodic "正在直播" notification text. */
|
|
540
|
+
renderLiveOngoing(params) {
|
|
541
|
+
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
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
/** Compose the "下播" notification text. */
|
|
549
|
+
renderLiveEnd(params) {
|
|
550
|
+
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)
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Compose the "上舰" notification text using the effective custom-guard
|
|
558
|
+
* config (resolved by listener-manager).
|
|
559
|
+
*/
|
|
560
|
+
renderGuardBuy(params) {
|
|
561
|
+
return applyTemplate(params.guardBuyConfig.guardBuyMsg ?? "", {
|
|
562
|
+
"-uname": params.uname,
|
|
563
|
+
"-mname": params.master?.username ?? "",
|
|
564
|
+
"-guard": params.giftName
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/** Compose the "特别关注弹幕" notification text. */
|
|
568
|
+
renderSpecialDanmaku(params) {
|
|
569
|
+
return applyTemplate(params.template, {
|
|
570
|
+
"-mastername": params.master?.username ?? "",
|
|
571
|
+
"-uname": params.uname,
|
|
572
|
+
"-msg": params.content
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/** Compose the "特别关注进入直播间" notification text. */
|
|
576
|
+
renderSpecialUserEnter(params) {
|
|
577
|
+
return applyTemplate(params.template, {
|
|
578
|
+
"-mastername": params.master?.username ?? "",
|
|
579
|
+
"-uname": params.uname
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Compose the templated "弹幕总结" text used as the fallback when AI
|
|
584
|
+
* summarisation is unavailable. Variables: `-dmc` (sender count), `-mdn`
|
|
585
|
+
* (master medal name), `-dca` (total danmaku), `-un1..5` (top usernames),
|
|
586
|
+
* `-dc1..5` (top counts).
|
|
587
|
+
*/
|
|
588
|
+
renderLiveSummary(params) {
|
|
589
|
+
const top = params.topSenders;
|
|
590
|
+
const at = (i) => top[i] ?? ["", 0];
|
|
591
|
+
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]}`
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/room-session-base.ts
|
|
610
|
+
/**
|
|
611
|
+
* Cooldown window between accepting `onLiveStart` / `onLiveEnd` events; the
|
|
612
|
+
* Bilibili WS sometimes fires duplicates for the same transition.
|
|
613
|
+
*/
|
|
614
|
+
const LIVE_EVENT_COOLDOWN = 10 * 1e3;
|
|
615
|
+
/**
|
|
616
|
+
* Base class for {@link import("./room-session").RoomSession}, holding all
|
|
617
|
+
* per-room mutable state and the high-level lifecycle / transition logic
|
|
618
|
+
* (bootstrap, periodic-timer arm/cancel, live-end pipeline). Event handlers
|
|
619
|
+
* (`onLiveStart`, `onIncomeSuperChat`, etc.) live in the subclass.
|
|
620
|
+
*
|
|
621
|
+
* State fields are `protected` so the subclass can read & mutate them
|
|
622
|
+
* directly when handling MsgHandler events.
|
|
623
|
+
*/
|
|
624
|
+
var RoomSessionBase = class {
|
|
625
|
+
ctx;
|
|
626
|
+
sub;
|
|
627
|
+
liveTime;
|
|
628
|
+
liveStatus = false;
|
|
629
|
+
liveRoomInfo;
|
|
630
|
+
masterInfo;
|
|
631
|
+
liveData = { likedNum: "0" };
|
|
632
|
+
pushAtTimeTimer = null;
|
|
633
|
+
lastLiveStart = 0;
|
|
634
|
+
lastLiveEnd = 0;
|
|
635
|
+
constructor(ctx, sub) {
|
|
636
|
+
this.ctx = ctx;
|
|
637
|
+
this.sub = sub;
|
|
638
|
+
}
|
|
639
|
+
/** Whether the underlying B-station room is currently broadcasting. */
|
|
640
|
+
get isLive() {
|
|
641
|
+
return this.liveStatus;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* 唯一允许翻转 `liveStatus` 的入口。只在真实 transition 时通过 RoomContext
|
|
645
|
+
* 推送 `live-state-changed` 事件,前端的"正在直播"面板靠它实时收敛。
|
|
646
|
+
* 直接赋值 `this.liveStatus = ...` 会绕过这里,**不要这样做**。
|
|
647
|
+
*/
|
|
648
|
+
setLiveStatus(next) {
|
|
649
|
+
if (this.liveStatus === next) return;
|
|
650
|
+
this.liveStatus = next;
|
|
651
|
+
this.ctx.emitLiveState(this.sub.uid, next ? "live" : "idle");
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Read-only diagnostic snapshot for routes / dashboards. Includes `uid`,
|
|
655
|
+
* `roomId`, and — when `liveRoomInfo` was successfully fetched — `title`,
|
|
656
|
+
* `cover`, `areaName`, `startedAt`. Returns undefined fields rather than
|
|
657
|
+
* partial data so consumers can render fallbacks deterministically.
|
|
658
|
+
*/
|
|
659
|
+
getLiveSnapshot() {
|
|
660
|
+
const w = this.liveData.watchedNum;
|
|
661
|
+
const viewers = typeof w === "number" ? String(w) : w;
|
|
662
|
+
return {
|
|
663
|
+
uid: this.sub.uid,
|
|
664
|
+
roomId: this.sub.roomId,
|
|
665
|
+
isLive: this.liveStatus,
|
|
666
|
+
title: this.liveRoomInfo?.title,
|
|
667
|
+
cover: this.liveRoomInfo?.user_cover || this.liveRoomInfo?.keyframe || void 0,
|
|
668
|
+
areaName: this.liveRoomInfo?.area_name,
|
|
669
|
+
startedAt: this.liveRoomInfo?.live_time || void 0,
|
|
670
|
+
viewers: viewers && viewers !== "暂未获取到" ? viewers : void 0
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Open the WS connection (via `RoomContext.startLiveRoomListener`), pull
|
|
675
|
+
* the initial live-room snapshot, and — if the room is already live —
|
|
676
|
+
* kick off the `restartPush` branch + arm the periodic timer.
|
|
677
|
+
*/
|
|
678
|
+
async bootstrap() {
|
|
679
|
+
if (!await this.ctx.startLiveRoomListener(this.sub.roomId, this.buildHandler())) {
|
|
680
|
+
await this.ctx.push.sendPrivateMsg(`直播间 [${this.sub.roomId}] 弹幕连接建立失败,已停止该房间监测`);
|
|
681
|
+
this.ctx.closeListener(this.sub.roomId);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (!await this.useLiveRoomInfo(4) || !await this.useMasterInfo(4) || !this.liveRoomInfo || !this.masterInfo) {
|
|
685
|
+
await this.ctx.push.sendPrivateMsg("获取直播间信息失败,启动直播间弹幕检测失败");
|
|
686
|
+
this.ctx.closeListener(this.sub.roomId);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
this.ctx.logger.debug(`[stat] 当前粉丝数:${this.masterInfo.liveOpenFollowerNum}`);
|
|
690
|
+
if (this.liveRoomInfo.live_status === 1) {
|
|
691
|
+
this.liveTime = this.liveRoomInfo.live_time;
|
|
692
|
+
const watched = String(this.liveData.watchedNum ?? "暂未获取到");
|
|
693
|
+
this.liveData.watchedNum = watched;
|
|
694
|
+
const diffTime = await this.ctx.getTimeDifference(this.liveTime);
|
|
695
|
+
const roomLink = buildRoomLink(this.liveRoomInfo);
|
|
696
|
+
const liveMsg = this.ctx.templateRenderer.renderLiveOngoing({
|
|
697
|
+
sub: this.sub,
|
|
698
|
+
globalCustom: this.ctx.config.customLiveMsg,
|
|
699
|
+
master: this.masterInfo,
|
|
700
|
+
diffTime,
|
|
701
|
+
watched,
|
|
702
|
+
roomLink
|
|
703
|
+
});
|
|
704
|
+
if (this.sub.restartPush ?? this.ctx.config.restartPush) await this.ctx.sendLiveNotifyCard({
|
|
705
|
+
liveType: 2,
|
|
706
|
+
liveData: this.liveData,
|
|
707
|
+
liveRoomInfo: this.liveRoomInfo,
|
|
708
|
+
master: this.masterInfo,
|
|
709
|
+
cardStyle: this.sub.customCardStyle,
|
|
710
|
+
uid: this.sub.uid,
|
|
711
|
+
notifyMsg: liveMsg
|
|
712
|
+
});
|
|
713
|
+
this.setLiveStatus(true);
|
|
714
|
+
this.armPeriodicTimer();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async useLiveRoomInfo(liveType) {
|
|
718
|
+
const data = await this.ctx.getLiveRoomInfo(this.sub.roomId);
|
|
719
|
+
if (!data?.uid) return false;
|
|
720
|
+
if (liveType === 1 || liveType === 4) {
|
|
721
|
+
this.liveRoomInfo = data;
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
this.liveRoomInfo = {
|
|
725
|
+
...data,
|
|
726
|
+
live_time: this.liveRoomInfo?.live_time ?? data.live_time
|
|
727
|
+
};
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
async useMasterInfo(liveType) {
|
|
731
|
+
try {
|
|
732
|
+
this.masterInfo = await this.ctx.getMasterInfo(this.liveRoomInfo?.uid.toString() ?? this.sub.uid, this.masterInfo, liveType);
|
|
733
|
+
return true;
|
|
734
|
+
} catch {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Live 配置 `pushTime` 热更后调用:重新按当前(可能已变更的) `pushTime`
|
|
740
|
+
* arm 定时器。仅对正在直播的房间生效,因为只有 live 状态下才会有 timer。
|
|
741
|
+
*
|
|
742
|
+
* 注意:`setInterval` 句柄的 ms 参数是 immutable,只能 dispose 重建。
|
|
743
|
+
*/
|
|
744
|
+
rearmPeriodicTimer() {
|
|
745
|
+
if (!this.isLive) return;
|
|
746
|
+
this.cancelPeriodicTimer();
|
|
747
|
+
this.armPeriodicTimer();
|
|
748
|
+
}
|
|
749
|
+
armPeriodicTimer() {
|
|
750
|
+
const effPushTime = this.sub.pushTime ?? this.ctx.config.pushTime;
|
|
751
|
+
if (effPushTime === 0 || this.pushAtTimeTimer) return;
|
|
752
|
+
this.pushAtTimeTimer = this.ctx.serviceCtx.setInterval(() => this.tickPushAtTime(), effPushTime * 1e3 * 60 * 60);
|
|
753
|
+
this.ctx.livePushTimerManager.set(this.sub.roomId, () => this.pushAtTimeTimer?.dispose());
|
|
754
|
+
this.ctx.logSideEffectState(`timer:created room=${this.sub.roomId}`);
|
|
755
|
+
}
|
|
756
|
+
cancelPeriodicTimer() {
|
|
757
|
+
if (!this.pushAtTimeTimer) return;
|
|
758
|
+
this.pushAtTimeTimer.dispose();
|
|
759
|
+
this.pushAtTimeTimer = null;
|
|
760
|
+
this.ctx.livePushTimerManager.delete(this.sub.roomId);
|
|
761
|
+
this.ctx.logSideEffectState(`timer:deleted room=${this.sub.roomId}`);
|
|
762
|
+
}
|
|
763
|
+
/** Periodic "正在直播" tick (callback for `setInterval`). */
|
|
764
|
+
async tickPushAtTime() {
|
|
765
|
+
if (!await this.useLiveRoomInfo(2) || !this.liveRoomInfo) {
|
|
766
|
+
this.ctx.stopMonitoring("获取直播间信息失败,推送直播卡片失败", this.sub.roomId);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (this.liveRoomInfo.live_status === 0 || this.liveRoomInfo.live_status === 2) {
|
|
770
|
+
this.ctx.logger.warn(`[live] 直播间 [${this.sub.roomId}] 检测到已下播但未收到 onLiveEnd 事件,进入兜底处理`);
|
|
771
|
+
await this.ctx.push.sendPrivateMsg(`直播间 [${this.sub.roomId}] 已下播但未收到 WS 下播事件,已自动触发兜底总结`);
|
|
772
|
+
await this.handleLiveEnd("polling");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (!await this.useMasterInfo(2) || !this.masterInfo) return;
|
|
776
|
+
this.liveTime = this.liveRoomInfo.live_time;
|
|
777
|
+
const watched = String(this.liveData.watchedNum ?? "暂未获取到");
|
|
778
|
+
this.liveData.watchedNum = watched;
|
|
779
|
+
const diffTime = await this.ctx.getTimeDifference(this.liveTime);
|
|
780
|
+
const roomLink = buildRoomLink(this.liveRoomInfo);
|
|
781
|
+
const liveMsg = this.ctx.templateRenderer.renderLiveOngoing({
|
|
782
|
+
sub: this.sub,
|
|
783
|
+
globalCustom: this.ctx.config.customLiveMsg,
|
|
784
|
+
master: this.masterInfo,
|
|
785
|
+
diffTime,
|
|
786
|
+
watched,
|
|
787
|
+
roomLink
|
|
788
|
+
});
|
|
789
|
+
await this.ctx.sendLiveNotifyCard({
|
|
790
|
+
liveType: 2,
|
|
791
|
+
liveData: this.liveData,
|
|
792
|
+
liveRoomInfo: this.liveRoomInfo,
|
|
793
|
+
master: this.masterInfo,
|
|
794
|
+
cardStyle: this.sub.customCardStyle,
|
|
795
|
+
uid: this.sub.uid,
|
|
796
|
+
notifyMsg: liveMsg
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Live-end pipeline (shared by the WS `onLiveEnd` event and the polling
|
|
801
|
+
* fallback in {@link tickPushAtTime}).
|
|
802
|
+
*
|
|
803
|
+
* Order: cancel periodic timer → refresh room/master info → push live-end
|
|
804
|
+
* card → kick off wordcloud + summary → drain danmaku buffer.
|
|
805
|
+
*/
|
|
806
|
+
async handleLiveEnd(source) {
|
|
807
|
+
if (!this.liveStatus) {
|
|
808
|
+
this.ctx.logger.warn(`[live] 直播间 [${this.sub.roomId}] 已经是下播状态,忽略 (source=${source})`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
this.cancelPeriodicTimer();
|
|
812
|
+
if (!await this.useLiveRoomInfo(3) || !await this.useMasterInfo(3) || !this.liveRoomInfo || !this.masterInfo) {
|
|
813
|
+
this.setLiveStatus(false);
|
|
814
|
+
this.ctx.danmakuCollector.clear(this.sub.roomId);
|
|
815
|
+
if (this.ctx.isDisposed()) return;
|
|
816
|
+
this.ctx.stopMonitoring("获取直播间信息失败,推送直播下播卡片失败", this.sub.roomId);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
this.setLiveStatus(false);
|
|
820
|
+
this.ctx.logger.debug(`[stat] 开播时粉丝数:${this.masterInfo.liveOpenFollowerNum},下播时粉丝数:${this.masterInfo.liveEndFollowerNum},粉丝数变化:${this.masterInfo.liveFollowerChange}`);
|
|
821
|
+
this.liveTime = this.liveRoomInfo.live_time || DateTime.now().toFormat("yyyy-MM-dd HH:mm:ss");
|
|
822
|
+
const diffTime = await this.ctx.getTimeDifference(this.liveTime);
|
|
823
|
+
this.liveData.fansChanged = this.masterInfo.liveFollowerChange;
|
|
824
|
+
const liveEndMsg = this.ctx.templateRenderer.renderLiveEnd({
|
|
825
|
+
sub: this.sub,
|
|
826
|
+
globalCustom: this.ctx.config.customLiveMsg,
|
|
827
|
+
master: this.masterInfo,
|
|
828
|
+
diffTime,
|
|
829
|
+
followerChange: this.masterInfo.liveFollowerChange
|
|
830
|
+
});
|
|
831
|
+
try {
|
|
832
|
+
if (this.ctx.isSubscribed(this.sub, "liveEnd")) await this.ctx.sendLiveNotifyCard({
|
|
833
|
+
liveType: 3,
|
|
834
|
+
liveData: this.liveData,
|
|
835
|
+
liveRoomInfo: this.liveRoomInfo,
|
|
836
|
+
master: this.masterInfo,
|
|
837
|
+
cardStyle: this.sub.customCardStyle,
|
|
838
|
+
uid: this.sub.uid,
|
|
839
|
+
notifyMsg: liveEndMsg
|
|
840
|
+
});
|
|
841
|
+
await this.dispatchWordCloudAndSummary(this.sub.customLiveSummary.liveSummary || this.ctx.config.liveSummaryDefault);
|
|
842
|
+
} finally {
|
|
843
|
+
this.ctx.danmakuCollector.clear(this.sub.roomId);
|
|
844
|
+
this.ctx.danmakuCollector.registerRoom(this.sub.roomId);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Run wordcloud + AI live-summary in parallel and dispatch whichever
|
|
849
|
+
* succeeded. Skipped entirely when neither feature is subscribed.
|
|
850
|
+
*/
|
|
851
|
+
async dispatchWordCloudAndSummary(customLiveSummary) {
|
|
852
|
+
const wantWordcloud = this.ctx.isSubscribed(this.sub, "wordcloud");
|
|
853
|
+
const wantSummary = this.ctx.isSubscribed(this.sub, "liveSummary");
|
|
854
|
+
if (!wantWordcloud && !wantSummary) return;
|
|
855
|
+
this.ctx.logger.debug(`[wordcloud] 开始制作下播总结 wordcloud=${wantWordcloud} summary=${wantSummary}`);
|
|
856
|
+
const snapshot = this.ctx.danmakuCollector.snapshot(this.sub.roomId);
|
|
857
|
+
const [img, summary] = await Promise.all([wantWordcloud ? this.ctx.wordcloudGenerator.generate(snapshot.sortedWords, this.masterInfo?.username ?? "", this.masterInfo?.userface) : Promise.resolve(void 0), wantSummary ? this.ctx.liveSummaryRequester.generate({
|
|
858
|
+
senderRecord: snapshot.senderRecord,
|
|
859
|
+
sortedWords: snapshot.sortedWords,
|
|
860
|
+
master: this.masterInfo,
|
|
861
|
+
customLiveSummary,
|
|
862
|
+
aiOverride: this.sub.aiOverride
|
|
863
|
+
}) : Promise.resolve(void 0)]);
|
|
864
|
+
if (this.ctx.isDisposed()) return;
|
|
865
|
+
const wcMsg = img ? this.ctx.contentBuilder.image(img, "image/jpeg") : void 0;
|
|
866
|
+
const summaryMsg = summary ? this.ctx.contentBuilder.text(summary) : void 0;
|
|
867
|
+
if (wcMsg) await this.ctx.push.broadcastToTargets(this.sub.uid, wcMsg, 5);
|
|
868
|
+
if (summaryMsg) await this.ctx.push.broadcastToTargets(this.sub.uid, summaryMsg, 10);
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/room-session.ts
|
|
873
|
+
/**
|
|
874
|
+
* One {@link RoomSession} per UID/room actively being monitored.
|
|
875
|
+
*
|
|
876
|
+
* Extends {@link RoomSessionBase} (state + lifecycle + transitions) with the
|
|
877
|
+
* {@link MsgHandler} factory and the per-event handlers (`onLiveStart`,
|
|
878
|
+
* `onIncomeDanmu`, `onIncomeSuperChat`, `onGuardBuy`, `onLiveEnd`, `onError`,
|
|
879
|
+
* `onWatchedChange`, `onLikedChange`, plus the `INTERACT_WORD_V2` raw branch).
|
|
880
|
+
*
|
|
881
|
+
* Each handler reads / mutates the protected state defined on the base.
|
|
882
|
+
* `bootstrap()` (defined on the base) opens the WS connection and arms the
|
|
883
|
+
* periodic timer if the room is already live; subsequent state transitions
|
|
884
|
+
* are driven by the events routed through these handlers.
|
|
885
|
+
*/
|
|
886
|
+
/** Dashboard 端期望的"实时观看人数"采样间隔。B 站每几秒推一帧 WATCHED_CHANGE,
|
|
887
|
+
* 这里 per-UID 门控成 2s 最多一次,够人眼感知,WS 不会刷屏。 */
|
|
888
|
+
const VIEWERS_EMIT_THROTTLE_MS = 2e3;
|
|
889
|
+
/**
|
|
890
|
+
* onError 触发后的退避重连节奏(单位 ms)。失败时按下标顺序消耗,直到耗尽 → 真正放弃。
|
|
891
|
+
* 重连成功后 `reconnectAttempts` 复位到 0,后续新一轮 onError 重新从 1s 开始。
|
|
892
|
+
*/
|
|
893
|
+
const RECONNECT_BACKOFF_MS = [
|
|
894
|
+
1e3,
|
|
895
|
+
2e3,
|
|
896
|
+
4e3,
|
|
897
|
+
8e3,
|
|
898
|
+
16e3
|
|
899
|
+
];
|
|
900
|
+
var RoomSession = class extends RoomSessionBase {
|
|
901
|
+
lastViewersEmitMs = 0;
|
|
902
|
+
/**
|
|
903
|
+
* 当前 RoomSession 是否已被外层(stopForUid / disposeAll / liveEnd 主动关闭)取消。
|
|
904
|
+
* 一旦设为 true,onError 跳过重连。listener-manager.stopForUid 在 closeListener
|
|
905
|
+
* 之前调用 cancel() 设置。
|
|
906
|
+
*/
|
|
907
|
+
cancelled = false;
|
|
908
|
+
reconnectAttempts = 0;
|
|
909
|
+
/**
|
|
910
|
+
* L1 单飞守卫:并发 onError(WS 错误常突发多帧)若都进入重连路径,会各自
|
|
911
|
+
* closeListener + 退避 + startLiveRoomListener,装回多个 listener。一旦一个
|
|
912
|
+
* onError 拿到重连权,其余直接返回。
|
|
913
|
+
*/
|
|
914
|
+
reconnecting = false;
|
|
915
|
+
/** L3:退避 sleep 的 Disposable + 唤醒句柄,cancel/teardown 时清掉,不留回调到 expiry。 */
|
|
916
|
+
reconnectTimer;
|
|
917
|
+
reconnectWake;
|
|
918
|
+
/** 外层主动停止 listener 时调用,阻止 onError 触发重连。 */
|
|
919
|
+
cancel() {
|
|
920
|
+
this.cancelled = true;
|
|
921
|
+
this.reconnecting = false;
|
|
922
|
+
this.clearReconnectSleep();
|
|
923
|
+
}
|
|
924
|
+
/** L3:dispose 退避定时器并唤醒重连循环,使其立刻重校 cancelled/disposed 后退出。 */
|
|
925
|
+
clearReconnectSleep() {
|
|
926
|
+
this.reconnectTimer?.dispose();
|
|
927
|
+
this.reconnectTimer = void 0;
|
|
928
|
+
this.reconnectWake?.();
|
|
929
|
+
this.reconnectWake = void 0;
|
|
930
|
+
}
|
|
931
|
+
buildHandler() {
|
|
932
|
+
const base = {
|
|
933
|
+
onError: () => this.onError(),
|
|
934
|
+
onIncomeDanmu: ({ body }) => this.onIncomeDanmu(body),
|
|
935
|
+
onIncomeSuperChat: ({ body }) => this.onIncomeSuperChat(body),
|
|
936
|
+
onWatchedChange: ({ body }) => {
|
|
937
|
+
this.liveData.watchedNum = body.text_small;
|
|
938
|
+
const now = Date.now();
|
|
939
|
+
if (now - this.lastViewersEmitMs >= VIEWERS_EMIT_THROTTLE_MS) {
|
|
940
|
+
this.lastViewersEmitMs = now;
|
|
941
|
+
this.ctx.emitViewers(this.sub.uid, body.text_small);
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
onLikedChange: ({ body }) => {
|
|
945
|
+
this.liveData.likedNum = body.count;
|
|
946
|
+
},
|
|
947
|
+
onGuardBuy: ({ body }) => this.onGuardBuy(body),
|
|
948
|
+
onLiveStart: () => this.onLiveStart(),
|
|
949
|
+
onLiveEnd: () => this.onLiveEnd()
|
|
950
|
+
};
|
|
951
|
+
if (!this.sub.customSpecialUsersEnterTheRoom.enable) return base;
|
|
952
|
+
return {
|
|
953
|
+
...base,
|
|
954
|
+
raw: { INTERACT_WORD_V2: (msg) => this.onInteractWordV2(msg) }
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
async onError() {
|
|
958
|
+
if (this.cancelled || this.ctx.isDisposed()) return;
|
|
959
|
+
if (this.reconnecting) return;
|
|
960
|
+
this.reconnecting = true;
|
|
961
|
+
try {
|
|
962
|
+
await this.reconnectLoop();
|
|
963
|
+
} finally {
|
|
964
|
+
this.reconnecting = false;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* 退避重连循环(单飞,由 onError 持有)。`while` 取代旧的 `setTimeout(0)`
|
|
969
|
+
* 递归续链 —— 杜绝深栈递归 + 每步都丢弃的定时器 Disposable;每次 sleep 后
|
|
970
|
+
* 重校 cancelled/disposed,sleep 自身可被 cancel/teardown dispose。
|
|
971
|
+
*/
|
|
972
|
+
async reconnectLoop() {
|
|
973
|
+
while (this.reconnectAttempts < RECONNECT_BACKOFF_MS.length) {
|
|
974
|
+
if (this.cancelled || this.ctx.isDisposed()) return;
|
|
975
|
+
this.setLiveStatus(false);
|
|
976
|
+
this.cancelPeriodicTimer();
|
|
977
|
+
this.ctx.closeListener(this.sub.roomId);
|
|
978
|
+
const delay = RECONNECT_BACKOFF_MS[this.reconnectAttempts];
|
|
979
|
+
this.reconnectAttempts++;
|
|
980
|
+
this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 连接错误,${delay / 1e3}s 后重连(第 ${this.reconnectAttempts}/${RECONNECT_BACKOFF_MS.length} 次)`);
|
|
981
|
+
await this.sleepReconnect(delay);
|
|
982
|
+
if (this.cancelled || this.ctx.isDisposed()) return;
|
|
983
|
+
let ok = false;
|
|
984
|
+
try {
|
|
985
|
+
ok = await this.ctx.startLiveRoomListener(this.sub.roomId, this.buildHandler(), () => this.cancelled);
|
|
986
|
+
} catch (e) {
|
|
987
|
+
this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 重连发起异常:${e.message}`);
|
|
988
|
+
}
|
|
989
|
+
if (this.cancelled || this.ctx.isDisposed()) {
|
|
990
|
+
if (ok) this.ctx.closeListener(this.sub.roomId);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (ok) {
|
|
994
|
+
this.ctx.logger.info(`[conn] 直播间 [${this.sub.roomId}] 重连成功`);
|
|
995
|
+
this.reconnectAttempts = 0;
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
this.ctx.logger.warn(`[conn] 直播间 [${this.sub.roomId}] 重连未成功,继续退避`);
|
|
999
|
+
}
|
|
1000
|
+
this.reconnectAttempts = 0;
|
|
1001
|
+
const msg = `直播间 [${this.sub.roomId}] 连接持续失败,重试 ${RECONNECT_BACKOFF_MS.length} 次后放弃监听`;
|
|
1002
|
+
this.ctx.logger.error(`[conn] ${msg}`);
|
|
1003
|
+
this.ctx.emitEngineError(msg);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* L3:可被 {@link clearReconnectSleep} 取消的退避 sleep。dispose 时立即
|
|
1007
|
+
* resolve,让 reconnectLoop 醒来重校 cancelled/disposed 后退出 —— 不再留
|
|
1008
|
+
* 一个无法清除的延迟回调到 expiry。
|
|
1009
|
+
*/
|
|
1010
|
+
sleepReconnect(ms) {
|
|
1011
|
+
return new Promise((resolve) => {
|
|
1012
|
+
this.reconnectWake = resolve;
|
|
1013
|
+
this.reconnectTimer = this.ctx.serviceCtx.setTimeout(() => {
|
|
1014
|
+
this.reconnectTimer = void 0;
|
|
1015
|
+
this.reconnectWake = void 0;
|
|
1016
|
+
resolve();
|
|
1017
|
+
}, ms);
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
onIncomeDanmu(body) {
|
|
1021
|
+
if (this.ctx.isSubscribed(this.sub, "wordcloud") || this.ctx.isSubscribed(this.sub, "liveSummary")) this.ctx.danmakuCollector.recordDanmaku(this.sub.roomId, body.content, body.user.uname);
|
|
1022
|
+
if (this.sub.customSpecialDanmakuUsers.enable && this.ctx.hasTargets(this.sub, "specialDanmaku") && this.sub.customSpecialDanmakuUsers.specialDanmakuUsers?.includes(body.user.uid.toString())) {
|
|
1023
|
+
const text = this.ctx.templateRenderer.renderSpecialDanmaku({
|
|
1024
|
+
template: this.sub.customSpecialDanmakuUsers.msgTemplate,
|
|
1025
|
+
uname: body.user.uname,
|
|
1026
|
+
master: this.masterInfo,
|
|
1027
|
+
content: body.content
|
|
1028
|
+
});
|
|
1029
|
+
if (this.ctx.isDisposed()) return;
|
|
1030
|
+
this.ctx.safeBroadcast(this.sub.uid, this.ctx.contentBuilder.message([this.ctx.contentBuilder.text(text)]), 7);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
async onIncomeSuperChat(body) {
|
|
1034
|
+
const collectsDanmaku = this.ctx.isSubscribed(this.sub, "wordcloud") || this.ctx.isSubscribed(this.sub, "liveSummary");
|
|
1035
|
+
const pushesSC = this.ctx.isSubscribed(this.sub, "superchat");
|
|
1036
|
+
if (!collectsDanmaku && !pushesSC) return;
|
|
1037
|
+
if (collectsDanmaku) this.ctx.danmakuCollector.recordDanmaku(this.sub.roomId, body.content, body.user.uname);
|
|
1038
|
+
if (!pushesSC) return;
|
|
1039
|
+
const effMinScPrice = this.sub.minScPrice ?? this.ctx.config.minScPrice;
|
|
1040
|
+
if (body.price < effMinScPrice) return;
|
|
1041
|
+
const data = await this.ctx.api.getUserInfoInLive(body.user.uid.toString(), this.sub.uid);
|
|
1042
|
+
if (data.code !== 0) {
|
|
1043
|
+
const text = `【${this.masterInfo?.username ?? ""}的直播间】${body.user.uname}的SC:${body.content}(${body.price}元)`;
|
|
1044
|
+
if (this.ctx.isDisposed()) return;
|
|
1045
|
+
await this.ctx.push.broadcastToTargets(this.sub.uid, this.ctx.contentBuilder.message([this.ctx.contentBuilder.text(text)]), 6);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (this.ctx.imageRenderer?.generateSCCard) try {
|
|
1049
|
+
const userInfo = data.data;
|
|
1050
|
+
const buf = await this.ctx.imageRenderer.generateSCCard({
|
|
1051
|
+
senderFace: userInfo.face,
|
|
1052
|
+
senderName: userInfo.uname,
|
|
1053
|
+
masterName: this.masterInfo?.username ?? "",
|
|
1054
|
+
masterAvatarUrl: this.masterInfo?.userface ?? "",
|
|
1055
|
+
text: body.content,
|
|
1056
|
+
price: body.price
|
|
1057
|
+
});
|
|
1058
|
+
if (this.ctx.isDisposed()) return;
|
|
1059
|
+
await this.ctx.push.broadcastToTargets(this.sub.uid, this.ctx.contentBuilder.image(buf, "image/jpeg"), 6);
|
|
1060
|
+
return;
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
this.ctx.logger.error(`[sc] 生成SC图片失败:${e.message}`);
|
|
1063
|
+
}
|
|
1064
|
+
const fallback = `【${this.masterInfo?.username ?? ""}的直播间】${data.data.uname}的SC:${body.content}(${body.price}元)`;
|
|
1065
|
+
if (this.ctx.isDisposed()) return;
|
|
1066
|
+
await this.ctx.push.broadcastToTargets(this.sub.uid, this.ctx.contentBuilder.message([this.ctx.contentBuilder.text(fallback)]), 6);
|
|
1067
|
+
}
|
|
1068
|
+
async onGuardBuy(body) {
|
|
1069
|
+
if (!this.ctx.isSubscribed(this.sub, "liveGuardBuy")) return;
|
|
1070
|
+
const effMinGuardLevel = this.sub.minGuardLevel ?? this.ctx.config.minGuardLevel;
|
|
1071
|
+
if (body.guard_level > effMinGuardLevel) return;
|
|
1072
|
+
const guardImg = GUARD_LEVEL_IMG[body.guard_level];
|
|
1073
|
+
const effectiveGuardBuy = this.sub.customGuardBuy.enable ? this.sub.customGuardBuy : this.ctx.config.customGuardBuy;
|
|
1074
|
+
if (effectiveGuardBuy.enable) {
|
|
1075
|
+
const customGuardImg = {
|
|
1076
|
+
[GuardLevel.None]: void 0,
|
|
1077
|
+
[GuardLevel.Jianzhang]: effectiveGuardBuy.captainImgUrl,
|
|
1078
|
+
[GuardLevel.Tidu]: effectiveGuardBuy.supervisorImgUrl,
|
|
1079
|
+
[GuardLevel.Zongdu]: effectiveGuardBuy.governorImgUrl
|
|
1080
|
+
};
|
|
1081
|
+
const text = this.ctx.templateRenderer.renderGuardBuy({
|
|
1082
|
+
guardBuyConfig: effectiveGuardBuy,
|
|
1083
|
+
uname: body.user.uname,
|
|
1084
|
+
master: this.masterInfo,
|
|
1085
|
+
giftName: body.gift_name
|
|
1086
|
+
});
|
|
1087
|
+
if (this.ctx.isDisposed()) return;
|
|
1088
|
+
await this.ctx.push.broadcastToTargets(this.sub.uid, this.ctx.contentBuilder.message([this.ctx.contentBuilder.image(customGuardImg[body.guard_level] ?? guardImg), this.ctx.contentBuilder.text(text)]), 4);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (this.ctx.imageRenderer?.generateGuardCard) {
|
|
1092
|
+
const data = await this.ctx.api.getUserInfoInLive(body.user.uid.toString(), this.sub.uid);
|
|
1093
|
+
if (data.code === 0) try {
|
|
1094
|
+
const buf = await this.ctx.imageRenderer.generateGuardCard({
|
|
1095
|
+
guardLevel: body.guard_level,
|
|
1096
|
+
uname: data.data.uname,
|
|
1097
|
+
face: data.data.face,
|
|
1098
|
+
isAdmin: data.data.is_admin
|
|
1099
|
+
}, {
|
|
1100
|
+
masterName: this.masterInfo?.username ?? "",
|
|
1101
|
+
masterAvatarUrl: this.masterInfo?.userface ?? ""
|
|
1102
|
+
});
|
|
1103
|
+
if (this.ctx.isDisposed()) return;
|
|
1104
|
+
await this.ctx.push.broadcastToTargets(this.sub.uid, this.ctx.contentBuilder.image(buf, "image/jpeg"), 4);
|
|
1105
|
+
return;
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
this.ctx.logger.error(`[guard] 生成上舰图片失败:${e.message}`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (this.ctx.isDisposed()) return;
|
|
1111
|
+
await this.ctx.push.broadcastToTargets(this.sub.uid, this.ctx.contentBuilder.message([this.ctx.contentBuilder.image(guardImg), this.ctx.contentBuilder.text(`【${this.masterInfo?.username ?? ""}的直播间】${body.user.uname}加入了大航海(${body.gift_name})`)]), 4);
|
|
1112
|
+
}
|
|
1113
|
+
async onLiveStart() {
|
|
1114
|
+
const now = Date.now();
|
|
1115
|
+
if (now - this.lastLiveStart < 1e4) {
|
|
1116
|
+
this.ctx.logger.debug(`[live] 直播间 [${this.sub.roomId}] 的开播事件在冷却期内,忽略`);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (this.liveStatus) {
|
|
1120
|
+
this.ctx.logger.debug(`[live] 直播间 [${this.sub.roomId}] 已经是开播状态,忽略重复的开播事件`);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
this.lastLiveStart = now;
|
|
1124
|
+
this.setLiveStatus(true);
|
|
1125
|
+
if (!await this.useLiveRoomInfo(1) || !await this.useMasterInfo(1) || !this.liveRoomInfo || !this.masterInfo) {
|
|
1126
|
+
this.setLiveStatus(false);
|
|
1127
|
+
if (this.ctx.isDisposed()) return;
|
|
1128
|
+
this.ctx.stopMonitoring("获取直播间信息失败,推送直播开播卡片失败", this.sub.roomId);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
this.ctx.logger.info(`[stat] 房间号:${this.masterInfo.roomId},开播时的粉丝数:${this.masterInfo.liveOpenFollowerNum}`);
|
|
1132
|
+
this.liveTime = this.liveRoomInfo.live_time || DateTime.now().toFormat("yyyy-MM-dd HH:mm:ss");
|
|
1133
|
+
const diffTime = await this.ctx.getTimeDifference(this.liveTime);
|
|
1134
|
+
const followerNum = this.masterInfo.liveOpenFollowerNum >= 1e4 ? `${(this.masterInfo.liveOpenFollowerNum / 1e4).toFixed(1)}万` : this.masterInfo.liveOpenFollowerNum.toString();
|
|
1135
|
+
this.liveData.fansNum = this.masterInfo.liveOpenFollowerNum;
|
|
1136
|
+
const roomLink = buildRoomLink(this.liveRoomInfo);
|
|
1137
|
+
const liveStartMsg = this.ctx.templateRenderer.renderLiveStart({
|
|
1138
|
+
sub: this.sub,
|
|
1139
|
+
globalCustom: this.ctx.config.customLiveMsg,
|
|
1140
|
+
master: this.masterInfo,
|
|
1141
|
+
diffTime,
|
|
1142
|
+
followerNum,
|
|
1143
|
+
roomLink
|
|
1144
|
+
});
|
|
1145
|
+
await this.ctx.sendLiveNotifyCard({
|
|
1146
|
+
liveType: 1,
|
|
1147
|
+
liveData: this.liveData,
|
|
1148
|
+
liveRoomInfo: this.liveRoomInfo,
|
|
1149
|
+
master: this.masterInfo,
|
|
1150
|
+
cardStyle: this.sub.customCardStyle,
|
|
1151
|
+
uid: this.sub.uid,
|
|
1152
|
+
notifyMsg: liveStartMsg
|
|
1153
|
+
});
|
|
1154
|
+
if (this.ctx.isDisposed()) return;
|
|
1155
|
+
if (!this.liveStatus) {
|
|
1156
|
+
this.ctx.logger.warn(`[live] 直播间 [${this.sub.roomId}] 开播流程完成时已非开播态(疑似交错下播),跳过周期任务`);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
this.armPeriodicTimer();
|
|
1160
|
+
}
|
|
1161
|
+
async onLiveEnd() {
|
|
1162
|
+
const now = Date.now();
|
|
1163
|
+
if (now - this.lastLiveEnd < 1e4) {
|
|
1164
|
+
this.ctx.logger.debug(`[live] 直播间 [${this.sub.roomId}] 的下播事件在冷却期内,忽略`);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
this.lastLiveEnd = now;
|
|
1168
|
+
await this.handleLiveEnd("ws");
|
|
1169
|
+
}
|
|
1170
|
+
async onInteractWordV2(msg) {
|
|
1171
|
+
if (!this.sub.customSpecialUsersEnterTheRoom.enable || !this.ctx.hasTargets(this.sub, "specialUserEnterTheRoom")) return;
|
|
1172
|
+
const pb = msg?.data?.pb;
|
|
1173
|
+
if (typeof pb !== "string") {
|
|
1174
|
+
this.ctx.logger.warn(`[live] INTERACT_WORD_V2 缺少 data.pb 字段,跳过 (room=${this.sub.roomId})`);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const data = await this.ctx.decodeBase64PB(pb);
|
|
1178
|
+
const uid = typeof data.uid === "string" ? data.uid : String(data.uid ?? "");
|
|
1179
|
+
const uname = typeof data.uname === "string" ? data.uname : "";
|
|
1180
|
+
if (data.msgType === "1" && this.sub.customSpecialUsersEnterTheRoom.specialUsersEnterTheRoom?.includes(uid)) {
|
|
1181
|
+
const text = this.ctx.templateRenderer.renderSpecialUserEnter({
|
|
1182
|
+
template: this.sub.customSpecialUsersEnterTheRoom.msgTemplate,
|
|
1183
|
+
uname,
|
|
1184
|
+
master: this.masterInfo
|
|
1185
|
+
});
|
|
1186
|
+
this.ctx.safeBroadcast(this.sub.uid, this.ctx.contentBuilder.message([this.ctx.contentBuilder.text(text)]), 8);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
//#endregion
|
|
1191
|
+
//#region src/listener-manager.ts
|
|
1192
|
+
/**
|
|
1193
|
+
* Top-level lifecycle for live-room listeners.
|
|
1194
|
+
*
|
|
1195
|
+
* Owns:
|
|
1196
|
+
* - the per-uid {@link SubItemView} registry (mutable clones we update through
|
|
1197
|
+
* `bilibili-notify/subscription-changed` ops).
|
|
1198
|
+
* - the underlying {@link RoomContext} (which in turn owns the listener
|
|
1199
|
+
* record + periodic-timer record and exposes shared helpers consumed by
|
|
1200
|
+
* {@link RoomSession}).
|
|
1201
|
+
*
|
|
1202
|
+
* Per-room state and the dispatcher closure live in {@link RoomSession}; the
|
|
1203
|
+
* manager only orchestrates start / stop / clearAll.
|
|
1204
|
+
*/
|
|
1205
|
+
var ListenerManager = class {
|
|
1206
|
+
ctx;
|
|
1207
|
+
subRecord = /* @__PURE__ */ new Map();
|
|
1208
|
+
sessionRecord = /* @__PURE__ */ new Map();
|
|
1209
|
+
constructor(opts) {
|
|
1210
|
+
this.ctx = new RoomContext(opts);
|
|
1211
|
+
}
|
|
1212
|
+
/** Replace runtime config (called when the adapter receives a config update). */
|
|
1213
|
+
updateConfig(config) {
|
|
1214
|
+
this.ctx.updateConfig(config);
|
|
1215
|
+
}
|
|
1216
|
+
isDisposed() {
|
|
1217
|
+
return this.ctx.isDisposed();
|
|
1218
|
+
}
|
|
1219
|
+
getListenerCount() {
|
|
1220
|
+
return this.ctx.getListenerCount();
|
|
1221
|
+
}
|
|
1222
|
+
/** Active mutable sub by uid (used by `applyOps` to detect existence). */
|
|
1223
|
+
getActiveSub(uid) {
|
|
1224
|
+
return this.subRecord.get(uid);
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Read-only live-state snapshot for every monitored room. Each entry pairs
|
|
1228
|
+
* the room's `isLive` boolean (mirrors RoomSession.liveStatus) with the
|
|
1229
|
+
* latest fetched `liveRoomInfo` fields. Used by the standalone dashboard's
|
|
1230
|
+
* `GET /api/live/listening` route to populate the "正在直播" panel.
|
|
1231
|
+
*/
|
|
1232
|
+
listLiveSnapshots() {
|
|
1233
|
+
return Array.from(this.sessionRecord.values()).map((s) => s.getLiveSnapshot());
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* `pushTime` 热更后调用:把所有 active session 的"正在直播"复推 timer 按当前
|
|
1237
|
+
* `pushTime` 重新 arm。LiveEngine.updateConfig 在检测到变化时转发。
|
|
1238
|
+
*/
|
|
1239
|
+
rearmAllPeriodicTimers() {
|
|
1240
|
+
for (const session of this.sessionRecord.values()) session.rearmPeriodicTimer();
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* 单个 UID 的 pushTime 热更入口。LiveEngine.applyOps 在 update 分支检测到
|
|
1244
|
+
* `LiveScopedChange.pushTime` 变化时调用,仅对该 uid 的活跃 session rearm。
|
|
1245
|
+
*/
|
|
1246
|
+
rearmPeriodicTimerForUid(uid) {
|
|
1247
|
+
this.sessionRecord.get(uid)?.rearmPeriodicTimer();
|
|
1248
|
+
}
|
|
1249
|
+
/** Whether any feature on this sub requires the live-room WS connection. */
|
|
1250
|
+
needsLiveMonitor(sub) {
|
|
1251
|
+
return this.ctx.needsLiveMonitor(sub);
|
|
1252
|
+
}
|
|
1253
|
+
/** Start listeners for everything in `subs` that needs one. */
|
|
1254
|
+
startAll(subs) {
|
|
1255
|
+
for (const session of this.sessionRecord.values()) session.cancel();
|
|
1256
|
+
this.ctx.setDisposed(false);
|
|
1257
|
+
this.ctx.clearPushTimers();
|
|
1258
|
+
this.ctx.clearListeners();
|
|
1259
|
+
this.subRecord.clear();
|
|
1260
|
+
this.sessionRecord.clear();
|
|
1261
|
+
const liveSubUids = Object.values(subs).filter((s) => this.ctx.needsLiveMonitor(s)).map((s) => s.uid);
|
|
1262
|
+
this.ctx.logger.debug(`[start] 启动直播监听,共 ${liveSubUids.length} 个 UID:${liveSubUids.join(", ")}`);
|
|
1263
|
+
for (const sub of Object.values(subs)) if (this.ctx.needsLiveMonitor(sub)) this.startForUid(sub, "[start]");
|
|
1264
|
+
}
|
|
1265
|
+
/** Start a single sub's listener via a fresh {@link RoomSession}. */
|
|
1266
|
+
startForUid(sub, logPrefix = "[ops]") {
|
|
1267
|
+
const mutable = structuredClone(sub);
|
|
1268
|
+
this.subRecord.set(sub.uid, mutable);
|
|
1269
|
+
this.bootstrapForUid(mutable, logPrefix);
|
|
1270
|
+
}
|
|
1271
|
+
async bootstrapForUid(mutable, logPrefix) {
|
|
1272
|
+
if (!mutable.roomId) {
|
|
1273
|
+
const resolved = await this.resolveRoomId(mutable.uid, logPrefix);
|
|
1274
|
+
if (!resolved) return;
|
|
1275
|
+
mutable.roomId = resolved;
|
|
1276
|
+
}
|
|
1277
|
+
if (this.ctx.isDisposed()) return;
|
|
1278
|
+
if (!this.subRecord.has(mutable.uid)) return;
|
|
1279
|
+
this.ctx.danmakuCollector.registerRoom(mutable.roomId);
|
|
1280
|
+
const session = new RoomSession(this.ctx, mutable);
|
|
1281
|
+
this.sessionRecord.set(mutable.uid, session);
|
|
1282
|
+
session.bootstrap().catch((e) => {
|
|
1283
|
+
this.ctx.logger.error(`${logPrefix} 启动直播监听失败 UID=${mutable.uid}:${e instanceof Error ? e.message : String(e)}`);
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
async resolveRoomId(uid, logPrefix) {
|
|
1287
|
+
try {
|
|
1288
|
+
const roomid = (await this.ctx.api.getUserInfo(uid))?.data?.live_room?.roomid;
|
|
1289
|
+
const n = Number(roomid);
|
|
1290
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
1291
|
+
this.ctx.logger.warn(`${logPrefix} UID=${uid} 未开通直播间或 live_room 解析失败,跳过 listener 创建`);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
return String(n);
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
this.ctx.logger.warn(`${logPrefix} UID=${uid} 解析直播间号失败:${e.message}`);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/** Stop a single sub's listener and drop its bookkeeping. */
|
|
1301
|
+
stopForUid(uid) {
|
|
1302
|
+
const sub = this.subRecord.get(uid);
|
|
1303
|
+
if (!sub) return;
|
|
1304
|
+
const session = this.sessionRecord.get(uid);
|
|
1305
|
+
if (session?.isLive) this.ctx.emitLiveState(uid, "idle");
|
|
1306
|
+
session?.cancel();
|
|
1307
|
+
this.ctx.livePushTimerManager.get(sub.roomId)?.();
|
|
1308
|
+
this.ctx.livePushTimerManager.delete(sub.roomId);
|
|
1309
|
+
this.ctx.closeListener(sub.roomId);
|
|
1310
|
+
this.ctx.danmakuCollector.clear(sub.roomId);
|
|
1311
|
+
this.subRecord.delete(uid);
|
|
1312
|
+
this.sessionRecord.delete(uid);
|
|
1313
|
+
}
|
|
1314
|
+
/** Tear down everything. Used by engine `stop()` / `auth-lost`. */
|
|
1315
|
+
disposeAll() {
|
|
1316
|
+
this.ctx.logSideEffectState("stop:before-clear");
|
|
1317
|
+
this.ctx.setDisposed(true);
|
|
1318
|
+
for (const session of this.sessionRecord.values()) session.cancel();
|
|
1319
|
+
this.ctx.clearPushTimers();
|
|
1320
|
+
this.ctx.clearListeners();
|
|
1321
|
+
this.subRecord.clear();
|
|
1322
|
+
this.sessionRecord.clear();
|
|
1323
|
+
this.ctx.danmakuCollector.clearAll();
|
|
1324
|
+
this.ctx.logSideEffectState("stop:after-clear");
|
|
1325
|
+
}
|
|
1326
|
+
/** Tear down listeners + timers but keep registry / disposed flag intact. */
|
|
1327
|
+
clearListeners() {
|
|
1328
|
+
this.ctx.clearListeners();
|
|
1329
|
+
}
|
|
1330
|
+
/** Cancel every periodic "正在直播" timer. */
|
|
1331
|
+
clearPushTimers() {
|
|
1332
|
+
this.ctx.clearPushTimers();
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
//#endregion
|
|
1336
|
+
//#region src/live-summary-requester.ts
|
|
1337
|
+
/**
|
|
1338
|
+
* Threshold below which the engine refuses to generate a live summary.
|
|
1339
|
+
* Mirrors the original live-service heuristic.
|
|
1340
|
+
*/
|
|
1341
|
+
const LIVE_SUMMARY_MIN_SENDERS = 5;
|
|
1342
|
+
/**
|
|
1343
|
+
* Builds the AI prompt + dispatches it through {@link CommentaryGenerator},
|
|
1344
|
+
* falling back to the template-based summary when AI is unavailable or fails.
|
|
1345
|
+
*
|
|
1346
|
+
* Two-tier strategy (kept identical to live-service):
|
|
1347
|
+
* 1. If `commentary` is non-null, build a prompt summarising sender count,
|
|
1348
|
+
* medal name, total danmaku, top-10 words and top-5 senders, then call
|
|
1349
|
+
* `commentary.comment(prompt, "liveSummary")`.
|
|
1350
|
+
* 2. On failure (AI not configured / API error), fall back to the user-supplied
|
|
1351
|
+
* template (`customLiveSummary` per-sub or the global default).
|
|
1352
|
+
*
|
|
1353
|
+
* Returns `undefined` when sender count is below the threshold (signals the
|
|
1354
|
+
* caller to skip the summary push entirely).
|
|
1355
|
+
*/
|
|
1356
|
+
var LiveSummaryRequester = class {
|
|
1357
|
+
commentary;
|
|
1358
|
+
isAiEnabled;
|
|
1359
|
+
templateRenderer;
|
|
1360
|
+
logger;
|
|
1361
|
+
constructor(opts) {
|
|
1362
|
+
this.commentary = opts.commentary;
|
|
1363
|
+
this.isAiEnabled = opts.isAiEnabled ?? (() => true);
|
|
1364
|
+
this.templateRenderer = opts.templateRenderer;
|
|
1365
|
+
this.logger = opts.logger;
|
|
1366
|
+
}
|
|
1367
|
+
/** 热替换 CommentaryGenerator 实例。null 表示降级到模板回退。 */
|
|
1368
|
+
setCommentary(commentary) {
|
|
1369
|
+
this.commentary = commentary;
|
|
1370
|
+
}
|
|
1371
|
+
async generate(params) {
|
|
1372
|
+
const { senderRecord, sortedWords, master, customLiveSummary, aiOverride } = params;
|
|
1373
|
+
const senderCount = Object.keys(senderRecord).length;
|
|
1374
|
+
if (senderCount < 5) {
|
|
1375
|
+
this.logger.debug(`[summary] 发言人数不足5位,放弃生成直播总结`);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const danmakuCount = Object.values(senderRecord).reduce((sum, val) => sum + val, 0);
|
|
1379
|
+
const top5Senders = Object.entries(senderRecord).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
1380
|
+
if (this.commentary && this.isAiEnabled()) try {
|
|
1381
|
+
const top10Words = sortedWords.slice(0, 10).map(([word, count]) => `${word}(${count})`);
|
|
1382
|
+
const prompt = [
|
|
1383
|
+
"请生成直播总结",
|
|
1384
|
+
`弹幕发言人数:${senderCount}`,
|
|
1385
|
+
`粉丝牌名:${master?.medalName ?? ""}`,
|
|
1386
|
+
`弹幕总数:${danmakuCount}`,
|
|
1387
|
+
`热词TOP10:${top10Words.join("、")}`,
|
|
1388
|
+
`弹幕排行TOP5:${top5Senders.map(([u, c]) => `${u}(${c}条)`).join("、")}`
|
|
1389
|
+
].join(",");
|
|
1390
|
+
const aiResult = await this.commentary.comment(prompt, "liveSummary", void 0, aiOverride);
|
|
1391
|
+
this.logger.debug(`[summary] AI 直播总结生成完毕,长度=${aiResult.length}`);
|
|
1392
|
+
return aiResult;
|
|
1393
|
+
} catch (e) {
|
|
1394
|
+
this.logger.error(`[summary] AI 直播总结生成失败:${e.message},回退到模板`);
|
|
1395
|
+
}
|
|
1396
|
+
return this.templateRenderer.renderLiveSummary({
|
|
1397
|
+
template: customLiveSummary,
|
|
1398
|
+
senderCount,
|
|
1399
|
+
master,
|
|
1400
|
+
danmakuCount,
|
|
1401
|
+
topSenders: top5Senders
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
//#endregion
|
|
1406
|
+
//#region src/stop-words.ts
|
|
1407
|
+
const stopwords = new Set([
|
|
1408
|
+
",",
|
|
1409
|
+
"。",
|
|
1410
|
+
"!",
|
|
1411
|
+
"?",
|
|
1412
|
+
":",
|
|
1413
|
+
";",
|
|
1414
|
+
"“",
|
|
1415
|
+
"”",
|
|
1416
|
+
"‘",
|
|
1417
|
+
"’",
|
|
1418
|
+
"(",
|
|
1419
|
+
")",
|
|
1420
|
+
"、",
|
|
1421
|
+
"……",
|
|
1422
|
+
"——",
|
|
1423
|
+
"-",
|
|
1424
|
+
"_",
|
|
1425
|
+
".",
|
|
1426
|
+
",",
|
|
1427
|
+
"(",
|
|
1428
|
+
")",
|
|
1429
|
+
"【",
|
|
1430
|
+
"】",
|
|
1431
|
+
"而且",
|
|
1432
|
+
"但是",
|
|
1433
|
+
"如果",
|
|
1434
|
+
"虽然",
|
|
1435
|
+
"因为",
|
|
1436
|
+
"所以",
|
|
1437
|
+
"那么",
|
|
1438
|
+
"那么就",
|
|
1439
|
+
"今天",
|
|
1440
|
+
"昨天",
|
|
1441
|
+
"后天",
|
|
1442
|
+
"明天",
|
|
1443
|
+
"现在",
|
|
1444
|
+
"刚刚",
|
|
1445
|
+
"刚才",
|
|
1446
|
+
"一直",
|
|
1447
|
+
"一直在",
|
|
1448
|
+
"目前",
|
|
1449
|
+
"以前",
|
|
1450
|
+
"以后",
|
|
1451
|
+
"以前的",
|
|
1452
|
+
"the",
|
|
1453
|
+
"and",
|
|
1454
|
+
"to",
|
|
1455
|
+
"of",
|
|
1456
|
+
"a",
|
|
1457
|
+
"is",
|
|
1458
|
+
"in",
|
|
1459
|
+
"on",
|
|
1460
|
+
"for",
|
|
1461
|
+
"with",
|
|
1462
|
+
"this",
|
|
1463
|
+
"that",
|
|
1464
|
+
"you",
|
|
1465
|
+
"觉得",
|
|
1466
|
+
"表示",
|
|
1467
|
+
"发现",
|
|
1468
|
+
"认为",
|
|
1469
|
+
"看到",
|
|
1470
|
+
"听说",
|
|
1471
|
+
"了解",
|
|
1472
|
+
"知道",
|
|
1473
|
+
"说明",
|
|
1474
|
+
"指出",
|
|
1475
|
+
"讨论",
|
|
1476
|
+
"讨论一下",
|
|
1477
|
+
"看看",
|
|
1478
|
+
"想想",
|
|
1479
|
+
"说说",
|
|
1480
|
+
"讲讲",
|
|
1481
|
+
"一个",
|
|
1482
|
+
"一些",
|
|
1483
|
+
"这个",
|
|
1484
|
+
"那个",
|
|
1485
|
+
"每个",
|
|
1486
|
+
"什么",
|
|
1487
|
+
"东西",
|
|
1488
|
+
"事情",
|
|
1489
|
+
"这些",
|
|
1490
|
+
"那些",
|
|
1491
|
+
"这种",
|
|
1492
|
+
"那种",
|
|
1493
|
+
"怎么说",
|
|
1494
|
+
"怎么会",
|
|
1495
|
+
"怎么可能",
|
|
1496
|
+
"不可能",
|
|
1497
|
+
"有点像",
|
|
1498
|
+
"真的很",
|
|
1499
|
+
"特别是",
|
|
1500
|
+
"有时候",
|
|
1501
|
+
"每次都",
|
|
1502
|
+
"一点点",
|
|
1503
|
+
"哪里有",
|
|
1504
|
+
"太离谱",
|
|
1505
|
+
"太搞笑",
|
|
1506
|
+
"太真实",
|
|
1507
|
+
"为了",
|
|
1508
|
+
"因为",
|
|
1509
|
+
"所以",
|
|
1510
|
+
"但是",
|
|
1511
|
+
"而且",
|
|
1512
|
+
"然后",
|
|
1513
|
+
"如果",
|
|
1514
|
+
"虽然",
|
|
1515
|
+
"然而",
|
|
1516
|
+
"不过",
|
|
1517
|
+
"并且",
|
|
1518
|
+
"即使",
|
|
1519
|
+
"由于",
|
|
1520
|
+
"那么",
|
|
1521
|
+
"除非",
|
|
1522
|
+
"比如",
|
|
1523
|
+
"比如说",
|
|
1524
|
+
"现在",
|
|
1525
|
+
"刚刚",
|
|
1526
|
+
"刚才",
|
|
1527
|
+
"以前",
|
|
1528
|
+
"以后",
|
|
1529
|
+
"一直",
|
|
1530
|
+
"从来",
|
|
1531
|
+
"目前",
|
|
1532
|
+
"最近",
|
|
1533
|
+
"已经",
|
|
1534
|
+
"后来",
|
|
1535
|
+
"之前",
|
|
1536
|
+
"某天"
|
|
1537
|
+
]);
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/wordcloud-generator.ts
|
|
1540
|
+
/**
|
|
1541
|
+
* Threshold for refusing to render a wordcloud — stays in sync with the
|
|
1542
|
+
* original `live-service` heuristic: a board-level wordcloud needs a baseline
|
|
1543
|
+
* vocabulary or it looks empty.
|
|
1544
|
+
*/
|
|
1545
|
+
const WORDCLOUD_MIN_WORDS = 50;
|
|
1546
|
+
/** Cap on how many top words are passed into the wordcloud renderer. */
|
|
1547
|
+
const WORDCLOUD_TOP_WORDS = 90;
|
|
1548
|
+
/**
|
|
1549
|
+
* Wraps {@link ImageRenderer.generateWordCloudImg} with the engine's gating
|
|
1550
|
+
* logic (≥50 unique words required) and surfaces logger messages identical to
|
|
1551
|
+
* the original live-service.
|
|
1552
|
+
*
|
|
1553
|
+
* The output is a `Buffer` so the caller decides how to wrap it for the target
|
|
1554
|
+
* platform (e.g. via `LiveContentBuilder.image`).
|
|
1555
|
+
*/
|
|
1556
|
+
var WordcloudGenerator = class {
|
|
1557
|
+
imageRenderer;
|
|
1558
|
+
isImageEnabled;
|
|
1559
|
+
logger;
|
|
1560
|
+
constructor(opts) {
|
|
1561
|
+
this.imageRenderer = opts.imageRenderer;
|
|
1562
|
+
this.isImageEnabled = opts.isImageEnabled ?? (() => true);
|
|
1563
|
+
this.logger = opts.logger;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Render a wordcloud image for `(masterName, masterAvatarUrl)`.
|
|
1567
|
+
*
|
|
1568
|
+
* Returns `undefined` when:
|
|
1569
|
+
* - There are fewer than {@link WORDCLOUD_MIN_WORDS} unique words.
|
|
1570
|
+
* - No `ImageRenderer` was injected (image-engine isn't installed).
|
|
1571
|
+
* - The renderer threw; the error is logged and swallowed so the rest of
|
|
1572
|
+
* the live-end pipeline (summary + downstream push) keeps running.
|
|
1573
|
+
*/
|
|
1574
|
+
async generate(sortedWords, masterName, masterAvatarUrl) {
|
|
1575
|
+
if (sortedWords.length < 50) {
|
|
1576
|
+
this.logger.debug(`[wordcloud] 热词不足50个,放弃生成弹幕词云`);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (!this.isImageEnabled()) {
|
|
1580
|
+
this.logger.debug("[wordcloud] cardStyle.enabled=false,跳过词云图片生成");
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
if (!this.imageRenderer?.generateWordCloudImg) return void 0;
|
|
1584
|
+
try {
|
|
1585
|
+
return await this.imageRenderer.generateWordCloudImg(sortedWords.slice(0, 90), masterName, masterAvatarUrl);
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
this.logger.error(`[wordcloud] 生成词云失败:${e.message}`);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
//#endregion
|
|
1593
|
+
//#region src/live-engine.ts
|
|
1594
|
+
/**
|
|
1595
|
+
* Platform-neutral live-monitoring engine. Wires the five helpers
|
|
1596
|
+
* (listener-manager / danmaku-collector / wordcloud-generator /
|
|
1597
|
+
* template-renderer / live-summary-requester) together and exposes the public
|
|
1598
|
+
* surface previously offered by the koishi `BilibiliNotifyLive` service.
|
|
1599
|
+
*
|
|
1600
|
+
* Lifecycle:
|
|
1601
|
+
*
|
|
1602
|
+
* - {@link start}: register subscription set, open listeners for those that need them.
|
|
1603
|
+
* - {@link applyOps}: incremental subscription delta (add / delete / update);
|
|
1604
|
+
* adapter forwards `bilibili-notify/subscription-changed` events here.
|
|
1605
|
+
* - {@link rebuildFromSubs}: full rebootstrap (used after `auth-restored`).
|
|
1606
|
+
* - {@link teardown}: tear down all listeners + records (used on `auth-lost`).
|
|
1607
|
+
* - {@link stop}: dispose; called by the adapter on plugin disposal.
|
|
1608
|
+
*/
|
|
1609
|
+
var LiveEngine = class {
|
|
1610
|
+
logger;
|
|
1611
|
+
listener;
|
|
1612
|
+
danmakuCollector;
|
|
1613
|
+
liveSummaryRequester;
|
|
1614
|
+
config;
|
|
1615
|
+
constructor(opts) {
|
|
1616
|
+
this.logger = opts.serviceCtx.logger;
|
|
1617
|
+
this.config = opts.config;
|
|
1618
|
+
const stopwords = mergeStopWords(opts.config.wordcloudStopWords);
|
|
1619
|
+
this.danmakuCollector = new DanmakuCollector(stopwords);
|
|
1620
|
+
const templateRenderer = new LiveTemplateRenderer();
|
|
1621
|
+
const wordcloudGenerator = new WordcloudGenerator({
|
|
1622
|
+
imageRenderer: opts.imageRenderer ?? null,
|
|
1623
|
+
isImageEnabled: () => this.config.imageEnabled !== false,
|
|
1624
|
+
logger: this.logger
|
|
1625
|
+
});
|
|
1626
|
+
this.liveSummaryRequester = new LiveSummaryRequester({
|
|
1627
|
+
commentary: opts.commentary ?? null,
|
|
1628
|
+
isAiEnabled: () => this.config.aiEnabled !== false,
|
|
1629
|
+
templateRenderer,
|
|
1630
|
+
logger: this.logger
|
|
1631
|
+
});
|
|
1632
|
+
const liveSummaryRequester = this.liveSummaryRequester;
|
|
1633
|
+
this.listener = new ListenerManager({
|
|
1634
|
+
serviceCtx: opts.serviceCtx,
|
|
1635
|
+
api: opts.api,
|
|
1636
|
+
push: opts.push,
|
|
1637
|
+
contentBuilder: opts.contentBuilder,
|
|
1638
|
+
templateRenderer,
|
|
1639
|
+
wordcloudGenerator,
|
|
1640
|
+
liveSummaryRequester,
|
|
1641
|
+
danmakuCollector: this.danmakuCollector,
|
|
1642
|
+
imageRenderer: opts.imageRenderer ?? null,
|
|
1643
|
+
config: toListenerConfig(opts.config),
|
|
1644
|
+
emitEngineError: opts.emitEngineError,
|
|
1645
|
+
emitLiveState: opts.emitLiveState,
|
|
1646
|
+
emitViewers: opts.emitViewers
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Bootstrap the engine with the initial subscription set. Idempotent —
|
|
1651
|
+
* calling it again replaces the active set (used by `auth-restored`).
|
|
1652
|
+
*/
|
|
1653
|
+
start(subs) {
|
|
1654
|
+
this.logger.info("[start] 直播引擎启动,正在初始化直播监听...");
|
|
1655
|
+
this.listener.startAll(subs);
|
|
1656
|
+
}
|
|
1657
|
+
/** Tear down all listeners + per-room state, leaving the engine instance reusable. */
|
|
1658
|
+
teardown() {
|
|
1659
|
+
this.logger.info("[live] 关闭所有直播间监听");
|
|
1660
|
+
this.listener.clearPushTimers();
|
|
1661
|
+
this.listener.clearListeners();
|
|
1662
|
+
this.danmakuCollector.clearAll();
|
|
1663
|
+
}
|
|
1664
|
+
/** Full rebootstrap. Used after auth-restored. */
|
|
1665
|
+
rebuildFromSubs(subs) {
|
|
1666
|
+
this.logger.info("[live] 重建直播间监听");
|
|
1667
|
+
this.listener.startAll(subs);
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Apply incremental subscription ops (the adapter receives these as a
|
|
1671
|
+
* `bilibili-notify/subscription-changed` event payload). Handles the same
|
|
1672
|
+
* three cases as the original live-service: add / delete / update.
|
|
1673
|
+
*/
|
|
1674
|
+
applyOps(ops, lookupFullSub) {
|
|
1675
|
+
for (const op of ops) switch (op.type) {
|
|
1676
|
+
case "add":
|
|
1677
|
+
if (!this.listener.needsLiveMonitor(op.sub)) break;
|
|
1678
|
+
this.listener.startForUid(op.sub);
|
|
1679
|
+
break;
|
|
1680
|
+
case "delete":
|
|
1681
|
+
this.listener.stopForUid(op.uid);
|
|
1682
|
+
break;
|
|
1683
|
+
case "update": {
|
|
1684
|
+
const liveChanges = op.changes.filter((c) => c.scope === "live");
|
|
1685
|
+
const targetChanges = op.changes.filter((c) => c.scope === "target");
|
|
1686
|
+
if (liveChanges.length === 0 && targetChanges.length === 0) break;
|
|
1687
|
+
const existing = this.listener.getActiveSub(op.uid);
|
|
1688
|
+
if (existing) {
|
|
1689
|
+
const prevPushTime = existing.pushTime;
|
|
1690
|
+
let nextPushTime = prevPushTime;
|
|
1691
|
+
for (const change of liveChanges) {
|
|
1692
|
+
const { scope: _scope, ...fields } = change;
|
|
1693
|
+
if ("pushTime" in fields) nextPushTime = fields.pushTime;
|
|
1694
|
+
Object.assign(existing, fields);
|
|
1695
|
+
}
|
|
1696
|
+
for (const change of targetChanges) existing.target = change.target;
|
|
1697
|
+
if (!this.listener.needsLiveMonitor(existing)) this.listener.stopForUid(op.uid);
|
|
1698
|
+
else if (nextPushTime !== prevPushTime) this.listener.rearmPeriodicTimerForUid(op.uid);
|
|
1699
|
+
} else {
|
|
1700
|
+
const fullSub = lookupFullSub(op.uid);
|
|
1701
|
+
if (fullSub && this.listener.needsLiveMonitor(fullSub)) this.listener.startForUid(fullSub);
|
|
1702
|
+
}
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
/** Replace runtime config (called when the adapter receives a config-changed event). */
|
|
1708
|
+
updateConfig(config) {
|
|
1709
|
+
const pushTimeChanged = this.config.pushTime !== config.pushTime;
|
|
1710
|
+
this.config = config;
|
|
1711
|
+
this.danmakuCollector.setStopwords(mergeStopWords(config.wordcloudStopWords));
|
|
1712
|
+
this.listener.updateConfig(toListenerConfig(config));
|
|
1713
|
+
if (pushTimeChanged) {
|
|
1714
|
+
this.logger.info(`[live] pushTime 已更新为 ${config.pushTime}h,重排所有定时器`);
|
|
1715
|
+
this.listener.rearmAllPeriodicTimers();
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* 热替换 CommentaryGenerator 实例。adapter 在用户运行时打开 / 关闭 / 更换 AI
|
|
1720
|
+
* 配置后调用,引擎随后的直播总结会立即用新实例 (或回退到模板) ,无需重启 server。
|
|
1721
|
+
*/
|
|
1722
|
+
setCommentary(commentary) {
|
|
1723
|
+
this.liveSummaryRequester.setCommentary(commentary);
|
|
1724
|
+
}
|
|
1725
|
+
/** Final dispose; the engine instance must not be reused after this. */
|
|
1726
|
+
stop() {
|
|
1727
|
+
this.listener.disposeAll();
|
|
1728
|
+
}
|
|
1729
|
+
/** Diagnostic accessor, used by the koishi shell for `[conn] state` logging. */
|
|
1730
|
+
get listenerCount() {
|
|
1731
|
+
return this.listener.getListenerCount();
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Per-room live-state snapshot for every active monitor. Routes / dashboards
|
|
1735
|
+
* filter on `isLive` to show "正在直播" panels.
|
|
1736
|
+
*/
|
|
1737
|
+
listLiveSnapshots() {
|
|
1738
|
+
return this.listener.listLiveSnapshots();
|
|
1739
|
+
}
|
|
1740
|
+
/** Read-only view of the engine config (for the koishi shell to pass through). */
|
|
1741
|
+
getConfig() {
|
|
1742
|
+
return this.config;
|
|
1743
|
+
}
|
|
1744
|
+
};
|
|
1745
|
+
function toListenerConfig(c) {
|
|
1746
|
+
return {
|
|
1747
|
+
pushTime: c.pushTime,
|
|
1748
|
+
restartPush: c.restartPush,
|
|
1749
|
+
minScPrice: c.minScPrice,
|
|
1750
|
+
minGuardLevel: c.minGuardLevel,
|
|
1751
|
+
customGuardBuy: c.customGuardBuy,
|
|
1752
|
+
customLiveMsg: c.customLiveMsg,
|
|
1753
|
+
liveSummaryDefault: c.liveSummaryDefault,
|
|
1754
|
+
imageEnabled: c.imageEnabled
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
/** Combine the bundled stop-words with the user's comma-separated additions. */
|
|
1758
|
+
function mergeStopWords(extra) {
|
|
1759
|
+
if (!extra || extra.trim() === "") return new Set(stopwords);
|
|
1760
|
+
const additions = extra.split(",").map((w) => w.trim()).filter((w) => w !== "");
|
|
1761
|
+
return new Set([...stopwords, ...additions]);
|
|
1762
|
+
}
|
|
1763
|
+
//#endregion
|
|
1764
|
+
export { DEFAULT_LIVE_TEMPLATES, DanmakuCollector, LIVE_EVENT_COOLDOWN, LIVE_ROOM_MASTER_KEYS, LIVE_SUMMARY_MIN_SENDERS, ListenerManager, LiveEngine, LivePushType, LiveSummaryRequester, LiveTemplateRenderer, LiveType, RoomContext, RoomContextBase, RoomSession, RoomSessionBase, WORDCLOUD_MIN_WORDS, WORDCLOUD_TOP_WORDS, WordcloudGenerator, buildRoomLink, stopwords as defaultStopWords, formatFollowerChange, formatFollowerCount };
|