@zhin.js/plugin-bilibili 0.0.0-stage → 0.1.1

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/poll.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type BiliClient, type DynamicItem, type LiveInfo } from './api.js';
2
+ import type { ResolvedBilibiliConfig } from './config.js';
3
+ import type { BiliOutboundPush } from './runtime.js';
4
+ import { type BiliDb, type WatchState } from './store.js';
5
+ /** At most this many new dynamics per UP are pushed in one tick. */
6
+ export declare const MAX_DYNAMICS_PER_TICK = 3;
7
+ /** Dynamics older than this are recorded as seen but never pushed. */
8
+ export declare const MAX_DYNAMIC_AGE_SECONDS: number;
9
+ export interface WatcherOptions {
10
+ readonly db: BiliDb;
11
+ readonly config: Readonly<ResolvedBilibiliConfig>;
12
+ readonly client: BiliClient;
13
+ readonly outbound: BiliOutboundPush | null;
14
+ readonly signal?: AbortSignal;
15
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
16
+ readonly random?: () => number;
17
+ readonly now?: () => number;
18
+ readonly warn?: (message: string) => void;
19
+ }
20
+ export interface TickResult {
21
+ readonly skipped?: 'busy' | 'aborted';
22
+ readonly liveChecked: number;
23
+ readonly dynamicsChecked: number;
24
+ readonly pushed: number;
25
+ readonly errors: readonly string[];
26
+ }
27
+ export declare class BilibiliWatcher {
28
+ #private;
29
+ lastError: {
30
+ readonly at: number;
31
+ readonly message: string;
32
+ } | undefined;
33
+ lastSuccessAt: number | undefined;
34
+ constructor(options: WatcherOptions);
35
+ /** Drop per-UP state once nobody subscribes to that UP any more. */
36
+ forget(uid: string): void;
37
+ /** Human readable list of sources currently backing off. */
38
+ backoffStatus(): string[];
39
+ /** Record the current live / dynamic position of one UP without pushing. */
40
+ baseline(uid: string): Promise<void>;
41
+ tick(): Promise<TickResult>;
42
+ }
43
+ /** Live start = known previous state, now live, and either was offline or a new session began. */
44
+ export declare function shouldPushLive(state: WatchState, info: LiveInfo): boolean;
45
+ export declare function markBaseline(state: WatchState, items: readonly DynamicItem[]): void;
46
+ /** New, unseen, timestamped items newer than the cursor, oldest first. */
47
+ export declare function selectFreshDynamics(state: WatchState, items: readonly DynamicItem[]): DynamicItem[];
package/lib/poll.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Conservative live / dynamic watcher.
3
+ *
4
+ * - One poll per schedule tick (default every 60s) after a random jitter.
5
+ * - Live status is fetched in one batched request for all watched UPs.
6
+ * - Dynamics are fetched per UP with a gap between requests.
7
+ * - Failures back off exponentially per source (live batch or one UP);
8
+ * risk-control responses back off harder. Nothing throws to the scheduler.
9
+ * - The first observation of an UP only records a baseline, so restarts and
10
+ * new subscriptions never flood a conversation with old items.
11
+ */
12
+ import { setTimeout as delay } from 'node:timers/promises';
13
+ import { BiliApiError } from './api.js';
14
+ import { formatDynamic, formatLiveStart } from './format.js';
15
+ import { emptyState, getBiliState, getBiliSubs, loadState, saveState, wantsDynamic, wantsLive, } from './store.js';
16
+ /** At most this many new dynamics per UP are pushed in one tick. */
17
+ export const MAX_DYNAMICS_PER_TICK = 3;
18
+ /** Dynamics older than this are recorded as seen but never pushed. */
19
+ export const MAX_DYNAMIC_AGE_SECONDS = 6 * 60 * 60;
20
+ async function defaultSleep(ms, signal) {
21
+ if (ms <= 0)
22
+ return;
23
+ try {
24
+ await delay(ms, undefined, signal ? { signal } : undefined);
25
+ }
26
+ catch {
27
+ // Aborted: the caller checks the signal.
28
+ }
29
+ }
30
+ export class BilibiliWatcher {
31
+ #options;
32
+ #backoff = new Map();
33
+ #running = false;
34
+ lastError;
35
+ lastSuccessAt;
36
+ constructor(options) {
37
+ this.#options = options;
38
+ }
39
+ get #now() {
40
+ return (this.#options.now ?? Date.now)();
41
+ }
42
+ #sleep(ms) {
43
+ return (this.#options.sleep ?? defaultSleep)(ms, this.#options.signal);
44
+ }
45
+ #aborted() {
46
+ return this.#options.signal?.aborted === true;
47
+ }
48
+ #blocked(key) {
49
+ const entry = this.#backoff.get(key);
50
+ return entry !== undefined && entry.until > this.#now;
51
+ }
52
+ #fail(key, error) {
53
+ const previous = this.#backoff.get(key);
54
+ const risk = error instanceof BiliApiError && error.kind === 'risk';
55
+ const failures = (previous?.failures ?? 0) + (risk ? 2 : 1);
56
+ const minutes = Math.min(this.#options.config.maxBackoffMinutes, 2 ** Math.min(failures - 1, 10));
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ this.#backoff.set(key, { failures, until: this.#now + minutes * 60_000, reason: message });
59
+ this.lastError = { at: this.#now, message: `${key}: ${message}` };
60
+ (this.#options.warn ?? console.warn)(`[bilibili] ${key} 请求失败,${minutes} 分钟后重试:${message}`);
61
+ return `${key}: ${message}`;
62
+ }
63
+ #ok(key) {
64
+ this.#backoff.delete(key);
65
+ }
66
+ /** Drop per-UP state once nobody subscribes to that UP any more. */
67
+ forget(uid) {
68
+ this.#backoff.delete(`dynamic:${uid}`);
69
+ }
70
+ /** Human readable list of sources currently backing off. */
71
+ backoffStatus() {
72
+ const now = this.#now;
73
+ return [...this.#backoff.entries()]
74
+ .filter(([, entry]) => entry.until > now)
75
+ .map(([key, entry]) => `${key}:${Math.ceil((entry.until - now) / 60_000)} 分钟后重试(${entry.reason})`);
76
+ }
77
+ async #push(rows, content) {
78
+ const push = this.#options.outbound;
79
+ if (!push)
80
+ return 0;
81
+ let pushed = 0;
82
+ for (const row of rows) {
83
+ const { adapter_name: adapterName, endpoint_id: endpointKey, channel_type: channelType, channel_id: channelId } = row;
84
+ if (typeof adapterName !== 'string' || !adapterName || typeof endpointKey !== 'string' || !endpointKey
85
+ || typeof channelId !== 'string' || !channelId
86
+ || (channelType !== 'private' && channelType !== 'group' && channelType !== 'channel'))
87
+ continue;
88
+ try {
89
+ await push({ adapterName, endpointKey, channelType, channelId, content });
90
+ pushed += 1;
91
+ }
92
+ catch {
93
+ // OutboundHost already logs; keep delivering to other subscribers.
94
+ }
95
+ }
96
+ return pushed;
97
+ }
98
+ /** Record the current live / dynamic position of one UP without pushing. */
99
+ async baseline(uid) {
100
+ const State = getBiliState(this.#options.db);
101
+ if (!State)
102
+ return;
103
+ const state = (await loadState(State, uid)) ?? emptyState(uid);
104
+ const { config, client } = this.#options;
105
+ if (config.live) {
106
+ try {
107
+ const info = (await client.getLiveStatus([uid])).get(uid);
108
+ if (info) {
109
+ state.liveStatus = info.liveStatus;
110
+ state.liveTime = info.liveTime;
111
+ }
112
+ }
113
+ catch { /* the next tick records the baseline */ }
114
+ }
115
+ if (config.dynamic && !state.dynamicReady) {
116
+ try {
117
+ const items = await client.getDynamics(uid);
118
+ if (items.length > 0)
119
+ markBaseline(state, items);
120
+ }
121
+ catch { /* the next tick records the baseline */ }
122
+ }
123
+ await saveState(State, state);
124
+ }
125
+ async tick() {
126
+ if (this.#running)
127
+ return { skipped: 'busy', liveChecked: 0, dynamicsChecked: 0, pushed: 0, errors: [] };
128
+ this.#running = true;
129
+ try {
130
+ return await this.#tick();
131
+ }
132
+ finally {
133
+ this.#running = false;
134
+ }
135
+ }
136
+ async #tick() {
137
+ const { db, config, client } = this.#options;
138
+ const Subs = getBiliSubs(db);
139
+ const State = getBiliState(db);
140
+ const errors = [];
141
+ let pushed = 0;
142
+ let liveChecked = 0;
143
+ let dynamicsChecked = 0;
144
+ if (!Subs || !State)
145
+ return { liveChecked, dynamicsChecked, pushed, errors: ['数据库尚未就绪'] };
146
+ const rows = await Subs.select();
147
+ if (rows.length === 0)
148
+ return { liveChecked, dynamicsChecked, pushed, errors };
149
+ const byUid = new Map();
150
+ for (const row of rows) {
151
+ if (typeof row.uid !== 'string' || !row.uid)
152
+ continue;
153
+ const list = byUid.get(row.uid) ?? [];
154
+ list.push(row);
155
+ byUid.set(row.uid, list);
156
+ }
157
+ if (config.jitterMs > 0)
158
+ await this.#sleep(Math.floor((this.#options.random ?? Math.random)() * config.jitterMs));
159
+ if (this.#aborted())
160
+ return { skipped: 'aborted', liveChecked, dynamicsChecked, pushed, errors };
161
+ if (config.live) {
162
+ const liveUids = [...byUid.entries()].filter(([, subs]) => subs.some((s) => wantsLive(s.kinds))).map(([uid]) => uid);
163
+ if (liveUids.length > 0 && !this.#blocked('live')) {
164
+ try {
165
+ const statuses = await client.getLiveStatus(liveUids);
166
+ this.#ok('live');
167
+ for (const uid of liveUids) {
168
+ const info = statuses.get(uid);
169
+ if (!info)
170
+ continue;
171
+ liveChecked += 1;
172
+ const state = (await loadState(State, uid)) ?? emptyState(uid);
173
+ if (shouldPushLive(state, info)) {
174
+ pushed += await this.#push(byUid.get(uid).filter((s) => wantsLive(s.kinds)), formatLiveStart(info));
175
+ }
176
+ if (state.liveStatus !== info.liveStatus || state.liveTime !== info.liveTime) {
177
+ state.liveStatus = info.liveStatus;
178
+ state.liveTime = info.liveTime;
179
+ await saveState(State, state);
180
+ }
181
+ }
182
+ }
183
+ catch (error) {
184
+ errors.push(this.#fail('live', error));
185
+ }
186
+ }
187
+ }
188
+ if (config.dynamic) {
189
+ let first = true;
190
+ for (const [uid, subs] of byUid) {
191
+ const targets = subs.filter((s) => wantsDynamic(s.kinds));
192
+ const key = `dynamic:${uid}`;
193
+ if (targets.length === 0 || this.#blocked(key))
194
+ continue;
195
+ if (!first && config.requestGapMs > 0)
196
+ await this.#sleep(config.requestGapMs);
197
+ first = false;
198
+ if (this.#aborted())
199
+ return { skipped: 'aborted', liveChecked, dynamicsChecked, pushed, errors };
200
+ try {
201
+ const items = await client.getDynamics(uid);
202
+ this.#ok(key);
203
+ dynamicsChecked += 1;
204
+ const state = (await loadState(State, uid)) ?? emptyState(uid);
205
+ // An empty feed is also what a soft risk-control block looks like:
206
+ // never treat it as a baseline, or the next real answer would flood.
207
+ if (items.length === 0)
208
+ continue;
209
+ if (!state.dynamicReady) {
210
+ markBaseline(state, items);
211
+ }
212
+ else {
213
+ const fresh = selectFreshDynamics(state, items);
214
+ const uname = typeof subs[0]?.uname === 'string' ? subs[0].uname : uid;
215
+ const recent = fresh.filter((item) => item.pubTs >= Math.floor(this.#now / 1000) - MAX_DYNAMIC_AGE_SECONDS);
216
+ for (const item of recent.slice(-MAX_DYNAMICS_PER_TICK)) {
217
+ pushed += await this.#push(targets, formatDynamic(uname, item));
218
+ }
219
+ for (const item of fresh) {
220
+ state.seenDynamics.push(item.id);
221
+ state.lastDynamicTs = Math.max(state.lastDynamicTs, item.pubTs);
222
+ }
223
+ if (fresh.length === 0)
224
+ continue;
225
+ }
226
+ await saveState(State, state);
227
+ }
228
+ catch (error) {
229
+ errors.push(this.#fail(key, error));
230
+ // A risk-control answer applies to the whole client; stop this round.
231
+ if (error instanceof BiliApiError && error.kind === 'risk')
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ if (errors.length === 0)
237
+ this.lastSuccessAt = this.#now;
238
+ return { liveChecked, dynamicsChecked, pushed, errors };
239
+ }
240
+ }
241
+ /** Live start = known previous state, now live, and either was offline or a new session began. */
242
+ export function shouldPushLive(state, info) {
243
+ if (info.liveStatus !== 1 || state.liveStatus === -1)
244
+ return false;
245
+ if (state.liveStatus !== 1)
246
+ return true;
247
+ return info.liveTime > 0 && state.liveTime > 0 && info.liveTime !== state.liveTime;
248
+ }
249
+ export function markBaseline(state, items) {
250
+ for (const item of items) {
251
+ if (!state.seenDynamics.includes(item.id))
252
+ state.seenDynamics.push(item.id);
253
+ state.lastDynamicTs = Math.max(state.lastDynamicTs, item.pubTs);
254
+ }
255
+ state.dynamicReady = true;
256
+ }
257
+ /** New, unseen, timestamped items newer than the cursor, oldest first. */
258
+ export function selectFreshDynamics(state, items) {
259
+ const seen = new Set(state.seenDynamics);
260
+ return items
261
+ .filter((item) => item.pubTs > 0 && item.pubTs > state.lastDynamicTs && !seen.has(item.id))
262
+ .sort((a, b) => a.pubTs - b.pubTs);
263
+ }
264
+ //# sourceMappingURL=poll.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"poll.js","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Conservative live / dynamic watcher.\n *\n * - One poll per schedule tick (default every 60s) after a random jitter.\n * - Live status is fetched in one batched request for all watched UPs.\n * - Dynamics are fetched per UP with a gap between requests.\n * - Failures back off exponentially per source (live batch or one UP);\n * risk-control responses back off harder. Nothing throws to the scheduler.\n * - The first observation of an UP only records a baseline, so restarts and\n * new subscriptions never flood a conversation with old items.\n */\nimport { setTimeout as delay } from 'node:timers/promises';\nimport { BiliApiError, type BiliClient, type DynamicItem, type LiveInfo } from './api.js';\nimport type { ResolvedBilibiliConfig } from './config.js';\nimport { formatDynamic, formatLiveStart } from './format.js';\nimport type { BiliOutboundPush } from './runtime.js';\nimport {\n emptyState,\n getBiliState,\n getBiliSubs,\n loadState,\n saveState,\n wantsDynamic,\n wantsLive,\n type BiliDb,\n type BiliRow,\n type WatchState,\n} from './store.js';\n\n/** At most this many new dynamics per UP are pushed in one tick. */\nexport const MAX_DYNAMICS_PER_TICK = 3;\n/** Dynamics older than this are recorded as seen but never pushed. */\nexport const MAX_DYNAMIC_AGE_SECONDS = 6 * 60 * 60;\n\nexport interface WatcherOptions {\n readonly db: BiliDb;\n readonly config: Readonly<ResolvedBilibiliConfig>;\n readonly client: BiliClient;\n readonly outbound: BiliOutboundPush | null;\n readonly signal?: AbortSignal;\n readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;\n readonly random?: () => number;\n readonly now?: () => number;\n readonly warn?: (message: string) => void;\n}\n\nexport interface TickResult {\n readonly skipped?: 'busy' | 'aborted';\n readonly liveChecked: number;\n readonly dynamicsChecked: number;\n readonly pushed: number;\n readonly errors: readonly string[];\n}\n\ninterface Backoff {\n failures: number;\n until: number;\n reason: string;\n}\n\nasync function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (ms <= 0) return;\n try {\n await delay(ms, undefined, signal ? { signal } : undefined);\n } catch {\n // Aborted: the caller checks the signal.\n }\n}\n\nexport class BilibiliWatcher {\n readonly #options: WatcherOptions;\n readonly #backoff = new Map<string, Backoff>();\n #running = false;\n lastError: { readonly at: number; readonly message: string } | undefined;\n lastSuccessAt: number | undefined;\n\n constructor(options: WatcherOptions) {\n this.#options = options;\n }\n\n get #now(): number {\n return (this.#options.now ?? Date.now)();\n }\n\n #sleep(ms: number): Promise<void> {\n return (this.#options.sleep ?? defaultSleep)(ms, this.#options.signal);\n }\n\n #aborted(): boolean {\n return this.#options.signal?.aborted === true;\n }\n\n #blocked(key: string): boolean {\n const entry = this.#backoff.get(key);\n return entry !== undefined && entry.until > this.#now;\n }\n\n #fail(key: string, error: unknown): string {\n const previous = this.#backoff.get(key);\n const risk = error instanceof BiliApiError && error.kind === 'risk';\n const failures = (previous?.failures ?? 0) + (risk ? 2 : 1);\n const minutes = Math.min(this.#options.config.maxBackoffMinutes, 2 ** Math.min(failures - 1, 10));\n const message = error instanceof Error ? error.message : String(error);\n this.#backoff.set(key, { failures, until: this.#now + minutes * 60_000, reason: message });\n this.lastError = { at: this.#now, message: `${key}: ${message}` };\n (this.#options.warn ?? console.warn)(`[bilibili] ${key} 请求失败,${minutes} 分钟后重试:${message}`);\n return `${key}: ${message}`;\n }\n\n #ok(key: string): void {\n this.#backoff.delete(key);\n }\n\n /** Drop per-UP state once nobody subscribes to that UP any more. */\n forget(uid: string): void {\n this.#backoff.delete(`dynamic:${uid}`);\n }\n\n /** Human readable list of sources currently backing off. */\n backoffStatus(): string[] {\n const now = this.#now;\n return [...this.#backoff.entries()]\n .filter(([, entry]) => entry.until > now)\n .map(([key, entry]) => `${key}:${Math.ceil((entry.until - now) / 60_000)} 分钟后重试(${entry.reason})`);\n }\n\n async #push(rows: readonly BiliRow[], content: string): Promise<number> {\n const push = this.#options.outbound;\n if (!push) return 0;\n let pushed = 0;\n for (const row of rows) {\n const { adapter_name: adapterName, endpoint_id: endpointKey, channel_type: channelType, channel_id: channelId } = row;\n if (typeof adapterName !== 'string' || !adapterName || typeof endpointKey !== 'string' || !endpointKey\n || typeof channelId !== 'string' || !channelId\n || (channelType !== 'private' && channelType !== 'group' && channelType !== 'channel')) continue;\n try {\n await push({ adapterName, endpointKey, channelType, channelId, content });\n pushed += 1;\n } catch {\n // OutboundHost already logs; keep delivering to other subscribers.\n }\n }\n return pushed;\n }\n\n /** Record the current live / dynamic position of one UP without pushing. */\n async baseline(uid: string): Promise<void> {\n const State = getBiliState(this.#options.db);\n if (!State) return;\n const state = (await loadState(State, uid)) ?? emptyState(uid);\n const { config, client } = this.#options;\n if (config.live) {\n try {\n const info = (await client.getLiveStatus([uid])).get(uid);\n if (info) {\n state.liveStatus = info.liveStatus;\n state.liveTime = info.liveTime;\n }\n } catch { /* the next tick records the baseline */ }\n }\n if (config.dynamic && !state.dynamicReady) {\n try {\n const items = await client.getDynamics(uid);\n if (items.length > 0) markBaseline(state, items);\n } catch { /* the next tick records the baseline */ }\n }\n await saveState(State, state);\n }\n\n async tick(): Promise<TickResult> {\n if (this.#running) return { skipped: 'busy', liveChecked: 0, dynamicsChecked: 0, pushed: 0, errors: [] };\n this.#running = true;\n try {\n return await this.#tick();\n } finally {\n this.#running = false;\n }\n }\n\n async #tick(): Promise<TickResult> {\n const { db, config, client } = this.#options;\n const Subs = getBiliSubs(db);\n const State = getBiliState(db);\n const errors: string[] = [];\n let pushed = 0;\n let liveChecked = 0;\n let dynamicsChecked = 0;\n if (!Subs || !State) return { liveChecked, dynamicsChecked, pushed, errors: ['数据库尚未就绪'] };\n\n const rows = await Subs.select();\n if (rows.length === 0) return { liveChecked, dynamicsChecked, pushed, errors };\n const byUid = new Map<string, BiliRow[]>();\n for (const row of rows) {\n if (typeof row.uid !== 'string' || !row.uid) continue;\n const list = byUid.get(row.uid) ?? [];\n list.push(row);\n byUid.set(row.uid, list);\n }\n\n if (config.jitterMs > 0) await this.#sleep(Math.floor((this.#options.random ?? Math.random)() * config.jitterMs));\n if (this.#aborted()) return { skipped: 'aborted', liveChecked, dynamicsChecked, pushed, errors };\n\n if (config.live) {\n const liveUids = [...byUid.entries()].filter(([, subs]) => subs.some((s) => wantsLive(s.kinds))).map(([uid]) => uid);\n if (liveUids.length > 0 && !this.#blocked('live')) {\n try {\n const statuses = await client.getLiveStatus(liveUids);\n this.#ok('live');\n for (const uid of liveUids) {\n const info = statuses.get(uid);\n if (!info) continue;\n liveChecked += 1;\n const state = (await loadState(State, uid)) ?? emptyState(uid);\n if (shouldPushLive(state, info)) {\n pushed += await this.#push(byUid.get(uid)!.filter((s) => wantsLive(s.kinds)), formatLiveStart(info));\n }\n if (state.liveStatus !== info.liveStatus || state.liveTime !== info.liveTime) {\n state.liveStatus = info.liveStatus;\n state.liveTime = info.liveTime;\n await saveState(State, state);\n }\n }\n } catch (error) {\n errors.push(this.#fail('live', error));\n }\n }\n }\n\n if (config.dynamic) {\n let first = true;\n for (const [uid, subs] of byUid) {\n const targets = subs.filter((s) => wantsDynamic(s.kinds));\n const key = `dynamic:${uid}`;\n if (targets.length === 0 || this.#blocked(key)) continue;\n if (!first && config.requestGapMs > 0) await this.#sleep(config.requestGapMs);\n first = false;\n if (this.#aborted()) return { skipped: 'aborted', liveChecked, dynamicsChecked, pushed, errors };\n try {\n const items = await client.getDynamics(uid);\n this.#ok(key);\n dynamicsChecked += 1;\n const state = (await loadState(State, uid)) ?? emptyState(uid);\n // An empty feed is also what a soft risk-control block looks like:\n // never treat it as a baseline, or the next real answer would flood.\n if (items.length === 0) continue;\n if (!state.dynamicReady) {\n markBaseline(state, items);\n } else {\n const fresh = selectFreshDynamics(state, items);\n const uname = typeof subs[0]?.uname === 'string' ? subs[0].uname : uid;\n const recent = fresh.filter((item) => item.pubTs >= Math.floor(this.#now / 1000) - MAX_DYNAMIC_AGE_SECONDS);\n for (const item of recent.slice(-MAX_DYNAMICS_PER_TICK)) {\n pushed += await this.#push(targets, formatDynamic(uname, item));\n }\n for (const item of fresh) {\n state.seenDynamics.push(item.id);\n state.lastDynamicTs = Math.max(state.lastDynamicTs, item.pubTs);\n }\n if (fresh.length === 0) continue;\n }\n await saveState(State, state);\n } catch (error) {\n errors.push(this.#fail(key, error));\n // A risk-control answer applies to the whole client; stop this round.\n if (error instanceof BiliApiError && error.kind === 'risk') break;\n }\n }\n }\n if (errors.length === 0) this.lastSuccessAt = this.#now;\n return { liveChecked, dynamicsChecked, pushed, errors };\n }\n}\n\n/** Live start = known previous state, now live, and either was offline or a new session began. */\nexport function shouldPushLive(state: WatchState, info: LiveInfo): boolean {\n if (info.liveStatus !== 1 || state.liveStatus === -1) return false;\n if (state.liveStatus !== 1) return true;\n return info.liveTime > 0 && state.liveTime > 0 && info.liveTime !== state.liveTime;\n}\n\nexport function markBaseline(state: WatchState, items: readonly DynamicItem[]): void {\n for (const item of items) {\n if (!state.seenDynamics.includes(item.id)) state.seenDynamics.push(item.id);\n state.lastDynamicTs = Math.max(state.lastDynamicTs, item.pubTs);\n }\n state.dynamicReady = true;\n}\n\n/** New, unseen, timestamped items newer than the cursor, oldest first. */\nexport function selectFreshDynamics(state: WatchState, items: readonly DynamicItem[]): DynamicItem[] {\n const seen = new Set(state.seenDynamics);\n return items\n .filter((item) => item.pubTs > 0 && item.pubTs > state.lastDynamicTs && !seen.has(item.id))\n .sort((a, b) => a.pubTs - b.pubTs);\n}\n"]}
@@ -0,0 +1,22 @@
1
+ import type { BiliClient } from './api.js';
2
+ import type { ChannelInfo } from './channel.js';
3
+ import type { ResolvedBilibiliConfig } from './config.js';
4
+ import type { BiliDb } from './store.js';
5
+ import type { BilibiliWatcher } from './poll.js';
6
+ export type BiliOutboundPush = (input: {
7
+ readonly adapterName: string;
8
+ readonly endpointKey: string;
9
+ readonly channelType: ChannelInfo['channelType'];
10
+ readonly channelId: string;
11
+ readonly content: string;
12
+ }) => Promise<void>;
13
+ export interface BilibiliRuntime {
14
+ readonly db: BiliDb;
15
+ readonly config: Readonly<ResolvedBilibiliConfig>;
16
+ readonly client: BiliClient;
17
+ readonly outbound: BiliOutboundPush | null;
18
+ readonly watcher: BilibiliWatcher;
19
+ /** False when no database is configured (subscriptions live in memory). */
20
+ readonly persistent: boolean;
21
+ }
22
+ export declare const bilibiliRuntimeToken: import("zhin.js").Token<BilibiliRuntime>;
package/lib/runtime.js ADDED
@@ -0,0 +1,3 @@
1
+ import { createToken } from 'zhin.js';
2
+ export const bilibiliRuntimeToken = createToken('zhin.bilibili.runtime', 'Owner-scoped Bilibili subscription runtime');
3
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAyBtC,MAAM,CAAC,MAAM,oBAAoB,GAAG,WAAW,CAC7C,uBAAuB,EACvB,4CAA4C,CAC7C,CAAC","sourcesContent":["import { createToken } from 'zhin.js';\nimport type { BiliClient } from './api.js';\nimport type { ChannelInfo } from './channel.js';\nimport type { ResolvedBilibiliConfig } from './config.js';\nimport type { BiliDb } from './store.js';\nimport type { BilibiliWatcher } from './poll.js';\n\nexport type BiliOutboundPush = (input: {\n readonly adapterName: string;\n readonly endpointKey: string;\n readonly channelType: ChannelInfo['channelType'];\n readonly channelId: string;\n readonly content: string;\n}) => Promise<void>;\n\nexport interface BilibiliRuntime {\n readonly db: BiliDb;\n readonly config: Readonly<ResolvedBilibiliConfig>;\n readonly client: BiliClient;\n readonly outbound: BiliOutboundPush | null;\n readonly watcher: BilibiliWatcher;\n /** False when no database is configured (subscriptions live in memory). */\n readonly persistent: boolean;\n}\n\nexport const bilibiliRuntimeToken = createToken<BilibiliRuntime>(\n 'zhin.bilibili.runtime',\n 'Owner-scoped Bilibili subscription runtime',\n);\n"]}
package/lib/store.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Subscription / watcher-state tables.
3
+ *
4
+ * The model surface matches the Plugin Runtime `DatabaseHostModel`, so the
5
+ * scoped `databaseHostToken` facade is used directly when a database is
6
+ * configured; otherwise an in-memory store keeps chat-only bots working
7
+ * (subscriptions are then lost on restart).
8
+ */
9
+ export type BiliRow = Record<string, unknown>;
10
+ export interface BiliModel {
11
+ select: (...fields: string[]) => {
12
+ where: (query: Record<string, unknown>) => PromiseLike<BiliRow[]>;
13
+ then: <TResult1 = BiliRow[], TResult2 = never>(onfulfilled?: ((value: BiliRow[]) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null) => Promise<TResult1 | TResult2>;
14
+ };
15
+ insert: (row: Record<string, unknown>) => Promise<unknown>;
16
+ delete: () => {
17
+ where: (query: Record<string, unknown>) => Promise<unknown>;
18
+ };
19
+ update: (patch: Record<string, unknown>) => {
20
+ where: (query: Record<string, unknown>) => Promise<unknown>;
21
+ };
22
+ }
23
+ export interface BiliDb {
24
+ models: {
25
+ get: (name: string) => BiliModel | undefined;
26
+ };
27
+ }
28
+ export declare const BILI_SUBS_TABLE = "bilibili_subscriptions";
29
+ export declare const BILI_STATE_TABLE = "bilibili_watch_state";
30
+ export declare const BILI_TABLE_DEFINITIONS: Readonly<Record<string, Record<string, unknown>>>;
31
+ export declare function defineBiliTables(db: {
32
+ define: (name: string, schema: Record<string, unknown>) => void;
33
+ }): void;
34
+ export declare function createInMemoryBiliDb(): BiliDb;
35
+ export declare function getBiliSubs(db: BiliDb): BiliModel | null;
36
+ export declare function getBiliState(db: BiliDb): BiliModel | null;
37
+ export type SubscriptionKinds = 'all' | 'live' | 'dynamic';
38
+ export declare function parseKinds(input: string): SubscriptionKinds | undefined;
39
+ export declare function wantsLive(kinds: unknown): boolean;
40
+ export declare function wantsDynamic(kinds: unknown): boolean;
41
+ export interface WatchState {
42
+ uid: string;
43
+ liveStatus: number;
44
+ liveTime: number;
45
+ lastDynamicTs: number;
46
+ seenDynamics: string[];
47
+ dynamicReady: boolean;
48
+ }
49
+ export declare function loadState(model: BiliModel, uid: string): Promise<WatchState | undefined>;
50
+ export declare const SEEN_DYNAMICS_LIMIT = 40;
51
+ export declare function saveState(model: BiliModel, state: WatchState): Promise<void>;
52
+ export declare function emptyState(uid: string): WatchState;
package/lib/store.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Subscription / watcher-state tables.
3
+ *
4
+ * The model surface matches the Plugin Runtime `DatabaseHostModel`, so the
5
+ * scoped `databaseHostToken` facade is used directly when a database is
6
+ * configured; otherwise an in-memory store keeps chat-only bots working
7
+ * (subscriptions are then lost on restart).
8
+ */
9
+ export const BILI_SUBS_TABLE = 'bilibili_subscriptions';
10
+ export const BILI_STATE_TABLE = 'bilibili_watch_state';
11
+ export const BILI_TABLE_DEFINITIONS = Object.freeze({
12
+ [BILI_SUBS_TABLE]: {
13
+ uid: { type: 'text', nullable: false },
14
+ uname: { type: 'text', default: '' },
15
+ /** `all` | `live` | `dynamic` */
16
+ kinds: { type: 'text', default: 'all' },
17
+ adapter_name: { type: 'text', nullable: false },
18
+ endpoint_id: { type: 'text', default: '' },
19
+ channel_type: { type: 'text', default: 'private' },
20
+ channel_id: { type: 'text', nullable: false },
21
+ creator_id: { type: 'text', default: '' },
22
+ created_at: { type: 'text', default: '' },
23
+ },
24
+ [BILI_STATE_TABLE]: {
25
+ uid: { type: 'text', nullable: false },
26
+ live_status: { type: 'integer', default: -1 },
27
+ live_time: { type: 'integer', default: 0 },
28
+ last_dynamic_ts: { type: 'integer', default: 0 },
29
+ /** JSON array of recently pushed dynamic ids (dedupe across restarts). */
30
+ seen_dynamics: { type: 'text', default: '[]' },
31
+ dynamic_ready: { type: 'integer', default: 0 },
32
+ updated_at: { type: 'text', default: '' },
33
+ },
34
+ });
35
+ export function defineBiliTables(db) {
36
+ for (const [name, definition] of Object.entries(BILI_TABLE_DEFINITIONS))
37
+ db.define(name, definition);
38
+ }
39
+ function createMemoryModel() {
40
+ const rows = [];
41
+ let nextId = 1;
42
+ const matches = (row, query) => Object.entries(query).every(([key, value]) => row[key] === value);
43
+ return {
44
+ select: () => {
45
+ const promise = Promise.resolve().then(() => rows.map((row) => ({ ...row })));
46
+ return {
47
+ where: async (query) => rows.filter((row) => matches(row, query)).map((row) => ({ ...row })),
48
+ then: (onfulfilled, onrejected) => promise.then(onfulfilled, onrejected),
49
+ };
50
+ },
51
+ insert: async (row) => {
52
+ const withId = { id: String(nextId++), ...row };
53
+ rows.push(withId);
54
+ return { ...withId };
55
+ },
56
+ delete: () => ({
57
+ where: async (query) => {
58
+ for (let index = rows.length - 1; index >= 0; index -= 1)
59
+ if (matches(rows[index], query))
60
+ rows.splice(index, 1);
61
+ },
62
+ }),
63
+ update: (patch) => ({
64
+ where: async (query) => {
65
+ for (const row of rows)
66
+ if (matches(row, query))
67
+ Object.assign(row, patch);
68
+ },
69
+ }),
70
+ };
71
+ }
72
+ export function createInMemoryBiliDb() {
73
+ const models = new Map(Object.keys(BILI_TABLE_DEFINITIONS).map((name) => [name, createMemoryModel()]));
74
+ return { models: { get: (name) => models.get(name) } };
75
+ }
76
+ export function getBiliSubs(db) {
77
+ return db.models.get(BILI_SUBS_TABLE) ?? null;
78
+ }
79
+ export function getBiliState(db) {
80
+ return db.models.get(BILI_STATE_TABLE) ?? null;
81
+ }
82
+ export function parseKinds(input) {
83
+ const text = input.trim().toLowerCase();
84
+ if (!text || text === 'all' || text === '全部')
85
+ return 'all';
86
+ if (text === 'live' || text === '直播' || text === '开播')
87
+ return 'live';
88
+ if (text === 'dynamic' || text === 'dyn' || text === '动态')
89
+ return 'dynamic';
90
+ return undefined;
91
+ }
92
+ export function wantsLive(kinds) {
93
+ return kinds === 'all' || kinds === 'live' || kinds === undefined || kinds === '';
94
+ }
95
+ export function wantsDynamic(kinds) {
96
+ return kinds === 'all' || kinds === 'dynamic' || kinds === undefined || kinds === '';
97
+ }
98
+ function intOf(value, fallback) {
99
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
100
+ return Number.isFinite(parsed) ? parsed : fallback;
101
+ }
102
+ export async function loadState(model, uid) {
103
+ const [row] = await model.select().where({ uid });
104
+ if (!row)
105
+ return undefined;
106
+ let seen = [];
107
+ try {
108
+ const parsed = JSON.parse(typeof row.seen_dynamics === 'string' ? row.seen_dynamics : '[]');
109
+ if (Array.isArray(parsed))
110
+ seen = parsed.filter((id) => typeof id === 'string');
111
+ }
112
+ catch { /* corrupted cache: start over */ }
113
+ return {
114
+ uid,
115
+ liveStatus: intOf(row.live_status, -1),
116
+ liveTime: intOf(row.live_time, 0),
117
+ lastDynamicTs: intOf(row.last_dynamic_ts, 0),
118
+ seenDynamics: seen,
119
+ dynamicReady: intOf(row.dynamic_ready, 0) === 1,
120
+ };
121
+ }
122
+ export const SEEN_DYNAMICS_LIMIT = 40;
123
+ export async function saveState(model, state) {
124
+ const patch = {
125
+ live_status: state.liveStatus,
126
+ live_time: state.liveTime,
127
+ last_dynamic_ts: state.lastDynamicTs,
128
+ seen_dynamics: JSON.stringify(state.seenDynamics.slice(-SEEN_DYNAMICS_LIMIT)),
129
+ dynamic_ready: state.dynamicReady ? 1 : 0,
130
+ updated_at: new Date().toISOString(),
131
+ };
132
+ const existing = await model.select().where({ uid: state.uid });
133
+ if (existing.length > 0)
134
+ await model.update(patch).where({ uid: state.uid });
135
+ else
136
+ await model.insert({ uid: state.uid, ...patch });
137
+ }
138
+ export function emptyState(uid) {
139
+ return { uid, liveStatus: -1, liveTime: 0, lastDynamicTs: 0, seenDynamics: [], dynamicReady: false };
140
+ }
141
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Subscription / watcher-state tables.\n *\n * The model surface matches the Plugin Runtime `DatabaseHostModel`, so the\n * scoped `databaseHostToken` facade is used directly when a database is\n * configured; otherwise an in-memory store keeps chat-only bots working\n * (subscriptions are then lost on restart).\n */\n\nexport type BiliRow = Record<string, unknown>;\n\nexport interface BiliModel {\n select: (...fields: string[]) => {\n where: (query: Record<string, unknown>) => PromiseLike<BiliRow[]>;\n then: <TResult1 = BiliRow[], TResult2 = never>(\n onfulfilled?: ((value: BiliRow[]) => TResult1 | PromiseLike<TResult1>) | null,\n onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,\n ) => Promise<TResult1 | TResult2>;\n };\n insert: (row: Record<string, unknown>) => Promise<unknown>;\n delete: () => { where: (query: Record<string, unknown>) => Promise<unknown> };\n update: (patch: Record<string, unknown>) => {\n where: (query: Record<string, unknown>) => Promise<unknown>;\n };\n}\n\nexport interface BiliDb {\n models: { get: (name: string) => BiliModel | undefined };\n}\n\nexport const BILI_SUBS_TABLE = 'bilibili_subscriptions';\nexport const BILI_STATE_TABLE = 'bilibili_watch_state';\n\nexport const BILI_TABLE_DEFINITIONS: Readonly<Record<string, Record<string, unknown>>> = Object.freeze({\n [BILI_SUBS_TABLE]: {\n uid: { type: 'text', nullable: false },\n uname: { type: 'text', default: '' },\n /** `all` | `live` | `dynamic` */\n kinds: { type: 'text', default: 'all' },\n adapter_name: { type: 'text', nullable: false },\n endpoint_id: { type: 'text', default: '' },\n channel_type: { type: 'text', default: 'private' },\n channel_id: { type: 'text', nullable: false },\n creator_id: { type: 'text', default: '' },\n created_at: { type: 'text', default: '' },\n },\n [BILI_STATE_TABLE]: {\n uid: { type: 'text', nullable: false },\n live_status: { type: 'integer', default: -1 },\n live_time: { type: 'integer', default: 0 },\n last_dynamic_ts: { type: 'integer', default: 0 },\n /** JSON array of recently pushed dynamic ids (dedupe across restarts). */\n seen_dynamics: { type: 'text', default: '[]' },\n dynamic_ready: { type: 'integer', default: 0 },\n updated_at: { type: 'text', default: '' },\n },\n});\n\nexport function defineBiliTables(db: { define: (name: string, schema: Record<string, unknown>) => void }): void {\n for (const [name, definition] of Object.entries(BILI_TABLE_DEFINITIONS)) db.define(name, definition);\n}\n\nfunction createMemoryModel(): BiliModel {\n const rows: BiliRow[] = [];\n let nextId = 1;\n const matches = (row: BiliRow, query: Record<string, unknown>): boolean =>\n Object.entries(query).every(([key, value]) => row[key] === value);\n return {\n select: () => {\n const promise = Promise.resolve().then(() => rows.map((row) => ({ ...row })));\n return {\n where: async (query: Record<string, unknown>) => rows.filter((row) => matches(row, query)).map((row) => ({ ...row })),\n then: (onfulfilled, onrejected) => promise.then(onfulfilled, onrejected),\n };\n },\n insert: async (row: Record<string, unknown>) => {\n const withId: BiliRow = { id: String(nextId++), ...row };\n rows.push(withId);\n return { ...withId };\n },\n delete: () => ({\n where: async (query: Record<string, unknown>) => {\n for (let index = rows.length - 1; index >= 0; index -= 1) if (matches(rows[index]!, query)) rows.splice(index, 1);\n },\n }),\n update: (patch: Record<string, unknown>) => ({\n where: async (query: Record<string, unknown>) => {\n for (const row of rows) if (matches(row, query)) Object.assign(row, patch);\n },\n }),\n };\n}\n\nexport function createInMemoryBiliDb(): BiliDb {\n const models = new Map<string, BiliModel>(Object.keys(BILI_TABLE_DEFINITIONS).map((name) => [name, createMemoryModel()]));\n return { models: { get: (name: string) => models.get(name) } };\n}\n\nexport function getBiliSubs(db: BiliDb): BiliModel | null {\n return db.models.get(BILI_SUBS_TABLE) ?? null;\n}\n\nexport function getBiliState(db: BiliDb): BiliModel | null {\n return db.models.get(BILI_STATE_TABLE) ?? null;\n}\n\nexport type SubscriptionKinds = 'all' | 'live' | 'dynamic';\n\nexport function parseKinds(input: string): SubscriptionKinds | undefined {\n const text = input.trim().toLowerCase();\n if (!text || text === 'all' || text === '全部') return 'all';\n if (text === 'live' || text === '直播' || text === '开播') return 'live';\n if (text === 'dynamic' || text === 'dyn' || text === '动态') return 'dynamic';\n return undefined;\n}\n\nexport function wantsLive(kinds: unknown): boolean {\n return kinds === 'all' || kinds === 'live' || kinds === undefined || kinds === '';\n}\n\nexport function wantsDynamic(kinds: unknown): boolean {\n return kinds === 'all' || kinds === 'dynamic' || kinds === undefined || kinds === '';\n}\n\nexport interface WatchState {\n uid: string;\n liveStatus: number;\n liveTime: number;\n lastDynamicTs: number;\n seenDynamics: string[];\n dynamicReady: boolean;\n}\n\nfunction intOf(value: unknown, fallback: number): number {\n const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nexport async function loadState(model: BiliModel, uid: string): Promise<WatchState | undefined> {\n const [row] = await model.select().where({ uid });\n if (!row) return undefined;\n let seen: string[] = [];\n try {\n const parsed: unknown = JSON.parse(typeof row.seen_dynamics === 'string' ? row.seen_dynamics : '[]');\n if (Array.isArray(parsed)) seen = parsed.filter((id): id is string => typeof id === 'string');\n } catch { /* corrupted cache: start over */ }\n return {\n uid,\n liveStatus: intOf(row.live_status, -1),\n liveTime: intOf(row.live_time, 0),\n lastDynamicTs: intOf(row.last_dynamic_ts, 0),\n seenDynamics: seen,\n dynamicReady: intOf(row.dynamic_ready, 0) === 1,\n };\n}\n\nexport const SEEN_DYNAMICS_LIMIT = 40;\n\nexport async function saveState(model: BiliModel, state: WatchState): Promise<void> {\n const patch = {\n live_status: state.liveStatus,\n live_time: state.liveTime,\n last_dynamic_ts: state.lastDynamicTs,\n seen_dynamics: JSON.stringify(state.seenDynamics.slice(-SEEN_DYNAMICS_LIMIT)),\n dynamic_ready: state.dynamicReady ? 1 : 0,\n updated_at: new Date().toISOString(),\n };\n const existing = await model.select().where({ uid: state.uid });\n if (existing.length > 0) await model.update(patch).where({ uid: state.uid });\n else await model.insert({ uid: state.uid, ...patch });\n}\n\nexport function emptyState(uid: string): WatchState {\n return { uid, liveStatus: -1, liveTime: 0, lastDynamicTs: 0, seenDynamics: [], dynamicReady: false };\n}\n"]}
package/lib/wbi.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** `https://i0.hdslb.com/bfs/wbi/7cd0...077c.png` → `7cd0...077c`. */
2
+ export declare function wbiKeyFromUrl(url: string): string;
3
+ export declare function mixinKey(imgKey: string, subKey: string): string;
4
+ /** Returns the signed query string (`a=1&wts=...&w_rid=...`). */
5
+ export declare function signWbi(params: Readonly<Record<string, string | number>>, key: string, nowSeconds?: number): string;