From a7b5c28e43181a99e478893bd2c312d51f95d7df Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:21:05 -0500 Subject: [PATCH 1/8] =?UTF-8?q?feat(worlds/bilibili):=20=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E3=80=8C=E5=8D=8A=E9=80=8F=E6=98=8E=E9=BB=91=E3=80=8D=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E4=BD=9C=E4=B8=BA=E7=BB=84=E4=BB=B6=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 背景 #0000001f、无边框,排在内置样式首位:缺省设计的两个组件、编辑器新建组件的兜底、 渲染器找不到样式时都取首项 - 自定义样式缺字段时的背景、边框也按首项补 - 已保存的设计都带 styleId 与完整样式字段,不受影响 Co-Authored-By: Claude Opus 5.5 --- src/worlds/bilibili/README.md | 2 +- src/worlds/bilibili/overlay/model.ts | 24 +++++++++++++++------ src/worlds/bilibili/overlay/web/editor.js | 2 +- tests/worlds/bilibili/overlay-model.test.ts | 3 ++- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/worlds/bilibili/README.md b/src/worlds/bilibili/README.md index 5b68c8c13..718fa6edc 100644 --- a/src/worlds/bilibili/README.md +++ b/src/worlds/bilibili/README.md @@ -129,7 +129,7 @@ World 在 `127.0.0.1` 启动独立页面:`GET /overlay` 是 OBS browser source 弹幕机 Mock 会持续混合注入弹幕和礼物,组件工作区的 Mock 测试只写入预览运行器,不改设计、 真实公告或直播事件流。 -内置样式为天蓝色、海军蓝、白色与极简,全部默认尖角。自定义样式可分别配置用户名和正文的 +内置样式为半透明黑、天蓝色、海军蓝、白色与极简,全部默认尖角;半透明黑是新组件、缺省设计与找不到样式时的默认面板。自定义样式可分别配置用户名和正文的 字体、字号、字重、RGBA 颜色与描边。Nine-slice 将源图四条切线与输出边框宽度分开保存; 编辑器可在源图上拖动切线,并用可缩放的实际组件预览验证拉伸、平铺和中心填充。 diff --git a/src/worlds/bilibili/overlay/model.ts b/src/worlds/bilibili/overlay/model.ts index f99f143fa..a206ab1f4 100644 --- a/src/worlds/bilibili/overlay/model.ts +++ b/src/worlds/bilibili/overlay/model.ts @@ -63,7 +63,19 @@ const BASE_TITLE: OverlayTextStyle = { strokeWidth: 0, }; +/** 首项是默认样式:新组件、缺省设计和找不到样式的组件都用它。 */ export const BUILTIN_OVERLAY_STYLES: readonly OverlayStyle[] = [ + { + id: 'builtin:translucent', + name: '半透明黑', + background: '#0000001f', + borderColor: '#00000000', + borderWidth: 0, + radius: 0, + padding: 16, + username: { ...BASE_USERNAME }, + body: { ...BASE_BODY }, + }, { id: 'builtin:sky', name: '天蓝色', @@ -124,7 +136,7 @@ export const BILIBILI_OVERLAY_DEFAULTS: BilibiliOverlayConfig = { id: 'danmaku-main', name: '主弹幕机', kind: 'danmaku', - styleId: 'builtin:sky', + styleId: 'builtin:translucent', axis: 'vertical', admission: 'all', showAvatar: true, @@ -139,7 +151,7 @@ export const BILIBILI_OVERLAY_DEFAULTS: BilibiliOverlayConfig = { id: 'agent-notice', name: 'Agent 公告', kind: 'agent-notice', - styleId: 'builtin:navy', + styleId: 'builtin:translucent', emptyText: '公告栏待更新', hideWhenEmpty: false, typingMs: 42, @@ -264,9 +276,9 @@ function normalizeStyle(value: unknown, index: number): OverlayStyle { return { id: id(raw.id, `style-${index + 1}`), name: text(raw.name, `样式 ${index + 1}`, 80), - background: color(raw.background, '#58b8e8dd'), - borderColor: color(raw.borderColor, '#d8f5ffff'), - borderWidth: number(raw.borderWidth, 0, 64, 2), + background: color(raw.background, BUILTIN_OVERLAY_STYLES[0].background), + borderColor: color(raw.borderColor, BUILTIN_OVERLAY_STYLES[0].borderColor), + borderWidth: number(raw.borderWidth, 0, 64, BUILTIN_OVERLAY_STYLES[0].borderWidth), radius: number(raw.radius, 0, 200, 0), padding: number(raw.padding, 0, 200, 16), username: normalizeTextStyle(raw.username, BASE_USERNAME), @@ -361,7 +373,7 @@ function normalizeComponent(value: unknown, index: number): OverlayComponent { const base = { id: id(raw.id, `component-${index + 1}`), name: text(raw.name, `组件 ${index + 1}`, 80), - styleId: id(raw.styleId, 'builtin:sky'), + styleId: id(raw.styleId, BUILTIN_OVERLAY_STYLES[0].id), ...(title ? { title } : {}), }; switch (raw.kind) { diff --git a/src/worlds/bilibili/overlay/web/editor.js b/src/worlds/bilibili/overlay/web/editor.js index 51244ecda..e1b938d5a 100644 --- a/src/worlds/bilibili/overlay/web/editor.js +++ b/src/worlds/bilibili/overlay/web/editor.js @@ -2290,7 +2290,7 @@ } function createComponent(kind) { - const base = { id: uniqueId('component'), name: componentKinds[kind][0], kind, styleId: editor.selectedStyle || 'builtin:sky' }; + const base = { id: uniqueId('component'), name: componentKinds[kind][0], kind, styleId: editor.selectedStyle || editor.state.builtinStyles[0].id }; let component; if (kind === 'danmaku') component = { ...base, axis: 'vertical', admission: 'all', showAvatar: true, speed: 90, gap: 12, maxItems: 12, usernameMaxChars: 24, bodyMaxChars: 80, edgeFadePx: 32 }; else if (kind === 'scroll-notice') component = { ...base, axis: 'horizontal', text: T.defaultScrollText, speed: 70, gap: 60, lineHoldMs: 1600, lineTransitionMs: 420, edgeFadePx: 32 }; diff --git a/tests/worlds/bilibili/overlay-model.test.ts b/tests/worlds/bilibili/overlay-model.test.ts index c42e501c9..5bb7816a2 100644 --- a/tests/worlds/bilibili/overlay-model.test.ts +++ b/tests/worlds/bilibili/overlay-model.test.ts @@ -9,8 +9,9 @@ import { import type { AudienceGroup, AudienceRule } from '../../../src/worlds/bilibili/overlay/types.ts'; describe('Bilibili Overlay 模型', () => { - it('内置主题保留持久化设计引用的四个 id', () => { + it('内置主题保留持久化设计引用的 id,首项是默认样式', () => { expect(BUILTIN_OVERLAY_STYLES.map((style) => style.id)).toEqual([ + 'builtin:translucent', 'builtin:sky', 'builtin:navy', 'builtin:white', From a6e18e6f5257fb73a351528537633f1b0a1e87a2 Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:22:29 -0500 Subject: [PATCH 2/8] =?UTF-8?q?refactor(worlds/bilibili):=20=E5=85=AC?= =?UTF-8?q?=E5=91=8A=E6=96=87=E4=BB=B6=E7=9A=84=E5=8E=9F=E5=AD=90=E5=86=99?= =?UTF-8?q?=E5=85=A5=E6=8A=BD=E6=88=90=20writeStateFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视频队列状态文件沿用同一写法。 Co-Authored-By: Claude Opus 5.5 --- src/worlds/bilibili/overlay/announcement.ts | 29 +++------------------ src/worlds/bilibili/overlay/state-file.ts | 25 ++++++++++++++++++ 2 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 src/worlds/bilibili/overlay/state-file.ts diff --git a/src/worlds/bilibili/overlay/announcement.ts b/src/worlds/bilibili/overlay/announcement.ts index 52934a11f..ad0bf0257 100644 --- a/src/worlds/bilibili/overlay/announcement.ts +++ b/src/worlds/bilibili/overlay/announcement.ts @@ -1,6 +1,5 @@ -import { randomUUID } from 'node:crypto'; -import { dirname } from 'node:path'; -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; +import { writeStateFile } from './state-file.ts'; import type { AgentAnnouncementState } from './types.ts'; const EMPTY_STATE: AgentAnnouncementState = { @@ -31,7 +30,7 @@ export class AgentAnnouncementStore { revision: this.state.revision + 1, updatedAt: new Date().toISOString(), }; - this.persist(next); + if (this.file) writeStateFile(this.file, next); this.state = next; return this.current; } @@ -51,26 +50,4 @@ export class AgentAnnouncementStore { this.state = { ...EMPTY_STATE }; } } - - private persist(next: AgentAnnouncementState): void { - if (!this.file) return; - const dir = dirname(this.file); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const suffix = `${process.pid}-${randomUUID()}`; - const temporary = `${this.file}.tmp-${suffix}`; - const backup = `${this.file}.bak-${suffix}`; - writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); - if (!existsSync(this.file)) { - renameSync(temporary, this.file); - return; - } - renameSync(this.file, backup); - try { - renameSync(temporary, this.file); - } catch (error) { - renameSync(backup, this.file); - throw error; - } - rmSync(backup); - } } diff --git a/src/worlds/bilibili/overlay/state-file.ts b/src/worlds/bilibili/overlay/state-file.ts new file mode 100644 index 000000000..f767f8a30 --- /dev/null +++ b/src/worlds/bilibili/overlay/state-file.ts @@ -0,0 +1,25 @@ +import { randomUUID } from 'node:crypto'; +import { dirname } from 'node:path'; +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; + +/** 原子替换 JSON 状态文件:先写临时文件再改名;替换失败时把旧文件改回原名并抛出。 */ +export function writeStateFile(file: string, value: unknown): void { + const dir = dirname(file); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const suffix = `${process.pid}-${randomUUID()}`; + const temporary = `${file}.tmp-${suffix}`; + const backup = `${file}.bak-${suffix}`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + if (!existsSync(file)) { + renameSync(temporary, file); + return; + } + renameSync(file, backup); + try { + renameSync(temporary, file); + } catch (error) { + renameSync(backup, file); + throw error; + } + rmSync(backup); +} From f5fd4a4c3b1aa2cd0cd00f4c36b308c577100bc6 Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:29:11 -0500 Subject: [PATCH 3/8] =?UTF-8?q?feat(worlds/bilibili):=20Overlay=20?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=8E=92=E9=98=9F=E7=BB=84=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=20bilibili=5Fupdate=5Fvideo=5Fqueue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新组件类型 video-queue:显示 BV 号列表,默认纵向堆叠、可改横向,可选序号、空队列占位或隐藏 - 队列与上限属于 World:worlds.bilibili.overlay.maxQueuedVideos(默认 10,热改), 状态原子保存到 data/bilibili-overlay/video-queue.json,修改走独立 SSE 事件 video-queue - Bot 一个工具管 add/remove/move/clear,回执列出整队(snapshot 标签,交接只留最后一次); BV 号可写在视频链接里,排满时 add 被拒绝 - ENV_PROMPT 加 bilibili.videoQueue / videoQueueLimit 占位符,前缀重建时同步 - 测试覆盖:排满拒绝且不写坏文件、位置从 1 数起与越界、重复 BV 号、工具→ENV→SSE 同步 Co-Authored-By: Claude Opus 5.5 --- scripts/dev-webui.ts | 31 +++++- src/worlds/bilibili/ENV_PROMPT.md | 5 +- src/worlds/bilibili/README.md | 14 ++- src/worlds/bilibili/config.ts | 9 ++ src/worlds/bilibili/definition.ts | 11 +- src/worlds/bilibili/overlay/model.ts | 12 ++ src/worlds/bilibili/overlay/server.ts | 6 +- src/worlds/bilibili/overlay/types.ts | 28 ++++- src/worlds/bilibili/overlay/video-queue.ts | 108 ++++++++++++++++++ src/worlds/bilibili/overlay/web/app.js | 74 ++++++++++++- src/worlds/bilibili/overlay/web/editor.js | 37 ++++++- src/worlds/bilibili/overlay/web/styles.css | 33 ++++++ src/worlds/bilibili/strings.ts | 8 ++ src/worlds/bilibili/world.ts | 117 +++++++++++++++++++- tests/worlds/bilibili/video-queue.test.ts | 121 +++++++++++++++++++++ tests/worlds/bilibili/world.test.ts | 4 +- 16 files changed, 597 insertions(+), 21 deletions(-) create mode 100644 src/worlds/bilibili/overlay/video-queue.ts create mode 100644 tests/worlds/bilibili/video-queue.test.ts diff --git a/scripts/dev-webui.ts b/scripts/dev-webui.ts index 6f99ee021..742b520e5 100644 --- a/scripts/dev-webui.ts +++ b/scripts/dev-webui.ts @@ -39,7 +39,7 @@ import { bilibiliPanelDecls } from '../src/worlds/bilibili/world.ts'; import { BUILTIN_OVERLAY_STYLES, normalizeOverlayDesign } from '../src/worlds/bilibili/overlay/model.ts'; import { OverlayAssetStore } from '../src/worlds/bilibili/overlay/assets.ts'; import { BilibiliOverlayServer, OverlayEditorConflictError } from '../src/worlds/bilibili/overlay/server.ts'; -import type { AgentAnnouncementState, BilibiliOverlayDesign } from '../src/worlds/bilibili/overlay/types.ts'; +import type { AgentAnnouncementState, BilibiliOverlayDesign, VideoQueueState } from '../src/worlds/bilibili/overlay/types.ts'; import { PromptRevisionConflict, WebApp, type ExtensionComment, type ExtensionDescribe, type ExtensionInfo, type ExtensionRating, type ExtensionSearchHit, type MarketTag, type OwnedStoragePart, type ToolOwner, type WebAppMarketDeps } from '../src/web/server.ts'; import { EXTENSION_API_VERSIONS, parseExtensionManifest } from '../src/extensions/manifest.ts'; import { PREVIOUS_CONTRACT_VERSIONS } from '../src/extensions/previous-contract.ts'; @@ -539,6 +539,17 @@ devBilibiliDesign.components.push( styleId: 'builtin:white', text: '今日目标:完成 Overlay 联调', }, + { + id: 'dev-video-queue', + name: '视频排队', + kind: 'video-queue', + styleId: 'builtin:translucent', + axis: 'vertical', + gap: 8, + showIndex: true, + emptyText: '暂无排队视频', + hideWhenEmpty: false, + }, ); devBilibiliDesign.placements.push( { @@ -553,6 +564,10 @@ devBilibiliDesign.placements.push( id: 'placement-dev-fixed-notice', componentId: 'dev-fixed-notice', x: 120, y: 220, width: 760, height: 120, z: 18, visible: true, locked: false, }, + { + id: 'placement-dev-video-queue', componentId: 'dev-video-queue', + x: 120, y: 690, width: 700, height: 190, z: 16, visible: true, locked: false, + }, ); let devBilibiliAnnouncement: AgentAnnouncementState = { schemaVersion: 1, @@ -560,6 +575,15 @@ let devBilibiliAnnouncement: AgentAnnouncementState = { revision: 1, updatedAt: new Date().toISOString(), }; +const devBilibiliVideoQueue: VideoQueueState = { + schemaVersion: 1, + items: [ + { bvid: 'BV1Ab4y1C7dE', note: '新番预告 · 观众点播', addedAt: new Date().toISOString() }, + { bvid: 'BV1Qx411m7Ks', note: '', addedAt: new Date().toISOString() }, + ], + revision: 1, + updatedAt: new Date().toISOString(), +}; const devBilibiliAssets = new OverlayAssetStore(join(tmpData, 'bilibili-overlay-assets')); const devBilibiliOverlay: BilibiliOverlayServer = new BilibiliOverlayServer({ preferredPort: 0, @@ -569,6 +593,7 @@ const devBilibiliOverlay: BilibiliOverlayServer = new BilibiliOverlayServer({ designRevision: devBilibiliDesignRevision, builtinStyles: structuredClone(BUILTIN_OVERLAY_STYLES), agentAnnouncement: { ...devBilibiliAnnouncement }, + videoQueue: structuredClone(devBilibiliVideoQueue), }), editor: { state: () => devBilibiliState(), @@ -595,7 +620,9 @@ function devBilibiliState(): Record { designRevision: devBilibiliDesignRevision, builtinStyles: structuredClone(BUILTIN_OVERLAY_STYLES), agentAnnouncement: { ...devBilibiliAnnouncement }, + videoQueue: structuredClone(devBilibiliVideoQueue), maxAnnouncementChars: BILIBILI_DEFAULTS.overlay.agentNoticeMaxChars, + maxQueuedVideos: BILIBILI_DEFAULTS.overlay.maxQueuedVideos, assets: devBilibiliAssets.list(DEV_BILIBILI_OVERLAY_URL ? devBilibiliOverlay.baseUrl : null), url: DEV_BILIBILI_OVERLAY_URL, streamUp: !MINIMAL, @@ -1025,7 +1052,7 @@ const devFakeWorlds: World[] = MINIMAL ? [] : [ const devFakeWorldTools: Record = { minecraft: ['mc_do', 'mc_queue', 'mc_check', 'mc_stop', 'mc_escape'], - bilibili: ['bilibili_set_announcement'], + bilibili: ['bilibili_set_announcement', 'bilibili_update_video_queue'], }; /** QQ 事件面板(dev):按会话分组读假事件库 */ diff --git a/src/worlds/bilibili/ENV_PROMPT.md b/src/worlds/bilibili/ENV_PROMPT.md index 677c5414b..c84704d71 100644 --- a/src/worlds/bilibili/ENV_PROMPT.md +++ b/src/worlds/bilibili/ENV_PROMPT.md @@ -2,4 +2,7 @@ 观众看到的画面比现场晚 5 到 8 秒,注意下延迟 你可以用 `bilibili_set_announcement` 更新直播画面上的写字板,最多 {{bilibili.agentAnnouncementLimit}} 字 当前写字板内容:{{bilibili.agentAnnouncement | (暂无内容)}} -可以把你当前的目标/直播内容,或者别的你想写的更新在写字板。更新之前要通知外界。 \ No newline at end of file +可以把你当前的目标/直播内容,或者别的你想写的更新在写字板。更新之前要通知外界。 +画面上还有一个视频排队列表,最多 {{bilibili.videoQueueLimit}} 个,用 `bilibili_update_video_queue` 加入、移除、调整顺序或清空 +当前排队: +{{bilibili.videoQueue | (空)}} \ No newline at end of file diff --git a/src/worlds/bilibili/README.md b/src/worlds/bilibili/README.md index 718fa6edc..43851efbd 100644 --- a/src/worlds/bilibili/README.md +++ b/src/worlds/bilibili/README.md @@ -3,8 +3,8 @@ # worlds/bilibili B 站直播间协议只读接入。观众的弹幕、礼物、醒目留言、上舰、进场与人流读数从这里进事件流; -同一 World 提供本机透明 Overlay。唯一工具 `bilibili_set_announcement` 只写本机 Agent 公告栏, -不会向 B 站账号发送弹幕或执行平台操作。 +同一 World 提供本机透明 Overlay。两个工具只改本机 Overlay:`bilibili_set_announcement` 写 Agent 公告栏, +`bilibili_update_video_queue` 改视频排队列表;都不会向 B 站账号发送弹幕或执行平台操作。 “重要观众”指符合 UID 资格、在限流时优先分配上下文预算的观众。资格、拥挤状态、事件分组与人物档案召回由 [`audience-admission.ts`](audience-admission.ts) 管理;阈值和预算在 [`config.ts`](config.ts)。资格变化不删除 Memory 中的人物档案。具体条件见「重要观众与实时限流」。 @@ -123,6 +123,8 @@ World 在 `127.0.0.1` 启动独立页面:`GET /overlay` 是 OBS browser source - 横向或纵向弹幕机,准入可选弹幕、礼物、弹幕+礼物;用户名与内容可分别限制显示字数,礼物包含免费礼物、付费礼物、SC 与舰队事件。 - 横向或纵向滚动公告、固定公告、Agent 公告;多行滚动公告可设置每行停留时间和行间切换时长,滚动内容在运动方向的两端渐隐。 - HTTPS/HTTP 外部图片或上传图片,支持 contain/cover/fill。 +- 视频排队:显示 Bot 维护的 BV 号列表,默认纵向堆叠,可改横向;可显示序号,队列为空时显示占位文字或隐藏组件。 + 序号与 BV 号用样式的用户名文字,Bot 写的备注用正文文字,放不下时以省略号截断。 组件可带独立标题。标题占据上、右、下或左侧边带,支持起点、居中、终点对齐,并拥有独立的 字体、字号、字重、RGBA 颜色与描边。Agent 公告更新时按 Unicode 字符逐字显示并保留输入光标; @@ -144,6 +146,12 @@ WebP 与 GIF,单文件上限 8 MiB。Agent 公告单独原子保存到 正在滚动的弹幕。公告内容在下一次主会话前缀重建时通过 `ENV_PROMPT.md` 占位符同步; World 不会 为了每次公告写入强制重建前缀。 +视频排队列表属于 World,所有排队组件显示同一份,原子保存到 `data/bilibili-overlay/video-queue.json`。 +`bilibili_update_video_queue` 的 `add` 按位置插入(从 1 数起,缺省或超出末尾排到最后),`remove` 与 `move` +作用于第一个相同的 BV 号,`clear` 清空;BV 号可以写在视频链接里。队列达到 `worlds.bilibili.overlay.maxQueuedVideos` +时 `add` 被拒绝,调低上限不删除已有条目。每次修改以独立 SSE 事件 `video-queue` 更新组件,回执列出整队; +`ENV_PROMPT.md` 的 `bilibili.videoQueue` 占位符同公告一样在前缀重建时同步。 + ## WebUI WebUI 提供直播间事件诊断和独立 Overlay 编辑器入口。样式、组件与布局编辑共享草稿、撤销历史和保存操作。草稿预览复用 OBS 渲染器,支持拖动、八向缩放、网格、画布与组件吸附、层级、可见性和锁定;编辑控件与选择框不进入 `/overlay` 输出。 @@ -151,7 +159,7 @@ WebUI 提供直播间事件诊断和独立 Overlay 编辑器入口。样式、 从主 Web 页面打开编辑器时,语义调色板通过 URL fragment 传递。编辑器内置浅色和深色默认调色板,主 Web 停止后仍可通过独立 URL 打开。画布背景的浅深设置只保存在本机,不写入设计数据。 配置项包括直播间号、登录凭证、礼物即时投递门槛、相同消息归并窗与容量上限,以及 Overlay -启用、端口和 Agent 公告字数上限。 +启用、端口、Agent 公告字数上限和视频排队上限。 日志面板每 2 秒轮询 `log.state`。`total` 是累计记录数,包括已超出 `RECENT_CAP` 而从缓冲移除的记录;客户端按差值追加新行,保留滚动位置。 diff --git a/src/worlds/bilibili/config.ts b/src/worlds/bilibili/config.ts index 7eebb7db5..09b709613 100644 --- a/src/worlds/bilibili/config.ts +++ b/src/worlds/bilibili/config.ts @@ -180,6 +180,15 @@ export function bilibiliConfigGroup(language: Language): ConfigGroup { 'x-hot': true, description: t.noticeMaxDescription, }, + 'worlds.bilibili.overlay.maxQueuedVideos': { + type: 'integer', + title: t.queueMaxTitle, + minimum: 1, + maximum: 100, + 'x-suffix': t.suffixItems, + 'x-hot': true, + description: t.queueMaxDescription, + }, }, }, }; diff --git a/src/worlds/bilibili/definition.ts b/src/worlds/bilibili/definition.ts index 5d5d47dde..b271bfe57 100644 --- a/src/worlds/bilibili/definition.ts +++ b/src/worlds/bilibili/definition.ts @@ -27,17 +27,26 @@ export const BILIBILI: WorldDefinition = { overlay: cfg.overlay, agentNoticeMaxChars: () => cfg.overlay.agentNoticeMaxChars, agentNoticeFile: join(ctx.dataDir, 'bilibili-overlay', 'agent-notice.json'), + maxQueuedVideos: () => cfg.overlay.maxQueuedVideos, + videoQueueFile: join(ctx.dataDir, 'bilibili-overlay', 'video-queue.json'), overlayAssetDir: join(ctx.dataDir, 'bilibili-overlay', 'assets'), onOverlayConfig: (overlay) => ctx.persist({ overlay: { design: overlay.design } }), timezone: ctx.timezone, }); }, describe: { - tools: describeTools(bilibiliToolDecls(BILIBILI_DEFAULTS.overlay.agentNoticeMaxChars), { + tools: describeTools(bilibiliToolDecls( + BILIBILI_DEFAULTS.overlay.agentNoticeMaxChars, + BILIBILI_DEFAULTS.overlay.maxQueuedVideos, + ), { bilibili_set_announcement: { zh: '改写直播画面叠加层上的公告板文字。', en: 'Rewrites the announcement board text on the stream overlay.', }, + bilibili_update_video_queue: { + zh: '改写直播画面上的视频排队列表:加入、移除、调整顺序或清空。', + en: 'Edits the video queue list on the stream overlay: add, remove, reorder or clear.', + }, }), events: [ { diff --git a/src/worlds/bilibili/overlay/model.ts b/src/worlds/bilibili/overlay/model.ts index a206ab1f4..c2f4519a2 100644 --- a/src/worlds/bilibili/overlay/model.ts +++ b/src/worlds/bilibili/overlay/model.ts @@ -126,6 +126,7 @@ export const BILIBILI_OVERLAY_DEFAULTS: BilibiliOverlayConfig = { enabled: true, port: 7795, agentNoticeMaxChars: 200, + maxQueuedVideos: 10, design: { schemaVersion: OVERLAY_SCHEMA_VERSION, canvas: { width: 1920, height: 1080 }, @@ -199,6 +200,7 @@ export function normalizeOverlayConfig(value: unknown): BilibiliOverlayConfig { 5000, BILIBILI_OVERLAY_DEFAULTS.agentNoticeMaxChars, ), + maxQueuedVideos: integer(raw.maxQueuedVideos, 1, 100, BILIBILI_OVERLAY_DEFAULTS.maxQueuedVideos), design: normalizeOverlayDesign(raw.design), }; } @@ -409,6 +411,16 @@ function normalizeComponent(value: unknown, index: number): OverlayComponent { fit: raw.fit === 'cover' || raw.fit === 'fill' ? raw.fit : 'contain', opacity: number(raw.opacity, 0, 1, 1), }; + case 'video-queue': + return { + ...base, + kind: 'video-queue', + axis: raw.axis === 'horizontal' ? 'horizontal' : 'vertical', + gap: number(raw.gap, 0, 500, 8), + showIndex: bool(raw.showIndex, true), + emptyText: text(raw.emptyText, '暂无排队视频', 500), + hideWhenEmpty: bool(raw.hideWhenEmpty, false), + }; case 'danmaku': return { ...base, diff --git a/src/worlds/bilibili/overlay/server.ts b/src/worlds/bilibili/overlay/server.ts index 7030cece1..67fefc7ac 100644 --- a/src/worlds/bilibili/overlay/server.ts +++ b/src/worlds/bilibili/overlay/server.ts @@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { AddressInfo } from 'node:net'; import { fileURLToPath } from 'node:url'; import type { Logger } from '../../../core/types.ts'; -import type { AgentAnnouncementState, OverlayAudienceEvent } from './types.ts'; +import type { AgentAnnouncementState, OverlayAudienceEvent, VideoQueueState } from './types.ts'; import { OverlayAssetStore } from './assets.ts'; const STATIC_DIR = fileURLToPath(new URL('./web/', import.meta.url)); @@ -147,6 +147,10 @@ export class BilibiliOverlayServer { this.emit('announcement', { agentAnnouncement }); } + emitVideoQueue(videoQueue: VideoQueueState): void { + this.emit('video-queue', { videoQueue }); + } + private emit(type: string, data: Record): void { if (!this.subscribers.size) return; const payload = `id: ${++this.seq}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; diff --git a/src/worlds/bilibili/overlay/types.ts b/src/worlds/bilibili/overlay/types.ts index 2ccba3963..4304b6da4 100644 --- a/src/worlds/bilibili/overlay/types.ts +++ b/src/worlds/bilibili/overlay/types.ts @@ -141,12 +141,23 @@ interface ImageComponent extends OverlayComponentBase { opacity: number; } +/** 显示 Bot 维护的视频排队列表;队列内容与上限属于 World,所有排队组件显示同一份。 */ +interface VideoQueueComponent extends OverlayComponentBase { + kind: 'video-queue'; + axis: OverlayAxis; + gap: number; + showIndex: boolean; + emptyText: string; + hideWhenEmpty: boolean; +} + export type OverlayComponent = | DanmakuComponent | ScrollNoticeComponent | FixedNoticeComponent | AgentNoticeComponent - | ImageComponent; + | ImageComponent + | VideoQueueComponent; export interface OverlayPlacement { id: string; @@ -173,6 +184,7 @@ export interface BilibiliOverlayConfig { enabled: boolean; port: number; agentNoticeMaxChars: number; + maxQueuedVideos: number; design: BilibiliOverlayDesign; } @@ -209,6 +221,20 @@ export interface AgentAnnouncementState { updatedAt: string | null; } +export interface VideoQueueItem { + bvid: string; + /** Bot 写在 BV 号后面的短文本,可为空。 */ + note: string; + addedAt: string; +} + +export interface VideoQueueState { + schemaVersion: 1; + items: VideoQueueItem[]; + revision: number; + updatedAt: string | null; +} + export interface OverlayAssetInfo { id: string; mime: string; diff --git a/src/worlds/bilibili/overlay/video-queue.ts b/src/worlds/bilibili/overlay/video-queue.ts new file mode 100644 index 000000000..365f93407 --- /dev/null +++ b/src/worlds/bilibili/overlay/video-queue.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { writeStateFile } from './state-file.ts'; +import type { VideoQueueItem, VideoQueueState } from './types.ts'; + +/** BV 号是 `BV1` 加 9 位 base58 字符。 */ +const BVID_RE = /(? VIDEO_QUEUE_NOTE_MAX_CHARS) { + throw new Error(`note 最多 ${VIDEO_QUEUE_NOTE_MAX_CHARS} 字,当前 ${count} 字`); + } + if (this.state.items.length >= maxItems) { + throw new Error(`队列已满(${this.state.items.length}/${maxItems})`); + } + const items = [...this.state.items]; + const index = position === undefined ? items.length : Math.min(items.length, position - 1); + items.splice(index, 0, { bvid, note, addedAt: new Date().toISOString() }); + return this.commit(items); + } + + /** 同一 BV 号排了多次时移除第一个。 */ + remove(bvid: string): VideoQueueState { + const items = [...this.state.items]; + items.splice(this.indexOf(bvid), 1); + return this.commit(items); + } + + /** `position` 超出末尾时移到最后。 */ + move(bvid: string, position: number): VideoQueueState { + const items = [...this.state.items]; + const [item] = items.splice(this.indexOf(bvid), 1); + items.splice(Math.min(items.length, position - 1), 0, item); + return this.commit(items); + } + + clear(): VideoQueueState { + return this.commit([]); + } + + private indexOf(bvid: string): number { + const index = this.state.items.findIndex((item) => item.bvid === bvid); + if (index < 0) throw new Error(`队列里没有 ${bvid}`); + return index; + } + + private commit(items: VideoQueueItem[]): VideoQueueState { + const next: VideoQueueState = { + schemaVersion: 1, + items, + revision: this.state.revision + 1, + updatedAt: new Date().toISOString(), + }; + if (this.file) writeStateFile(this.file, next); + this.state = next; + return this.current; + } + + private load(): void { + if (!this.file || !existsSync(this.file)) return; + try { + const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Partial; + if (raw.schemaVersion !== 1 || !Array.isArray(raw.items)) return; + this.state = { + schemaVersion: 1, + items: raw.items + .filter((item): item is VideoQueueItem => typeof item?.bvid === 'string' && STORED_BVID_RE.test(item.bvid)) + .map((item) => ({ + bvid: item.bvid, + note: typeof item.note === 'string' ? item.note : '', + addedAt: typeof item.addedAt === 'string' ? item.addedAt : '', + })), + revision: Number.isInteger(raw.revision) && Number(raw.revision) >= 0 ? Number(raw.revision) : 0, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : null, + }; + } catch { + this.state = structuredClone(EMPTY_STATE); + } + } +} diff --git a/src/worlds/bilibili/overlay/web/app.js b/src/worlds/bilibili/overlay/web/app.js index 2205089d6..25f096ecc 100644 --- a/src/worlds/bilibili/overlay/web/app.js +++ b/src/worlds/bilibili/overlay/web/app.js @@ -9,6 +9,7 @@ let design = null; let builtinStyles = []; let announcement = { text: '' }; + let videoQueue = { items: [] }; let hasEditorDraft = false; const feeds = new Map(); const horizontalLanes = new Map(); @@ -28,7 +29,9 @@ if (editorPreview && hasEditorDraft) { builtinStyles = Array.isArray(packet.builtinStyles) ? packet.builtinStyles : builtinStyles; announcement = packet.agentAnnouncement || announcement; + videoQueue = packet.videoQueue || videoQueue; updateAnnouncement(); + updateVideoQueues(); return; } applyState(packet); @@ -39,6 +42,11 @@ updateAnnouncement(); return; } + if (packet.type === 'video-queue' && packet.videoQueue) { + videoQueue = packet.videoQueue; + updateVideoQueues(); + return; + } if (packet.type === 'audience' && packet.event) renderAudience(packet.event); }; @@ -70,6 +78,7 @@ design = packet.design || design; builtinStyles = Array.isArray(packet.builtinStyles) ? packet.builtinStyles : builtinStyles; announcement = packet.agentAnnouncement || announcement; + videoQueue = packet.videoQueue || videoQueue; renderScene(); } @@ -162,6 +171,10 @@ renderAgentNotice(element, content, component, placementId, style.body); return; } + if (component.kind === 'video-queue') { + renderVideoQueue(element, content, component, style, videoQueue.items); + return; + } if (component.kind === 'image') { element.classList.add('image-component'); const image = document.createElement('img'); @@ -222,6 +235,51 @@ } } + function updateVideoQueues() { + if (!design) return; + const components = new Map(design.components.map((item) => [item.id, item])); + for (const element of stage.querySelectorAll('.video-queue')) { + const component = components.get(element.dataset.componentId); + if (!component || component.kind !== 'video-queue') continue; + if (component.id === activeMockComponentId) continue; + const content = element.querySelector('.component-content'); + if (content) renderVideoQueue(element, content, component, styleById(component.styleId), videoQueue.items); + } + } + + /** BV 号与序号用样式的用户名文字,备注用正文文字;放不下的备注以省略号截断。 */ + function renderVideoQueue(element, content, component, style, items) { + const list = Array.isArray(items) ? items : []; + element.hidden = !list.length && component.hideWhenEmpty; + content.replaceChildren(); + if (element.hidden) return; + if (!list.length) { + content.appendChild(textElement(component.emptyText, style.body)); + return; + } + const queue = document.createElement('ol'); + queue.className = `video-queue-list ${component.axis}`; + queue.style.gap = `${component.gap}px`; + list.forEach((item, index) => { + const row = document.createElement('li'); + row.className = 'video-queue-item'; + const bvid = document.createElement('span'); + bvid.className = 'video-queue-bvid'; + bvid.textContent = component.showIndex ? `${index + 1}. ${item.bvid}` : item.bvid; + applyTextStyle(bvid, style.username); + row.appendChild(bvid); + if (item.note) { + const note = document.createElement('span'); + note.className = 'video-queue-note'; + note.textContent = item.note; + applyTextStyle(note, style.body); + row.appendChild(note); + } + queue.appendChild(row); + }); + content.appendChild(queue); + } + function audienceElement(event, feed) { const item = document.createElement('div'); item.className = 'audience-item'; @@ -409,6 +467,18 @@ return; } + if (component.kind === 'video-queue') { + for (const element of elements) { + const content = element.querySelector('.component-content'); + if (content) renderVideoQueue(element, content, component, styleById(component.styleId), [ + { bvid: 'BV1Ab4y1C7dE', note: '一起看新番预告 · 星野' }, + { bvid: 'BV1Qx411m7Ks', note: '这是一条为了验证省略号而故意写得很长很长很长很长的视频备注' }, + { bvid: 'BV1Zr4y1N7pQ', note: '' }, + ]); + } + return; + } + if (component.kind === 'image' && !componentImageSource(component)) { for (const element of elements) { const image = element.querySelector('.component-content img'); @@ -422,7 +492,9 @@ ? { width: 720, height: 320 } : component.kind === 'image' ? { width: 520, height: 300 } - : { width: 820, height: 140 }; + : component.kind === 'video-queue' + ? { width: 600, height: 320 } + : { width: 820, height: 140 }; const width = Math.min(preset.width, design.canvas.width); const height = Math.min(preset.height, design.canvas.height); const z = Math.max(0, ...design.placements.map((placement) => placement.z)) + 1; diff --git a/src/worlds/bilibili/overlay/web/editor.js b/src/worlds/bilibili/overlay/web/editor.js index e1b938d5a..e1ae9ca40 100644 --- a/src/worlds/bilibili/overlay/web/editor.js +++ b/src/worlds/bilibili/overlay/web/editor.js @@ -64,6 +64,8 @@ kindAgentNotice: 'Agent 公告', kindImage: '图片', glyphImage: '图', + kindVideoQueue: '视频排队', + glyphVideoQueue: '排', fieldUid: '用户 UID', fieldGuardLevel: '大航海等级', fieldMedalLevel: '粉丝牌等级', @@ -123,8 +125,8 @@ padding: '内边距', sampleDanmaku: '这是一条弹幕正文', panelAppearance: '面板外观', - username: '用户名', - bodyText: '弹幕 / 公告正文', + username: '用户名 / BV 号', + bodyText: '弹幕 / 公告正文 / 视频备注', textPreview: '透明度、字体与描边实时预览', font: '字体', fontSize: '字号', @@ -241,6 +243,10 @@ hideWhenEmpty: '无公告时隐藏组件', typingInterval: '打字间隔 ms', typingNote: '逐字出现的间隔;设为 0 可关闭动画。', + stackDirection: '堆叠方向', + showIndex: '显示序号', + hideWhenQueueEmpty: '队列为空时隐藏组件', + queueLimitNote: (n) => `最多 ${n} 个,在 World 配置「视频排队上限」修改。`, imageSource: '图片来源', externalUrl: '外部 URL', assetLibrary: '素材库', @@ -281,6 +287,7 @@ defaultScrollText: '欢迎来到直播间\n关注主播,不错过开播通知', defaultFixedText: '固定公告', defaultEmptyText: '公告栏待更新', + defaultQueueEmptyText: '暂无排队视频', deleteComponent: '删除组件', deleteWithPlacements: (n) => `组件与它的 ${n} 个布局实例都会删除。`, canvasWidth: '画布宽度', @@ -368,6 +375,8 @@ kindAgentNotice: 'Agent announcement', kindImage: 'Image', glyphImage: 'I', + kindVideoQueue: 'Video queue', + glyphVideoQueue: 'Q', fieldUid: 'User UID', fieldGuardLevel: 'Guard level', fieldMedalLevel: 'Fan medal level', @@ -427,8 +436,8 @@ padding: 'Padding', sampleDanmaku: 'This is a danmaku message', panelAppearance: 'Panel appearance', - username: 'Username', - bodyText: 'Danmaku / announcement text', + username: 'Username / BV id', + bodyText: 'Danmaku / announcement / video note text', textPreview: 'Live preview of opacity, font and outline', font: 'Font', fontSize: 'Font size', @@ -545,6 +554,10 @@ hideWhenEmpty: 'Hide when there is no announcement', typingInterval: 'Typing interval ms', typingNote: 'Delay between characters; 0 turns the animation off.', + stackDirection: 'Stack direction', + showIndex: 'Show numbers', + hideWhenQueueEmpty: 'Hide component while the queue is empty', + queueLimitNote: (n) => `Holds up to ${n}; change it in the World setting "Video queue limit".`, imageSource: 'Image source', externalUrl: 'External URL', assetLibrary: 'Asset library', @@ -585,6 +598,7 @@ defaultScrollText: 'Welcome to the stream\nFollow to get notified when the stream starts', defaultFixedText: 'Fixed notice', defaultEmptyText: 'Announcement coming soon', + defaultQueueEmptyText: 'No videos queued', deleteComponent: 'Delete component', deleteWithPlacements: (n) => `The component and its ${n} layout instance${n === 1 ? '' : 's'} will be deleted.`, canvasWidth: 'Canvas width', @@ -669,6 +683,7 @@ 'fixed-notice': [T.kindFixedNotice, T.glyphFixedNotice], 'agent-notice': [T.kindAgentNotice, 'A'], image: [T.kindImage, T.glyphImage], + 'video-queue': [T.kindVideoQueue, T.glyphVideoQueue], }; const fontChoices = [ 'Microsoft YaHei, sans-serif', @@ -2100,6 +2115,15 @@ checkInput(T.hideWhenEmpty, component.hideWhenEmpty, (value) => { component.hideWhenEmpty = value; }), field(T.typingInterval, numberInput(component.typingMs, (value) => { component.typingMs = value; }, 0, 2000, 1), T.typingNote), ); + } else if (component.kind === 'video-queue') { + body.append( + h('p', 'field-note', T.queueLimitNote(editor.state.maxQueuedVideos)), + field(T.stackDirection, selectInput(component.axis, [['vertical', T.vertical], ['horizontal', T.horizontal]], (value) => { component.axis = value; }, { render: false })), + field(T.itemGap, numberInput(component.gap, (value) => { component.gap = value; }, 0, 500, 1)), + checkInput(T.showIndex, component.showIndex, (value) => { component.showIndex = value; }), + field(T.emptyPlaceholder, textInput(component.emptyText, (value) => { component.emptyText = value; }, { maxLength: 500 })), + checkInput(T.hideWhenQueueEmpty, component.hideWhenEmpty, (value) => { component.hideWhenEmpty = value; }), + ); } else if (component.kind === 'image') { body.append( field(T.imageSource, selectInput(component.source, [['external', T.externalUrl], ['upload', T.assetLibrary]], (value) => { component.source = value; }, { render: true })), @@ -2296,6 +2320,7 @@ else if (kind === 'scroll-notice') component = { ...base, axis: 'horizontal', text: T.defaultScrollText, speed: 70, gap: 60, lineHoldMs: 1600, lineTransitionMs: 420, edgeFadePx: 32 }; else if (kind === 'fixed-notice') component = { ...base, text: T.defaultFixedText }; else if (kind === 'agent-notice') component = { ...base, emptyText: T.defaultEmptyText, hideWhenEmpty: false, typingMs: 42 }; + else if (kind === 'video-queue') component = { ...base, axis: 'vertical', gap: 8, showIndex: true, emptyText: T.defaultQueueEmptyText, hideWhenEmpty: false }; else component = { ...base, source: 'external', url: '', assetId: '', fit: 'contain', opacity: 1 }; mutate(() => { editor.design.components.push(component); @@ -2402,7 +2427,9 @@ function addPlacement(component) { const canvas = editor.design.canvas; const width = component.kind === 'agent-notice' || component.kind.includes('notice') ? Math.min(960, canvas.width * .6) : Math.min(600, canvas.width * .35); - const height = component.kind === 'danmaku' ? Math.min(560, canvas.height * .65) : Math.min(160, canvas.height * .2); + const height = component.kind === 'danmaku' + ? Math.min(560, canvas.height * .65) + : component.kind === 'video-queue' ? Math.min(420, canvas.height * .4) : Math.min(160, canvas.height * .2); const placement = { id: uniqueId('placement'), componentId: component.id, x: Math.round((canvas.width - width) / 2), y: Math.round((canvas.height - height) / 2), diff --git a/src/worlds/bilibili/overlay/web/styles.css b/src/worlds/bilibili/overlay/web/styles.css index 58698133e..dc876011d 100644 --- a/src/worlds/bilibili/overlay/web/styles.css +++ b/src/worlds/bilibili/overlay/web/styles.css @@ -117,6 +117,39 @@ body.preview[data-wall-mode="dark"] { text-align: center; } +.video-queue-list { + display: flex; + flex-direction: column; + align-items: stretch; + margin: 0; + padding: 0; + list-style: none; +} + +.video-queue-list.horizontal { + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; +} + +.video-queue-item { + display: flex; + align-items: baseline; + gap: .5em; + min-width: 0; + white-space: nowrap; +} + +.video-queue-bvid { + flex: 0 0 auto; +} + +.video-queue-note { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + .image-component:not(.has-title) { background: transparent !important; border: 0 !important; diff --git a/src/worlds/bilibili/strings.ts b/src/worlds/bilibili/strings.ts index b232039c2..eca2e5125 100644 --- a/src/worlds/bilibili/strings.ts +++ b/src/worlds/bilibili/strings.ts @@ -44,6 +44,8 @@ const zh = { noticeMaxTitle: 'Agent 公告字数上限', suffixChars: '字', noticeMaxDescription: 'Agent 公告工具写入纯文本时的 Unicode 字符上限。', + queueMaxTitle: '视频排队上限', + queueMaxDescription: '视频排队列表最多容纳的视频数,排满后加入会被拒绝。', // ── WebUI 露出:灯、徽标、面板、链接、模板 ────────────────────────── label: 'B 站直播间', panelLogTitle: '直播间事件', @@ -75,6 +77,8 @@ const zh = { promptDocDescription: '直播间这个场所的常驻事实。', announcementVarDescription: 'Agent 公告栏当前保存的纯文本。', announcementLimitVarDescription: 'Agent 公告工具当前允许的最大字数。', + videoQueueVarDescription: '视频排队列表当前的内容,每项一行。', + videoQueueLimitVarDescription: '视频排队列表当前的上限。', // ── 关机核验 ─────────────────────────────────────────────────────── sourceLabel: 'B 站直播源', roomLabel: (roomId: number) => `B 站直播间 ${roomId}`, @@ -142,6 +146,8 @@ export const en: typeof zh = { noticeMaxTitle: 'Agent announcement character limit', suffixChars: 'chars', noticeMaxDescription: 'Maximum Unicode characters the Agent announcement tool may write.', + queueMaxTitle: 'Video queue limit', + queueMaxDescription: 'Maximum number of videos in the video queue list; adding is rejected while it is full.', label: 'Bilibili live room', panelLogTitle: 'Live room events', panelLogDescription: 'Live room events and counts per cmd.', @@ -171,6 +177,8 @@ export const en: typeof zh = { promptDocDescription: 'Standing facts about the live room.', announcementVarDescription: 'Plain text currently saved on the Agent announcement board.', announcementLimitVarDescription: 'Character limit the Agent announcement tool currently allows.', + videoQueueVarDescription: 'Current contents of the video queue list, one entry per line.', + videoQueueLimitVarDescription: 'Current limit of the video queue list.', sourceLabel: 'Bilibili live source', roomLabel: (roomId) => `Bilibili live room ${roomId}`, manualCheck: diff --git a/src/worlds/bilibili/world.ts b/src/worlds/bilibili/world.ts index b47acb0dc..0fb3409ac 100644 --- a/src/worlds/bilibili/world.ts +++ b/src/worlds/bilibili/world.ts @@ -35,7 +35,8 @@ import { } from './overlay/model.ts'; import { projectOverlayEvent } from './overlay/project.ts'; import { BilibiliOverlayServer, OverlayEditorConflictError } from './overlay/server.ts'; -import type { BilibiliOverlayConfig, OverlayAudienceEvent } from './overlay/types.ts'; +import type { BilibiliOverlayConfig, OverlayAudienceEvent, VideoQueueState } from './overlay/types.ts'; +import { parseBvid, VIDEO_QUEUE_NOTE_MAX_CHARS, VideoQueueStore } from './overlay/video-queue.ts'; import { BILIBILI_DEFAULTS, bilibiliConfigGroup } from './config.ts'; import { text, type BilibiliText } from './strings.ts'; @@ -110,6 +111,8 @@ export interface BilibiliWorldOptions { onOverlayConfig?: (config: BilibiliOverlayConfig) => void | Promise; agentNoticeMaxChars?: () => number; agentNoticeFile?: string; + maxQueuedVideos?: () => number; + videoQueueFile?: string; overlayAssetDir?: string; timezone?: string; /** 自定义直播源;默认使用 LiveClient。 */ @@ -170,8 +173,19 @@ function emptyAggregate(): Aggregate { }; } -/** 工具声明。公告字数上限来自 `worlds.bilibili.overlay.agentNoticeMaxChars`,写进参数的 schema 与说明。 */ -export function bilibiliToolDecls(agentNoticeMaxChars: number): Array> { +/** 每项一行「序号. BV 号 备注」;空队列为空串。 */ +function videoQueueLines(state: VideoQueueState): string { + return state.items.map((item, index) => `${index + 1}. ${item.bvid}${item.note ? ` ${item.note}` : ''}`).join('\n'); +} + +/** + * 工具声明。公告字数上限来自 `worlds.bilibili.overlay.agentNoticeMaxChars`,写进参数的 schema 与说明; + * 排队上限来自 `worlds.bilibili.overlay.maxQueuedVideos`,写进说明。 + */ +export function bilibiliToolDecls( + agentNoticeMaxChars: number, + maxQueuedVideos: number, +): Array> { return [ { name: 'bilibili_set_announcement', @@ -195,6 +209,39 @@ export function bilibiliToolDecls(agentNoticeMaxChars: number): Array = Promise.resolve(); private readonly announcement: AgentAnnouncementStore; + private readonly videoQueue: VideoQueueStore; private readonly assets: OverlayAssetStore | null; private overlayServer: BilibiliOverlayServer | null = null; private overlayError: string | null = null; @@ -283,6 +331,7 @@ export class BilibiliWorld implements World { ? normalizeOverlayConfig(opts.overlay) : { ...cloneOverlayConfig(), enabled: false }; this.announcement = new AgentAnnouncementStore(opts.agentNoticeFile); + this.videoQueue = new VideoQueueStore(opts.videoQueueFile); this.assets = opts.overlayAssetDir ? new OverlayAssetStore(opts.overlayAssetDir) : null; } @@ -290,6 +339,8 @@ export class BilibiliWorld implements World { return { 'bilibili.agentAnnouncement': this.announcement.current.text, 'bilibili.agentAnnouncementLimit': String(this.agentNoticeLimit()), + 'bilibili.videoQueue': videoQueueLines(this.videoQueue.current), + 'bilibili.videoQueueLimit': String(this.maxQueuedVideos()), }; } @@ -401,6 +452,15 @@ export class BilibiliWorld implements World { name: 'bilibili.agentAnnouncementLimit', description: t.announcementLimitVarDescription, }, + { + name: 'bilibili.videoQueue', + description: t.videoQueueVarDescription, + multiline: true, + }, + { + name: 'bilibili.videoQueueLimit', + description: t.videoQueueLimitVarDescription, + }, ], }, ], @@ -558,9 +618,10 @@ export class BilibiliWorld implements World { } tools(): ToolDef[] { + const [announcementDecl, videoQueueDecl] = bilibiliToolDecls(this.agentNoticeLimit(), this.maxQueuedVideos()); return [ { - ...bilibiliToolDecls(this.agentNoticeLimit())[0], + ...announcementDecl, handler: async (args) => { if (typeof args.text !== 'string') return '[bad input] text must be a string'; @@ -583,6 +644,17 @@ export class BilibiliWorld implements World { } }, }, + { + ...videoQueueDecl, + handler: async (args) => { + try { + const state = this.updateVideoQueue(args); + return `[video queue] ${state.items.length}/${this.maxQueuedVideos()}\n${videoQueueLines(state) || '(empty)'}`; + } catch (error) { + return `[not executed] ${error instanceof Error ? error.message : String(error)}`; + } + }, + }, ]; } @@ -779,6 +851,7 @@ export class BilibiliWorld implements World { designRevision: this.overlayDesignRevision, builtinStyles: structuredClone(BUILTIN_OVERLAY_STYLES), agentAnnouncement: this.announcement.current, + videoQueue: this.videoQueue.current, }; } @@ -789,6 +862,7 @@ export class BilibiliWorld implements World { streamUp: this.overlayServer?.running ?? false, error: this.overlayError, maxAnnouncementChars: this.agentNoticeLimit(), + maxQueuedVideos: this.maxQueuedVideos(), assets: this.assets?.list(this.overlayServer?.running ? this.overlayServer.baseUrl : null) ?? [], }; } @@ -865,6 +939,41 @@ export class BilibiliWorld implements World { this.overlayServer?.emitAudience(projected); } + /** 出错时队列不变,错误信息回给 Bot。 */ + private updateVideoQueue(args: Record): VideoQueueState { + const { action } = args; + let state: VideoQueueState; + if (action === 'clear') { + state = this.videoQueue.clear(); + } else { + if (action !== 'add' && action !== 'remove' && action !== 'move') { + throw new Error('action 只能是 add、remove、move 或 clear'); + } + const bvid = typeof args.bvid === 'string' ? parseBvid(args.bvid) : null; + if (!bvid) throw new Error('bvid 里没有 BV 号(BV1 加 9 位字符)'); + const position = args.position === undefined ? undefined : Number(args.position); + if (position !== undefined && !(Number.isInteger(position) && position >= 1)) { + throw new Error('position 是从 1 数起的整数'); + } + if (action === 'add') { + const note = typeof args.note === 'string' ? args.note.trim() : ''; + state = this.videoQueue.add(bvid, note, position, this.maxQueuedVideos()); + } else if (action === 'remove') { + state = this.videoQueue.remove(bvid); + } else { + if (position === undefined) throw new Error('move 需要 position'); + state = this.videoQueue.move(bvid, position); + } + } + this.overlayServer?.emitVideoQueue(state); + return state; + } + + private maxQueuedVideos(): number { + const value = this.opts.maxQueuedVideos?.() ?? this.overlayConfig.maxQueuedVideos; + return Number.isFinite(value) ? Math.min(100, Math.max(1, Math.round(value))) : 10; + } + private agentNoticeLimit(): number { const value = this.opts.agentNoticeMaxChars?.() ?? this.overlayConfig.agentNoticeMaxChars; return Number.isFinite(value) ? Math.min(5000, Math.max(1, Math.round(value))) : 200; diff --git a/tests/worlds/bilibili/video-queue.test.ts b/tests/worlds/bilibili/video-queue.test.ts new file mode 100644 index 000000000..bb696437e --- /dev/null +++ b/tests/worlds/bilibili/video-queue.test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { WorldHost } from '../../../src/core/types.ts'; +import { nullLogger } from '../../../src/core/util.ts'; +import { cloneOverlayConfig } from '../../../src/worlds/bilibili/overlay/model.ts'; +import { parseBvid, VideoQueueStore } from '../../../src/worlds/bilibili/overlay/video-queue.ts'; +import { BilibiliWorld } from '../../../src/worlds/bilibili/world.ts'; + +const roots: string[] = []; +const toolContext = { role: 'main', log: null as never } as const; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('视频排队', () => { + it('BV 号可从视频链接里取出,前缀写成大写;前后紧挨字母数字的不算', () => { + expect(parseBvid('https://www.bilibili.com/video/bv1Ab4y1C7dE/?p=2')).toBe('BV1Ab4y1C7dE'); + expect(parseBvid('BV1Ab4y1C7dEx')).toBeNull(); + expect(parseBvid('BV1Ab4y1C7d0')).toBeNull(); + }); + + it('排满后加入被拒绝,文件里的队列不变', () => { + const file = tempFile(); + const store = new VideoQueueStore(file); + store.add('BV1Ab4y1C7dE', '', undefined, 2); + store.add('BV1Qx411m7Ks', '', undefined, 2); + expect(() => store.add('BV1Zr4y1N7pQ', '', undefined, 2)).toThrow('队列已满(2/2)'); + const saved = new VideoQueueStore(file).current; + expect(saved.items.map((item) => item.bvid)).toEqual(['BV1Ab4y1C7dE', 'BV1Qx411m7Ks']); + expect(saved.revision).toBe(2); + }); + + it('插入与移动的位置从 1 数起,超出末尾排到最后;同一 BV 号排了多次时移除第一个', () => { + const store = new VideoQueueStore(); + store.add('BV1Ab4y1C7dE', 'a', undefined, 10); + store.add('BV1Qx411m7Ks', 'b', 1, 10); + store.add('BV1Ab4y1C7dE', 'c', 99, 10); + expect(store.current.items.map((item) => item.note)).toEqual(['b', 'a', 'c']); + store.move('BV1Qx411m7Ks', 99); + expect(store.current.items.map((item) => item.note)).toEqual(['a', 'c', 'b']); + store.remove('BV1Ab4y1C7dE'); + expect(store.current.items.map((item) => item.note)).toEqual(['c', 'b']); + expect(() => store.remove('BV1Zr4y1N7pQ')).toThrow('队列里没有 BV1Zr4y1N7pQ'); + }); + + it('工具改队列后回执列出整队,ENV 占位符与 Overlay SSE 同步;出错时队列不动', async () => { + const root = tempRoot(); + const module = new BilibiliWorld({ + roomId: 0, + overlay: { ...cloneOverlayConfig(), port: 0 }, + overlayAssetDir: join(root, 'assets'), + videoQueueFile: join(root, 'video-queue.json'), + maxQueuedVideos: () => 3, + }); + await module.start({ log: nullLogger() } as unknown as WorldHost); + try { + const obs = module.webUi('zh').links?.find((link) => link.href.endsWith('/overlay')); + const stream = await openSse(obs!.href.replace(/\/overlay$/, '/stream')); + await stream.readUntil('"type":"snapshot"'); + const tool = module.tools().find((item) => item.name === 'bilibili_update_video_queue')!; + + const added = await tool.handler( + { action: 'add', bvid: 'https://b23.tv/BV1Ab4y1C7dE', note: '新番预告' }, + toolContext, + ); + expect(added).toBe('[video queue] 1/3\n1. BV1Ab4y1C7dE 新番预告'); + expect(await stream.readUntil('BV1Ab4y1C7dE')).toContain('"type":"video-queue"'); + expect(module.envPromptVars()).toMatchObject({ + 'bilibili.videoQueue': '1. BV1Ab4y1C7dE 新番预告', + 'bilibili.videoQueueLimit': '3', + }); + + expect(await tool.handler({ action: 'move', bvid: 'BV1Ab4y1C7dE' }, toolContext)).toBe('[not executed] move 需要 position'); + expect(await tool.handler({ action: 'add', bvid: '第一个视频' }, toolContext)).toContain('[not executed]'); + expect(await tool.handler({ action: 'clear' }, toolContext)).toBe('[video queue] 0/3\n(empty)'); + expect(JSON.parse(readFileSync(join(root, 'video-queue.json'), 'utf8')).items).toEqual([]); + await stream.close(); + } finally { + await module.stop(); + } + }); +}); + +async function openSse(url: string): Promise<{ + readUntil(text: string): Promise; + close(): Promise; +}> { + const response = await fetch(url); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let pending = ''; + return { + async readUntil(text: string): Promise { + for (let index = 0; index < 20 && !pending.includes(text); index += 1) { + const chunk = await reader.read(); + if (chunk.done) break; + pending += decoder.decode(chunk.value, { stream: true }); + } + if (!pending.includes(text)) throw new Error(`SSE 未收到 ${text}`); + const out = pending; + pending = ''; + return out; + }, + async close(): Promise { + await reader.cancel(); + }, + }; +} + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'bilibili-video-queue-')); + roots.push(root); + return root; +} + +function tempFile(): string { + return join(tempRoot(), 'state', 'video-queue.json'); +} diff --git a/tests/worlds/bilibili/world.test.ts b/tests/worlds/bilibili/world.test.ts index 51474e974..0d886ef7b 100644 --- a/tests/worlds/bilibili/world.test.ts +++ b/tests/worlds/bilibili/world.test.ts @@ -241,10 +241,10 @@ describe('BilibiliWorld', () => { vi.useRealTimers(); }); - it('只提供本机 Agent 公告工具,不向 B 站账号写入', async () => { + it('只提供本机 Agent 公告与视频排队工具,不向 B 站账号写入', async () => { const { module } = await mount(); const tools = module.tools(); - expect(tools.map((tool) => tool.name)).toEqual(['bilibili_set_announcement']); + expect(tools.map((tool) => tool.name)).toEqual(['bilibili_set_announcement', 'bilibili_update_video_queue']); expect(tools[0].tags).toEqual(['speak']); expect((tools[0].parameters.properties as Record).text.maxLength).toBe(200); await module.stop(); From 6c8c7ad50615319b8c4d23ba39d374508d266484 Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:29:22 -0500 Subject: [PATCH 4/8] =?UTF-8?q?fix(worlds/bilibili):=20=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E7=9A=84=20Overlay=20=E7=BB=84=E4=BB=B6=E8=BF=9E=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=B8=80=E8=B5=B7=E4=B8=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:.overlay-component 的 display:flex 压过了浏览器对 hidden 属性的 display:none, Agent 公告「无公告时隐藏组件」只清空了文字,面板底色和边框仍留在画面上 - 视频排队「队列为空时隐藏组件」走同一路径 Co-Authored-By: Claude Opus 5.5 --- src/worlds/bilibili/overlay/web/styles.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/worlds/bilibili/overlay/web/styles.css b/src/worlds/bilibili/overlay/web/styles.css index dc876011d..f7b795bf5 100644 --- a/src/worlds/bilibili/overlay/web/styles.css +++ b/src/worlds/bilibili/overlay/web/styles.css @@ -53,6 +53,10 @@ body.preview[data-wall-mode="dark"] { flex-direction: column; } +.overlay-component[hidden] { + display: none; +} + .component-content { position: relative; flex: 1 1 auto; From 39b1f270307b45aaa0cbeff44f9d7c81c0bb448e Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:43:17 -0500 Subject: [PATCH 5/8] =?UTF-8?q?feat(worlds/bilibili):=20=E3=80=8C=E7=99=BB?= =?UTF-8?q?=E5=BD=95=20B=20=E7=AB=99=E3=80=8D=E9=9D=A2=E6=9D=BF=E7=94=A8?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E7=A1=AE=E8=AE=A4=E7=99=BB=E5=BD=95?= =?UTF-8?q?=EF=BC=8C=E5=85=8D=E6=89=8B=E5=A1=AB=20SESSDATA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login.ts 向 B 站要网页扫码登录的确认页链接,面板在新标签打开;浏览器已登录 B 站时页面显示账号 与确认按钮,点确认即完成。World 每 2 秒轮询,取登录响应 Set-Cookie 里的 SESSDATA 原样写回配置 - 写回成功后只重建直播间长连,Overlay、归并缓冲与开播基线不动;写回失败记为登录失败、沿用旧凭证 - 不读浏览器的 cookie 库:SESSDATA 是 HttpOnly,Chrome 的 cookie 加密绑定在浏览器自身 - 测试用本机假 passport 覆盖:原样写回(含 %2C 与 *)、写回失败、链接作废停止轮询、取消后不写回 - 登录换上的凭证同样按「已配置却仍匿名」检查并记 error Co-Authored-By: Claude Opus 5.5 --- scripts/dev-webui.ts | 21 +++- src/worlds/bilibili/README.md | 8 +- src/worlds/bilibili/client.ts | 2 +- src/worlds/bilibili/definition.ts | 1 + src/worlds/bilibili/login.ts | 169 +++++++++++++++++++++++++++ src/worlds/bilibili/strings.ts | 10 +- src/worlds/bilibili/webui/client.ts | 118 ++++++++++++++++++- src/worlds/bilibili/webui/strings.ts | 51 ++++++++ src/worlds/bilibili/world.ts | 108 ++++++++++++++--- tests/worlds/bilibili/login.test.ts | 154 ++++++++++++++++++++++++ tests/worlds/bilibili/webui.test.ts | 4 +- 11 files changed, 617 insertions(+), 29 deletions(-) create mode 100644 src/worlds/bilibili/login.ts create mode 100644 tests/worlds/bilibili/login.test.ts diff --git a/scripts/dev-webui.ts b/scripts/dev-webui.ts index 742b520e5..c32a69257 100644 --- a/scripts/dev-webui.ts +++ b/scripts/dev-webui.ts @@ -668,6 +668,14 @@ const devBilibiliOverlayPanel = { }, }; +let devBilibiliLoginStartedAt: number | null = null; +function devBilibiliLoginState(): Record { + const base = { url: null, uid: null, error: null, configured: true, selfUid: 87654321 }; + if (devBilibiliLoginStartedAt === null) return { ...base, phase: 'idle' }; + if (Date.now() - devBilibiliLoginStartedAt < 4000) return { ...base, phase: 'waiting', url: 'about:blank#dev-bilibili-login' }; + return { ...base, phase: 'done', uid: 87654321 }; +} + /** 按 World id、面板局部 id、方法名组织假数据,返回最终接口响应形状。 */ const devPanels: Record unknown>>> = { qq: { @@ -708,7 +716,18 @@ const devPanels: Record devBilibiliLoginState(), + start: () => { + devBilibiliLoginStartedAt = Date.now(); + return devBilibiliLoginState(); + }, + cancel: () => { + devBilibiliLoginStartedAt = null; + return devBilibiliLoginState(); + }, + }, log: { state: () => ({ status: { diff --git a/src/worlds/bilibili/README.md b/src/worlds/bilibili/README.md index 43851efbd..9e16dbc0e 100644 --- a/src/worlds/bilibili/README.md +++ b/src/worlds/bilibili/README.md @@ -46,7 +46,13 @@ B 站直播间协议只读接入。观众的弹幕、礼物、醒目留言、上 ## 登录凭证与观众身份 -`worlds.bilibili.sessdata` 保存浏览器登录后的 SESSDATA cookie,位于部署的 `config.json`,不进入版本控制。匿名连接仍可接收弹幕;已观测到匿名响应将观众 uid 置为 0,并对昵称脱敏。`uid=0` 时不生成 `senderKey`,昵称不作为稳定身份键。 +`worlds.bilibili.sessdata` 保存登录 cookie 的 SESSDATA,位于部署的 `config.json`,不进入版本控制。匿名连接仍可接收弹幕;已观测到匿名响应将观众 uid 置为 0,并对昵称脱敏。`uid=0` 时不生成 `senderKey`,昵称不作为稳定身份键。 + +WebUI 的「登录 B 站」面板(`login`)由 [`login.ts`](login.ts) 向 B 站要一个网页扫码登录的确认页链接, +面板在新标签打开它;浏览器已登录 B 站时页面显示该账号和确认按钮,点确认即完成。 +World 每 2 秒轮询登录状态,成功后取响应 `Set-Cookie` 里的 SESSDATA 原样(含 `%2C`)经 `onSessdata` 写回配置, +再只重建直播间长连;Overlay、归并缓冲与开播基线不动,所以不会补报开播。链接约 180 秒后由 B 站作废, +写回配置失败时这次登录记为失败、仍用旧凭证。 已有弹幕且最近至多 20 条均缺少有效 uid 时,WebUI 显示「近期弹幕缺少观众 uid」。 diff --git a/src/worlds/bilibili/client.ts b/src/worlds/bilibili/client.ts index 8f1a22b70..4fc0d087f 100644 --- a/src/worlds/bilibili/client.ts +++ b/src/worlds/bilibili/client.ts @@ -16,7 +16,7 @@ import type { Logger, ShutdownExternalCheck } from '../../core/types.ts'; import { text, type BilibiliText } from './strings.ts'; import { encodePacket, OP, parseFrame } from './wire.ts'; -const UA = +export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'; const HEARTBEAT_MS = 30_000; diff --git a/src/worlds/bilibili/definition.ts b/src/worlds/bilibili/definition.ts index b271bfe57..b8847e473 100644 --- a/src/worlds/bilibili/definition.ts +++ b/src/worlds/bilibili/definition.ts @@ -12,6 +12,7 @@ export const BILIBILI: WorldDefinition = { return new BilibiliWorld({ roomId: cfg.roomId, sessdata: cfg.sessdata, + onSessdata: (sessdata) => ctx.persist({ sessdata }), giftFlushYuan: () => cfg.giftFlushYuan, coalesceWindowMs: () => cfg.coalesceWindowMs, coalesceMaxItems: () => cfg.coalesceMaxItems, diff --git a/src/worlds/bilibili/login.ts b/src/worlds/bilibili/login.ts new file mode 100644 index 000000000..546f9f617 --- /dev/null +++ b/src/worlds/bilibili/login.ts @@ -0,0 +1,169 @@ +/** + * 网页扫码登录换 SESSDATA。`start` 向 B 站要一个确认页链接,在已登录 B 站的浏览器里打开它点确认即完成登录。 + * 之后本模块每 2 秒轮询一次,把登录响应 `Set-Cookie` 里的 SESSDATA 交给 `onSessdata`。链接由 B 站在约 180 秒后作废。 + */ +import { UA } from './client.ts'; + +const DEFAULT_PASSPORT = 'https://passport.bilibili.com'; +const POLL_MS = 2_000; +const REQUEST_TIMEOUT_MS = 10_000; +/** 轮询请求连续失败超过这么久就放弃;B 站作废链接的时间与此相同。 */ +const GIVE_UP_MS = 180_000; + +const CODE_DONE = 0; +const CODE_WAITING = 86101; +const CODE_SCANNED = 86090; +const CODE_EXPIRED = 86038; + +/** `scanned`:确认页已打开(或 App 已扫码),等待点确认。 */ +export type LoginPhase = 'idle' | 'waiting' | 'scanned' | 'done' | 'expired' | 'failed'; + +export interface LoginState { + phase: LoginPhase; + /** 确认页链接,仅 waiting 与 scanned 时有值。 */ + url: string | null; + /** 登录成功的账号 uid。 */ + uid: number | null; + error: string | null; +} + +export interface BrowserLoginOptions { + onSessdata(sessdata: string): void; + /** B 站账号接口的根地址;测试指向本机假服务。 */ + passportBase?: string; + pollMs?: number; +} + +const IDLE: LoginState = { phase: 'idle', url: null, uid: null, error: null }; + +export class BrowserLogin { + private state: LoginState = { ...IDLE }; + private generation = 0; + private timer: ReturnType | null = null; + + constructor(private readonly opts: BrowserLoginOptions) {} + + get current(): LoginState { + return { ...this.state }; + } + + /** 作废进行中的登录,换一个新链接。 */ + async start(): Promise { + const generation = this.reset(); + const body = await this.request('/x/passport-login/web/qrcode/generate'); + const data = object(body.data); + const url = typeof data.url === 'string' ? data.url : ''; + const key = typeof data.qrcode_key === 'string' ? data.qrcode_key : ''; + if (body.code !== 0 || !url || !key) { + throw new Error(`B 站没有给出登录链接:code=${String(body.code)} ${String(body.message ?? '')}`); + } + if (generation !== this.generation) return this.current; + this.state = { phase: 'waiting', url, uid: null, error: null }; + this.schedule(generation, key, Date.now()); + return this.current; + } + + cancel(): LoginState { + this.reset(); + return this.current; + } + + private reset(): number { + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.state = { ...IDLE }; + return ++this.generation; + } + + private schedule(generation: number, key: string, lastOkAt: number): void { + this.timer = setTimeout(() => { + void this.poll(generation, key, lastOkAt).catch((error: unknown) => { + if (generation === this.generation) this.state = { ...IDLE, phase: 'failed', error: errorText(error) }; + }); + }, this.opts.pollMs ?? POLL_MS); + this.timer.unref?.(); + } + + private async poll(generation: number, key: string, lastOkAt: number): Promise { + let response: Response; + let body: Record; + try { + response = await this.fetch(`/x/passport-login/web/qrcode/poll?qrcode_key=${encodeURIComponent(key)}`); + body = object(await response.json()); + } catch (error) { + if (generation !== this.generation) return; + if (Date.now() - lastOkAt > GIVE_UP_MS) { + this.state = { ...IDLE, phase: 'failed', error: `查询登录状态失败:${errorText(error)}` }; + return; + } + this.schedule(generation, key, lastOkAt); + return; + } + if (generation !== this.generation) return; + const data = object(body.data); + const code = data.code; + if (code === CODE_WAITING || code === CODE_SCANNED) { + this.state = { ...this.state, phase: code === CODE_SCANNED ? 'scanned' : 'waiting' }; + this.schedule(generation, key, Date.now()); + return; + } + if (code === CODE_EXPIRED) { + this.state = { ...IDLE, phase: 'expired' }; + return; + } + if (code !== CODE_DONE) { + this.state = { ...IDLE, phase: 'failed', error: `B 站返回 code=${String(code ?? body.code)} ${String(data.message ?? body.message ?? '')}` }; + return; + } + const cookies = response.headers.getSetCookie(); + const sessdata = cookieValue(cookies, 'SESSDATA'); + if (!sessdata) { + this.state = { ...IDLE, phase: 'failed', error: 'B 站确认了登录,但响应里没有 SESSDATA' }; + return; + } + try { + this.opts.onSessdata(sessdata); + } catch (error) { + this.state = { ...IDLE, phase: 'failed', error: `保存登录凭证失败:${errorText(error)}` }; + return; + } + const uid = Number(cookieValue(cookies, 'DedeUserID')); + this.state = { ...IDLE, phase: 'done', uid: Number.isSafeInteger(uid) && uid > 0 ? uid : null }; + } + + private async request(path: string): Promise> { + const response = await this.fetch(path); + if (!response.ok) throw new Error(`B 站账号接口 HTTP ${response.status}`); + return object(await response.json()); + } + + private fetch(path: string): Promise { + return fetch(`${this.opts.passportBase ?? DEFAULT_PASSPORT}${path}`, { + headers: { 'User-Agent': UA, Referer: 'https://www.bilibili.com/' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } +} + +/** Cookie 值原样返回;SESSDATA 里的 `%2C` 属于值本身,不解码。 */ +function cookieValue(setCookies: readonly string[], name: string): string | null { + for (const line of setCookies) { + const pair = line.split(';', 1)[0]; + const eq = pair.indexOf('='); + if (eq > 0 && pair.slice(0, eq).trim() === name) { + const value = pair.slice(eq + 1).trim(); + if (value) return value; + } + } + return null; +} + +function object(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/worlds/bilibili/strings.ts b/src/worlds/bilibili/strings.ts index eca2e5125..03bb90161 100644 --- a/src/worlds/bilibili/strings.ts +++ b/src/worlds/bilibili/strings.ts @@ -9,7 +9,7 @@ const zh = { roomIdDescription: '短号或真实房间号皆可。改动需重启后生效。', sessdataTitle: '登录凭证 SESSDATA', sessdataDescription: - '浏览器登录 bilibili 后的 SESSDATA cookie。留空使用匿名接入,观众身份可能被脱敏。修改后需重启。', + 'B 站登录 cookie 里的 SESSDATA。「登录 B 站」面板用浏览器确认登录后自动写入并重连;手动修改需重启。留空使用匿名接入,观众身份可能被脱敏。', giftFlushTitle: '礼物即时投递门槛', suffixYuan: '元', giftFlushDescription: '达到此金额的礼物立即投递;其余礼物参与常规合批。', @@ -50,6 +50,8 @@ const zh = { label: 'B 站直播间', panelLogTitle: '直播间事件', panelLogDescription: '直播间事件与各 cmd 计数。', + panelLoginTitle: '登录 B 站', + panelLoginDescription: '在已登录 B 站的浏览器里确认登录,SESSDATA 自动写入配置并重连直播间。', feed: '接入', phaseStopped: '未接入', phaseConnecting: '连接中', @@ -105,8 +107,8 @@ export const en: typeof zh = { roomIdDescription: 'Short or real room ID. Takes effect after a restart.', sessdataTitle: 'Login credential SESSDATA', sessdataDescription: - 'The SESSDATA cookie from a browser signed in to bilibili. Empty connects anonymously, and viewer identities may be masked. ' - + 'Takes effect after a restart.', + 'The SESSDATA cookie of a bilibili sign-in. Confirming a sign-in in a browser from the "Sign in to bilibili" panel writes it ' + + 'and reconnects; a manual change takes effect after a restart. Empty connects anonymously, and viewer identities may be masked.', giftFlushTitle: 'Instant gift delivery threshold', suffixYuan: 'CNY', giftFlushDescription: 'Gifts worth this much or more are delivered at once; other gifts join regular batching.', @@ -151,6 +153,8 @@ export const en: typeof zh = { label: 'Bilibili live room', panelLogTitle: 'Live room events', panelLogDescription: 'Live room events and counts per cmd.', + panelLoginTitle: 'Sign in to bilibili', + panelLoginDescription: 'Confirm a sign-in in a browser already signed in to bilibili; SESSDATA is written to the config and the live room reconnects.', feed: 'Feed', phaseStopped: 'Not connected', phaseConnecting: 'Connecting', diff --git a/src/worlds/bilibili/webui/client.ts b/src/worlds/bilibili/webui/client.ts index 2888d7c60..db6ae87ef 100644 --- a/src/worlds/bilibili/webui/client.ts +++ b/src/worlds/bilibili/webui/client.ts @@ -1,14 +1,14 @@ /** * B 站直播间 World 的浏览器扩展。 * - * `log` 保留直播间入站诊断。Overlay 编辑器由 World 的回环服务独立承载, + * `log` 保留直播间入站诊断;`login` 用浏览器确认登录换 SESSDATA。Overlay 编辑器由 World 的回环服务独立承载, * WebUI 只暴露入口链接。 * * 轮询 state,按 total 差值追加事件。服务端响应类型在浏览器端声明,避免导入 Node 模块。 */ import type { WebUiClientBundle, WebUiPanelContext, WebUiPanel } from '../../../web/shared/client-panel.ts'; -import { logText } from './strings.ts'; +import { loginText, logText } from './strings.ts'; export function errText(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -185,10 +185,124 @@ const logPanel: WebUiPanel = { }, }; +// --------------------------------------------------------------------------- +// 服务端 `invoke('login', 'state' | 'start' | 'cancel')` 的返回形状 +// --------------------------------------------------------------------------- + +interface BilibiliLoginState { + phase: 'idle' | 'waiting' | 'scanned' | 'done' | 'expired' | 'failed'; + /** 确认页链接,仅 waiting 与 scanned 时有值。 */ + url: string | null; + uid: number | null; + error: string | null; + /** 配置里已有 SESSDATA。 */ + configured: boolean; + /** 直播间握手读到的自己的 uid;匿名或未接入为 0。 */ + selfUid: number; +} + +const LOGIN_POLL_MS = 1500; + +const loginPanel: WebUiPanel = { + mount(ctx: WebUiPanelContext) { + const { ui, root } = ctx; + const T = loginText(ctx.language); + const card = ui.sheet({ title: T.sheetTitle, en: 'bilibili sign-in', desc: T.sheetDesc }); + root.appendChild(card.el); + + const err = ui.msgline(); + const info = ui.h('div'); + const startButton = ui.button(T.start, { variant: 'primary', onClick: () => void begin() }); + const cancelButton = ui.button(T.cancel, { onClick: () => void cancel() }); + const pageLink = ui.h('a', 'btn', T.openPage); + pageLink.target = '_blank'; + pageLink.rel = 'noopener noreferrer'; + const bar = ui.actions(); + bar.append(startButton, pageLink, cancelButton); + card.body.append(err, info, bar); + + const setError = (text: string | null): void => { + err.textContent = text ?? ''; + err.className = text ? 'msgline bad' : 'msgline'; + err.hidden = !text; + }; + setError(null); + + const draw = (st: BilibiliLoginState): void => { + const pending = st.phase === 'waiting' || st.phase === 'scanned'; + const progress = st.phase === 'done' + ? T.done(st.uid) + : st.phase === 'failed' + ? T.failed(st.error ?? '') + : T.phase[st.phase]; + info.replaceChildren(ui.kv([ + { k: T.kCredential, v: ui.pill(st.configured ? T.configured : T.notConfigured, st.configured ? 'on' : 'off') }, + { k: T.kIdentity, v: ui.pill(st.selfUid ? T.loggedIn(st.selfUid) : T.anonymous, st.selfUid ? 'on' : 'plain') }, + ...(progress ? [{ k: T.kProgress, v: progress }] : []), + ])); + startButton.hidden = pending; + startButton.textContent = st.configured ? T.restart : T.start; + pageLink.hidden = !pending || !st.url; + if (st.url) pageLink.href = st.url; + cancelButton.hidden = !pending; + }; + + /** 空白页在点击当下打开,拿到链接后再跳转,浏览器不会当成弹窗拦下。 */ + const begin = async (): Promise => { + const page = window.open('about:blank', '_blank'); + if (page) page.opener = null; + startButton.disabled = true; + try { + const st = await ctx.invoke('start'); + setError(null); + draw(st); + if (page && st.url) page.location.href = st.url; + else page?.close(); + } catch (e: unknown) { + page?.close(); + setError(T.failed(errText(e))); + } finally { + startButton.disabled = false; + } + }; + + const cancel = async (): Promise => { + try { + draw(await ctx.invoke('cancel')); + } catch (e: unknown) { + setError(T.stateFailed(errText(e))); + } + }; + + let polling = false; + const poll = (): void => { + if (polling) return; + polling = true; + void ctx.invoke('state').then( + (st) => { + polling = false; + if (ctx.signal.aborted) return; + setError(null); + draw(st); + }, + (e: unknown) => { + polling = false; + if (ctx.signal.aborted) return; + setError(T.stateFailed(errText(e))); + }, + ); + }; + + ctx.interval(poll, LOGIN_POLL_MS); + poll(); + }, +}; + const bundle: WebUiClientBundle = { // 键是**局部** panel id,与服务端 `webUi().panels[].id` 一一对应。 panels: { log: logPanel, + login: loginPanel, }, }; diff --git a/src/worlds/bilibili/webui/strings.ts b/src/worlds/bilibili/webui/strings.ts index 951175158..306a3112a 100644 --- a/src/worlds/bilibili/webui/strings.ts +++ b/src/worlds/bilibili/webui/strings.ts @@ -1,5 +1,56 @@ import { pick, type Language } from '../../../core/language.ts'; +/** 「登录 B 站」面板的浏览器端文案。 */ +const loginZh = { + sheetTitle: '登录 B 站', + sheetDesc: '在打开的 B 站页面确认登录;页面上显示的就是将要使用的账号。', + kCredential: '登录凭证', + configured: '已配置', + notConfigured: '未配置', + kIdentity: '当前身份', + loggedIn: (uid: number) => `已登录(uid ${uid})`, + anonymous: '匿名或未接入', + kProgress: '进度', + phase: { + waiting: '等待在 B 站页面确认', + scanned: '已扫码,等待确认', + expired: '链接已过期,请重新登录', + } as Record, + done: (uid: number | null) => `登录成功${uid ? `(uid ${uid})` : ''},已写入配置,直播间正在重连`, + start: '浏览器登录', + restart: '重新登录', + openPage: '打开确认页 ↗', + cancel: '取消登录', + failed: (err: string) => `登录失败:${err}`, + stateFailed: (err: string) => `读取登录状态失败:${err}`, +}; + +const loginEn: typeof loginZh = { + sheetTitle: 'Sign in to bilibili', + sheetDesc: 'Confirm the sign-in on the bilibili page that opens; the account shown there is the one that will be used.', + kCredential: 'Credential', + configured: 'Set', + notConfigured: 'Not set', + kIdentity: 'Current identity', + loggedIn: (uid: number) => `Signed in (uid ${uid})`, + anonymous: 'Anonymous or not connected', + kProgress: 'Progress', + phase: { + waiting: 'Waiting for confirmation on the bilibili page', + scanned: 'Scanned, waiting for confirmation', + expired: 'The link expired; sign in again', + }, + done: (uid: number | null) => `Signed in${uid ? ` (uid ${uid})` : ''}; saved to the config, the live room is reconnecting`, + start: 'Sign in with browser', + restart: 'Sign in again', + openPage: 'Open confirmation page ↗', + cancel: 'Cancel sign-in', + failed: (err: string) => `Sign-in failed: ${err}`, + stateFailed: (err: string) => `Could not read the sign-in state: ${err}`, +}; + +export const loginText = (language: Language) => pick(language, { zh: loginZh, en: loginEn }); + /** 「直播间事件」面板的浏览器端文案。 */ const zh = { sheetTitle: '直播间事件', diff --git a/src/worlds/bilibili/world.ts b/src/worlds/bilibili/world.ts index 0fb3409ac..5c05ba7ec 100644 --- a/src/worlds/bilibili/world.ts +++ b/src/worlds/bilibili/world.ts @@ -21,6 +21,7 @@ import { type ImportantAudienceParticipant, } from './audience-admission.ts'; import { LiveClient, type LivePhase, type LiveStatus } from './client.ts'; +import { BrowserLogin } from './login.ts'; import { CoalescingBuffer, type CoalescingGroup } from './coalescing-buffer.ts'; import { giftFrameData } from './gift-frame.ts'; import { normalize, type CountField, type GaugeField, type LiveEvent } from './normalize.ts'; @@ -43,6 +44,7 @@ import { text, type BilibiliText } from './strings.ts'; const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url)); const PANEL_LOG = 'log'; +const PANEL_LOGIN = 'login'; /** 开发 WebUI 复用此面板声明。 */ export function bilibiliPanelDecls(language: Language): readonly WorldPanelDecl[] { @@ -53,6 +55,11 @@ export function bilibiliPanelDecls(language: Language): readonly WorldPanelDecl[ title: t.panelLogTitle, description: t.panelLogDescription, }, + { + id: PANEL_LOGIN, + title: t.panelLoginTitle, + description: t.panelLoginDescription, + }, ]; } @@ -89,6 +96,10 @@ export interface BilibiliWorldOptions { roomId: number; /** 登录 cookie 的 SESSDATA;空串=匿名接入 */ sessdata?: string; + /** 浏览器确认登录拿到新 SESSDATA 时调用,负责写回配置;抛错则这次登录记为失败。 */ + onSessdata?: (sessdata: string) => void; + /** B 站账号接口的根地址;测试指向本机假服务。 */ + passportBase?: string; /** 热改:触发即时投递的礼物金额下限(元)。 */ giftFlushYuan?: () => number; /** 热改:相同弹幕与常规礼物进入总线前的固定归并窗 */ @@ -251,6 +262,8 @@ export class BilibiliWorld implements World { private host: WorldHost | null = null; private client: LiveSource | null = null; private readonly opts: BilibiliWorldOptions; + private sessdata: string; + private readonly login: BrowserLogin; private readonly timezone: string; private overlayConfig: BilibiliOverlayConfig; private overlayDesignRevision = 0; @@ -314,6 +327,11 @@ export class BilibiliWorld implements World { constructor(opts: BilibiliWorldOptions) { this.opts = opts; + this.sessdata = opts.sessdata ?? ''; + this.login = new BrowserLogin({ + passportBase: opts.passportBase, + onSessdata: (value) => this.applySessdata(value), + }); this.coalescing = new CoalescingBuffer((groups) => this.emitCoalesced(groups)); this.admission = new AudienceAdmission({ file: opts.audienceLedgerFile, @@ -418,6 +436,18 @@ export class BilibiliWorld implements World { ], panels: [...bilibiliPanelDecls(language)], invoke: async (panel, method, args) => { + if (panel === PANEL_LOGIN) { + if (method === 'state') return this.loginState(); + if (method === 'start') { + await this.login.start(); + return this.loginState(); + } + if (method === 'cancel') { + this.login.cancel(); + return this.loginState(); + } + throw new Error(t.unknownMethod(method)); + } if (panel !== PANEL_LOG) throw new Error(t.unknownPanel(panel)); if (method === 'state') return this.logState(); throw new Error(t.unknownMethod(method)); @@ -499,26 +529,10 @@ export class BilibiliWorld implements World { this.host = null; throw new Error('B 站直播间需要宿主提供候选投递能力'); } - if (!this.opts.sessdata && !this.opts.source) { + if (!this.sessdata && !this.opts.source) { host.log.warn('未配置 worlds.bilibili.sessdata,将以匿名接入——观众 uid 会被服务端抹成 0'); } - const handlers: LiveHandlers = { - onCmd: (msg) => { - if (generation === this.lifecycleGeneration) this.onCmd(msg); - }, - onStatus: (s) => { - if (generation === this.lifecycleGeneration) this.onLiveStatus(s); - }, - }; - this.client = this.opts.source - ? this.opts.source(handlers) - : new LiveClient({ - roomId: this.opts.roomId, - sessdata: this.opts.sessdata ?? '', - log: host.log, - onCmd: handlers.onCmd, - onStatus: handlers.onStatus, - }); + this.client = this.liveSource(generation, host); this.onLiveStatus(this.client.current()); this.shutdownChecks = this.unknownSourceShutdownChecks((t) => t.sourcePending); await this.client.start(); @@ -526,6 +540,7 @@ export class BilibiliWorld implements World { async stop(): Promise { ++this.lifecycleGeneration; + this.login.cancel(); this.coalescing.flush('stop'); this.flushCoalesceLog(true); @@ -832,6 +847,61 @@ export class BilibiliWorld implements World { } } + /** 回调只认 `generation` 仍是当前代的直播源。 */ + private liveSource(generation: number, host: WorldHost): LiveSource { + const handlers: LiveHandlers = { + onCmd: (msg) => { + if (generation === this.lifecycleGeneration) this.onCmd(msg); + }, + onStatus: (s) => { + if (generation === this.lifecycleGeneration) this.onLiveStatus(s); + }, + }; + return this.opts.source + ? this.opts.source(handlers) + : new LiveClient({ + roomId: this.opts.roomId, + sessdata: this.sessdata, + log: host.log, + onCmd: handlers.onCmd, + onStatus: handlers.onStatus, + }); + } + + /** 先写回配置(失败则抛给登录流程),再只重建直播间长连;Overlay、归并缓冲与开播基线不动。 */ + private applySessdata(value: string): void { + this.opts.onSessdata?.(value); + this.sessdata = value; + const host = this.host; + void this.reconnectLive().catch((error: unknown) => { + host?.log.error('B站换登录凭证后重连失败', { err: error instanceof Error ? error.message : String(error) }); + }); + } + + private async reconnectLive(): Promise { + const host = this.host; + const old = this.client; + if (!host || !old || this.opts.source) return; + const generation = ++this.lifecycleGeneration; + this.client = null; + await old.stop(); + if (generation !== this.lifecycleGeneration) return; + this.anonymousLoginReported = false; + host.log.info('B站登录凭证已更新,重连直播间'); + const client = this.liveSource(generation, host); + this.client = client; + this.onLiveStatus(client.current()); + await client.start(); + } + + private loginState(): Record { + return { + ...this.login.current, + configured: this.sessdata !== '', + selfUid: this.status?.selfUid ?? 0, + }; + } + private logState(): Record { return { status: this.status, @@ -1265,7 +1335,7 @@ export class BilibiliWorld implements World { private checkAnonymousLogin(status: LiveStatus): void { if (status.phase !== 'connected') return; - if (!this.opts.sessdata) return; + if (!this.sessdata) return; if (status.selfUid > 0) { this.anonymousLoginReported = false; return; diff --git a/tests/worlds/bilibili/login.test.ts b/tests/worlds/bilibili/login.test.ts new file mode 100644 index 000000000..dea226bc0 --- /dev/null +++ b/tests/worlds/bilibili/login.test.ts @@ -0,0 +1,154 @@ +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ioPageContribution } from '../../../src/bot.ts'; +import type { WorldHost } from '../../../src/core/types.ts'; +import type { LiveStatus } from '../../../src/worlds/bilibili/client.ts'; +import { BrowserLogin } from '../../../src/worlds/bilibili/login.ts'; +import { BilibiliWorld } from '../../../src/worlds/bilibili/world.ts'; + +/** 浏览器里复制出来的 SESSDATA 就带 `%2C` 与 `*`,写回配置的必须是这个原样。 */ +const RAW_SESSDATA = 'a1b2c3d4%2C1790000000%2Cef56a*21'; + +interface PollReply { + code: number; + cookies?: string[]; +} + +const servers: Server[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) await new Promise((resolve) => server.close(() => resolve())); +}); + +describe('浏览器确认登录', () => { + it('确认后把 Set-Cookie 里的 SESSDATA 原样写回,面板读到成功与 uid', async () => { + const passport = await fakePassport([ + { code: 86101 }, + { + code: 0, + cookies: [ + `SESSDATA=${RAW_SESSDATA}; Path=/; Domain=bilibili.com; HttpOnly; Secure`, + 'bili_jct=0123abcd; Path=/; Domain=bilibili.com', + 'DedeUserID=42; Path=/; Domain=bilibili.com', + ], + }, + ]); + const saved: string[] = []; + const module = new BilibiliWorld({ roomId: 0, passportBase: passport.base, onSessdata: (value) => saved.push(value) }); + const page = ioPageContribution('bilibili', 'B 站直播间', undefined, module); + + const started = await page.invoke!('login', 'start', []) as Record; + expect(started).toMatchObject({ phase: 'waiting', url: passport.confirmUrl, configured: false }); + + await until(() => saved.length > 0, 8000); + expect(saved).toEqual([RAW_SESSDATA]); + expect(await page.invoke!('login', 'state', [])).toMatchObject({ phase: 'done', uid: 42, url: null, configured: true }); + expect(passport.polledKeys.every((key) => key === 'key-1')).toBe(true); + }, 15000); + + it('写回配置失败时这次登录记为失败,仍用旧凭证', async () => { + const passport = await fakePassport([{ code: 0, cookies: [`SESSDATA=${RAW_SESSDATA}; Path=/`] }]); + const module = new BilibiliWorld({ + roomId: 0, + passportBase: passport.base, + onSessdata: () => { throw new Error('config.json 被占用'); }, + }); + const page = ioPageContribution('bilibili', 'B 站直播间', undefined, module); + await page.invoke!('login', 'start', []); + await until(async () => (await page.invoke!('login', 'state', []) as { phase: string }).phase !== 'waiting', 8000); + expect(await page.invoke!('login', 'state', [])).toMatchObject({ + phase: 'failed', + error: expect.stringContaining('config.json 被占用'), + configured: false, + }); + }, 15000); + + it('登录拿到的凭证接入后仍是匿名时,照已配置凭证的规则报错', async () => { + const passport = await fakePassport([{ code: 0, cookies: [`SESSDATA=${RAW_SESSDATA}`] }]); + const anonymous: LiveStatus = { + phase: 'connected', roomId: 6, realRoomId: 6, title: '测试间', living: false, liveStartedAt: null, selfUid: 0, lastError: null, + }; + let report: ((status: LiveStatus) => void) | null = null; + const errors: string[] = []; + const log = { info: () => {}, warn: () => {}, debug: () => {}, error: (msg: string) => { errors.push(msg); } }; + const module = new BilibiliWorld({ + roomId: 6, + passportBase: passport.base, + onSessdata: () => {}, + source: (handlers) => { + report = handlers.onStatus; + return { start: async () => {}, stop: async () => {}, current: () => anonymous }; + }, + }); + await module.start({ log: { ...log, child: () => log }, pushCandidate: async () => [] } as unknown as WorldHost); + try { + expect(errors).toEqual([]); + const page = ioPageContribution('bilibili', 'B 站直播间', undefined, module); + await page.invoke!('login', 'start', []); + await until(async () => (await page.invoke!('login', 'state', []) as { phase: string }).phase === 'done', 8000); + report!(anonymous); + expect(errors).toEqual([expect.stringContaining('登录接口仍返回匿名状态')]); + } finally { + await module.stop(); + } + }, 15000); + + it('链接作废后停止轮询,取消后的迟到结果不再写回', async () => { + const expiring = await fakePassport([{ code: 86090 }, { code: 86038 }]); + const login = new BrowserLogin({ passportBase: expiring.base, pollMs: 10, onSessdata: () => {} }); + await login.start(); + await until(() => login.current.phase === 'expired', 3000); + const polls = expiring.polledKeys.length; + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(expiring.polledKeys.length).toBe(polls); + expect(login.current.url).toBeNull(); + + const late = await fakePassport([{ code: 0, cookies: [`SESSDATA=${RAW_SESSDATA}`] }]); + const saved: string[] = []; + const cancelled = new BrowserLogin({ passportBase: late.base, pollMs: 30, onSessdata: (value) => saved.push(value) }); + await cancelled.start(); + cancelled.cancel(); + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(saved).toEqual([]); + expect(cancelled.current.phase).toBe('idle'); + }); +}); + +/** 本机假 passport:生成固定的 key,轮询依次回放 `replies`,用完后重复最后一条。 */ +async function fakePassport(replies: PollReply[]): Promise<{ base: string; confirmUrl: string; polledKeys: string[] }> { + const polledKeys: string[] = []; + const confirmUrl = 'https://account.example.test/h5/scan-web?qrcode_key=key-1'; + let next = 0; + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (url.pathname === '/x/passport-login/web/qrcode/generate') { + json(response, { code: 0, message: '0', data: { url: confirmUrl, qrcode_key: 'key-1' } }); + return; + } + if (url.pathname === '/x/passport-login/web/qrcode/poll') { + polledKeys.push(url.searchParams.get('qrcode_key') ?? ''); + const reply = replies[Math.min(next, replies.length - 1)]; + next += 1; + if (reply.cookies) response.setHeader('Set-Cookie', reply.cookies); + json(response, { code: 0, message: '0', data: { url: '', refresh_token: '', timestamp: 0, code: reply.code, message: '' } }); + return; + } + response.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + return { base: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, confirmUrl, polledKeys }; +} + +function json(response: ServerResponse, body: unknown): void { + response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(body)); +} + +async function until(check: () => boolean | Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!(await check())) { + if (Date.now() > deadline) throw new Error('等待超时'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} diff --git a/tests/worlds/bilibili/webui.test.ts b/tests/worlds/bilibili/webui.test.ts index e62ad3026..47d20fb1d 100644 --- a/tests/worlds/bilibili/webui.test.ts +++ b/tests/worlds/bilibili/webui.test.ts @@ -1,5 +1,5 @@ /** - * 直播间 World 的 WebUI 表面:只保留日志。 + * 直播间 World 的 WebUI 表面:日志与浏览器确认登录。 * * 咬住的是**两侧对齐**:服务端声明的局部 panel id 与扩展导出的键一一对应。 * 对不上时 WebUI 渲染的是"扩展缺这个面板"的错误卡,而那张卡在真直播时才会 @@ -23,7 +23,7 @@ describe('bilibili 的面板声明', () => { it('适配成 provider 贡献后:局部 id 原样,配置组与环境提示词照带', () => { const c = ioPageContribution('bilibili', 'B 站直播间', undefined, mod()); expect(c.id).toBe('world:bilibili'); - expect(c.panels?.map((p) => p.id)).toEqual(['log']); + expect(c.panels?.map((p) => p.id)).toEqual(['log', 'login']); expect(c.config?.map((g) => g.id)).toEqual(['world:bilibili']); expect(c.promptDocs?.map((d) => d.key)).toEqual(['worlds.bilibili.envPrompt']); const properties = c.config?.[0].schema.properties ?? {}; From 614b2d66b0bae0efd0b9885c0d50ca93ce56a023 Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:58:33 -0500 Subject: [PATCH 6/8] =?UTF-8?q?fix(worlds/bilibili):=20=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=8E=92=E9=98=9F=E7=9A=84=E5=A4=87=E6=B3=A8=E4=B8=8D=E8=AE=BE?= =?UTF-8?q?=E5=AD=97=E6=95=B0=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 60 字没有来源,也推不出:Overlay 里备注单行显示,放不下以省略号截断 Co-Authored-By: Claude Opus 5.5 --- src/worlds/bilibili/overlay/video-queue.ts | 5 ----- src/worlds/bilibili/world.ts | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/worlds/bilibili/overlay/video-queue.ts b/src/worlds/bilibili/overlay/video-queue.ts index 365f93407..8e989cc0f 100644 --- a/src/worlds/bilibili/overlay/video-queue.ts +++ b/src/worlds/bilibili/overlay/video-queue.ts @@ -5,7 +5,6 @@ import type { VideoQueueItem, VideoQueueState } from './types.ts'; /** BV 号是 `BV1` 加 9 位 base58 字符。 */ const BVID_RE = /(? VIDEO_QUEUE_NOTE_MAX_CHARS) { - throw new Error(`note 最多 ${VIDEO_QUEUE_NOTE_MAX_CHARS} 字,当前 ${count} 字`); - } if (this.state.items.length >= maxItems) { throw new Error(`队列已满(${this.state.items.length}/${maxItems})`); } diff --git a/src/worlds/bilibili/world.ts b/src/worlds/bilibili/world.ts index 5c05ba7ec..f499e6853 100644 --- a/src/worlds/bilibili/world.ts +++ b/src/worlds/bilibili/world.ts @@ -37,7 +37,7 @@ import { import { projectOverlayEvent } from './overlay/project.ts'; import { BilibiliOverlayServer, OverlayEditorConflictError } from './overlay/server.ts'; import type { BilibiliOverlayConfig, OverlayAudienceEvent, VideoQueueState } from './overlay/types.ts'; -import { parseBvid, VIDEO_QUEUE_NOTE_MAX_CHARS, VideoQueueStore } from './overlay/video-queue.ts'; +import { parseBvid, VideoQueueStore } from './overlay/video-queue.ts'; import { BILIBILI_DEFAULTS, bilibiliConfigGroup } from './config.ts'; import { text, type BilibiliText } from './strings.ts'; @@ -240,7 +240,6 @@ export function bilibiliToolDecls( }, note: { type: 'string', - maxLength: VIDEO_QUEUE_NOTE_MAX_CHARS, description: 'add only: short text shown after the BV id, such as the video title or who asked for it.', }, position: { From f8c261266711931070df025d4aa192280384402c Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:58:33 -0500 Subject: [PATCH 7/8] =?UTF-8?q?fix(worlds/bilibili):=20=E6=8E=92=E9=98=9F?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=86=99=E6=98=8E=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=BD=95=E6=97=B6=E6=98=BE=E7=A4=BA=E5=9C=A8=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原句说画面上有排队列表,但缺省设计没有视频排队组件;队列属于 World, 只有 Overlay 放了排队组件时观众才看得到 Co-Authored-By: Claude Opus 5.5 --- src/worlds/bilibili/ENV_PROMPT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/worlds/bilibili/ENV_PROMPT.md b/src/worlds/bilibili/ENV_PROMPT.md index c84704d71..174b0c1e7 100644 --- a/src/worlds/bilibili/ENV_PROMPT.md +++ b/src/worlds/bilibili/ENV_PROMPT.md @@ -3,6 +3,6 @@ 你可以用 `bilibili_set_announcement` 更新直播画面上的写字板,最多 {{bilibili.agentAnnouncementLimit}} 字 当前写字板内容:{{bilibili.agentAnnouncement | (暂无内容)}} 可以把你当前的目标/直播内容,或者别的你想写的更新在写字板。更新之前要通知外界。 -画面上还有一个视频排队列表,最多 {{bilibili.videoQueueLimit}} 个,用 `bilibili_update_video_queue` 加入、移除、调整顺序或清空 +你还可以用 `bilibili_update_video_queue` 维护一份视频排队列表:加入、移除、调整顺序或清空,最多 {{bilibili.videoQueueLimit}} 个。Overlay 里放了视频排队组件时,这份列表会显示在直播画面上 当前排队: {{bilibili.videoQueue | (空)}} \ No newline at end of file From af14db1f51f9648e9006bf32b65a2e96ff5891ee Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 20:58:33 -0500 Subject: [PATCH 8/8] =?UTF-8?q?docs(worlds/bilibili):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=AE=B5=E5=8F=AA=E5=86=99=E8=A1=8C=E4=B8=BA=EF=BC=8C=E5=88=A0?= =?UTF-8?q?=E6=8E=89=E5=AF=B9=E6=8C=89=E9=92=AE=E4=B8=8E=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E5=8F=99=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/worlds/bilibili/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/worlds/bilibili/README.md b/src/worlds/bilibili/README.md index 9e16dbc0e..27cba9967 100644 --- a/src/worlds/bilibili/README.md +++ b/src/worlds/bilibili/README.md @@ -48,8 +48,8 @@ B 站直播间协议只读接入。观众的弹幕、礼物、醒目留言、上 `worlds.bilibili.sessdata` 保存登录 cookie 的 SESSDATA,位于部署的 `config.json`,不进入版本控制。匿名连接仍可接收弹幕;已观测到匿名响应将观众 uid 置为 0,并对昵称脱敏。`uid=0` 时不生成 `senderKey`,昵称不作为稳定身份键。 -WebUI 的「登录 B 站」面板(`login`)由 [`login.ts`](login.ts) 向 B 站要一个网页扫码登录的确认页链接, -面板在新标签打开它;浏览器已登录 B 站时页面显示该账号和确认按钮,点确认即完成。 +WebUI 的 `login` 面板经 [`login.ts`](login.ts) 申请 B 站网页扫码登录的确认页链接;在已登录 B 站的浏览器里确认后, +登录的是这个浏览器当前的账号。 World 每 2 秒轮询登录状态,成功后取响应 `Set-Cookie` 里的 SESSDATA 原样(含 `%2C`)经 `onSessdata` 写回配置, 再只重建直播间长连;Overlay、归并缓冲与开播基线不动,所以不会补报开播。链接约 180 秒后由 B 站作废, 写回配置失败时这次登录记为失败、仍用旧凭证。