change core library to libilibili
This commit is contained in:
@@ -27,12 +27,3 @@ data/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Only the vendored blivedm crate source and its manifests are required to
|
|
||||||
# compile the local patch. Exclude its upstream docs, examples and tooling.
|
|
||||||
vendor/blivedm/*
|
|
||||||
!vendor/blivedm/Cargo.toml
|
|
||||||
!vendor/blivedm/Cargo.lock
|
|
||||||
!vendor/blivedm/LICENSE
|
|
||||||
!vendor/blivedm/src/
|
|
||||||
!vendor/blivedm/src/**
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
`apps/server-rust/` contains the Axum backend, live-event adapters, authentication, persistence, and WebSocket delivery. Database migrations live in `apps/server-rust/migrations/`; Rust unit and async tests are normally kept beside the modules they exercise. `apps/overlay/src/` contains the React/Vite control and OBS interfaces, with PWA code in `apps/overlay/pwa/` and static assets in `apps/overlay/public/`. Architecture, protocol, security, and component notes belong in `docs/`; deployment support lives in `deploy/`, `Dockerfile`, and `compose.yaml`. `vendor/blivedm/` is an intentionally patched dependency—edit it only for a targeted upstream integration change.
|
`apps/server-rust/` contains the Axum backend, live-event adapters, authentication, persistence, and WebSocket delivery. Database migrations live in `apps/server-rust/migrations/`; Rust unit and async tests are normally kept beside the modules they exercise. `apps/overlay/src/` contains the React/Vite control and OBS interfaces, with PWA code in `apps/overlay/pwa/` and static assets in `apps/overlay/public/`. Architecture, protocol, security, and component notes belong in `docs/`; deployment support lives in `deploy/`, `Dockerfile`, and `compose.yaml`. The backend uses the sibling `../libilibili` checkout as a path dependency; coordinate API changes in that crate instead of copying it here.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -6,10 +6,12 @@ COPY apps/overlay ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM rust:1.97-bookworm AS rust-build
|
FROM rust:1.97-bookworm AS rust-build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libasound2-dev && rm -rf /var/lib/apt/lists/*
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/
|
COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/
|
||||||
COPY vendor/blivedm ./vendor/blivedm
|
# `libilibili` is a sibling checkout in local development. Compose exposes it
|
||||||
|
# as a named build context so the image always compiles the same local source
|
||||||
|
# without copying a second, drifting crate into this repository.
|
||||||
|
COPY --from=libilibili . /libilibili
|
||||||
COPY apps/server-rust/src ./apps/server-rust/src
|
COPY apps/server-rust/src ./apps/server-rust/src
|
||||||
COPY apps/server-rust/migrations ./apps/server-rust/migrations
|
COPY apps/server-rust/migrations ./apps/server-rust/migrations
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
@@ -18,11 +20,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server
|
cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl libasound2 libssl3 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# The upstream client logs full upstream HTTP responses at INFO, which can include
|
|
||||||
# account metadata and short-lived connection tokens. Keep application lifecycle
|
|
||||||
# logs while suppressing dependency INFO output by default.
|
|
||||||
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
|
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
|
||||||
COPY --from=web-build /app/apps/overlay/dist /app/web
|
COPY --from=web-build /app/apps/overlay/dist /app/web
|
||||||
EXPOSE 9719
|
EXPOSE 9719
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request`
|
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request`
|
||||||
点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
||||||
|
|
||||||
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在
|
直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
|
||||||
`vendor/blivedm`
|
crate。它负责 Cookie/WBI
|
||||||
固定了保留原始 JSON、可观测发送失败和安全重连所需的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃,也让宿主在 socket 恢复失败后重建客户端。
|
API、直播 WebSocket 认证、心跳以及 JSON、zlib、brotli 包解析;本项目的 provider
|
||||||
|
adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。crate 尚未建模的少量兼容命令只在 provider 边界解析,原始包不会进入组件协议。
|
||||||
|
|
||||||
## 文档索引
|
## 文档索引
|
||||||
|
|
||||||
@@ -36,6 +37,14 @@
|
|||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
|
源码目录需要保持为相邻 checkout,Compose 会把 `libilibili` 作为独立 BuildKit 上下文传入镜像:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source/
|
||||||
|
├── libilibili/
|
||||||
|
└── lxc-streamutils/
|
||||||
|
```
|
||||||
|
|
||||||
先复制配置并生成独立密钥:
|
先复制配置并生成独立密钥:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -82,8 +91,8 @@ git diff --check
|
|||||||
```
|
```
|
||||||
|
|
||||||
`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git
|
`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git
|
||||||
ignore 会排除依赖、构建产物、第三方 vendor、PNG/SVG 和包含真实 Secret 的 `config.toml`。不要对
|
ignore 会排除依赖、构建产物、PNG/SVG 和包含真实 Secret 的 `config.toml`。`libilibili`
|
||||||
`vendor/blivedm` 做无关的批量风格改写,以便继续审查上游补丁。
|
是独立 crate;协议解析能力应在该项目中维护,本仓库只维护领域事件适配。
|
||||||
|
|
||||||
## 首次初始化与登录
|
## 首次初始化与登录
|
||||||
|
|
||||||
|
|||||||
Generated
+146
-2409
File diff suppressed because it is too large
Load Diff
@@ -7,14 +7,12 @@ edition = "2024"
|
|||||||
axum = { version = "0.8", features = ["ws", "json"] }
|
axum = { version = "0.8", features = ["ws", "json"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
# Patched local copy of the published blivedm_rs crate. The patch preserves
|
|
||||||
# the upstream raw payload so the application can retain UID, price and event
|
|
||||||
# identifiers for atomic accounting and gift de-duplication.
|
|
||||||
blivedm = { path = "../../vendor/blivedm", default-features = false }
|
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
deadpool-postgres = "0.14"
|
deadpool-postgres = "0.14"
|
||||||
futures-channel = "0.3"
|
# Kept as a sibling checkout during development. Docker Compose supplies the
|
||||||
|
# same directory as a named BuildKit context at `/libilibili`.
|
||||||
|
libilibili = { path = "../../../libilibili" }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ crate 导出。
|
|||||||
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
|
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
|
||||||
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
|
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
|
||||||
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
|
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
|
||||||
- Bilibili listener 必须保持 20 秒心跳;socket 内部重连失败后由 adapter 重建完整客户端。
|
- `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。
|
||||||
- projection 无副作用;可靠业务动作必须使用幂等 handler。
|
- projection 无副作用;可靠业务动作必须使用幂等 handler。
|
||||||
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
|
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
|
||||||
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
|
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
|
||||||
@@ -42,7 +42,8 @@ cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps
|
|||||||
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
|
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
|
||||||
```
|
```
|
||||||
|
|
||||||
第三方 `vendor/blivedm` 不作为本项目风格重写目标;项目只维护保留原始 JSON 所需的小补丁。
|
直播协议、WBI 与 WebSocket 解包由相邻的 `libilibili`
|
||||||
|
crate 维护;本项目只测试强类型命令到领域事件的映射。
|
||||||
|
|
||||||
更多设计说明:
|
更多设计说明:
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ impl Config {
|
|||||||
legacy_obs_access_token: file.obs.access_token,
|
legacy_obs_access_token: file.obs.access_token,
|
||||||
legacy_overlay_defaults: overlay_defaults(file.overlay),
|
legacy_overlay_defaults: overlay_defaults(file.overlay),
|
||||||
log_filter: file.logging.filter.unwrap_or_else(|| {
|
log_filter: file.logging.filter.unwrap_or_else(|| {
|
||||||
"lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into()
|
"lxc_stream_server=info,libilibili=warn,tokio_postgres=warn".into()
|
||||||
}),
|
}),
|
||||||
gift_refresh_seconds: file
|
gift_refresh_seconds: file
|
||||||
.gifts
|
.gifts
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -1,6 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
additional_contexts:
|
||||||
|
# Cargo resolves apps/server-rust/../../../libilibili to this checkout.
|
||||||
|
libilibili: ../libilibili
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
user: '${APP_UID:-1000}:${APP_GID:-1000}'
|
user: '${APP_UID:-1000}:${APP_GID:-1000}'
|
||||||
|
|||||||
+3
-3
@@ -115,7 +115,7 @@ guard = true
|
|||||||
like = false
|
like = false
|
||||||
share = false
|
share = false
|
||||||
|
|
||||||
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。
|
# 默认记录本服务生命周期信息,并保持依赖库仅输出警告。
|
||||||
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。
|
# libilibili 的凭据 Debug 已脱敏,但仍不要记录 Cookie 或原始认证载荷。
|
||||||
[logging]
|
[logging]
|
||||||
filter = "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn"
|
filter = "lxc_stream_server=info,libilibili=warn,tokio_postgres=warn"
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
|
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
|
||||||
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据获取 Cookie,并把原始消息转换成
|
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据构造 `libilibili`
|
||||||
`LiveEvent`。
|
客户端;crate 负责 WBI、WebSocket 与压缩包解析,adapter 再把强类型命令转换成 `LiveEvent`。
|
||||||
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
|
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
|
||||||
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
||||||
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
||||||
@@ -71,6 +71,7 @@ flowchart LR
|
|||||||
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
|
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
|
||||||
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。
|
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。
|
||||||
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
|
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
|
||||||
|
- `libilibili` 是源码树中的相邻 crate,由 Compose named build context 注入 Rust 构建阶段。
|
||||||
|
|
||||||
## 代码导航
|
## 代码导航
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ token。以下规则是实现约束,而不是可选部署建议。
|
|||||||
- Key 编码成单一路径段,不能注入额外路径。
|
- Key 编码成单一路径段,不能注入额外路径。
|
||||||
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
|
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
|
||||||
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
|
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
|
||||||
- 日志不得把 `blivedm` 调到可能打印认证响应的详细级别。
|
- `libilibili::Credentials` 的 `Debug` 已脱敏,但应用仍不得记录 Cookie、原始认证响应或弹幕 token。
|
||||||
|
|
||||||
## HTTP、WebSocket 与 OBS
|
## HTTP、WebSocket 与 OBS
|
||||||
|
|
||||||
|
|||||||
-3934
File diff suppressed because it is too large
Load Diff
Vendored
-172
@@ -1,172 +0,0 @@
|
|||||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
|
||||||
#
|
|
||||||
# When uploading crates to the registry Cargo will automatically
|
|
||||||
# "normalize" Cargo.toml files for maximal compatibility
|
|
||||||
# with all versions of Cargo and also rewrite `path` dependencies
|
|
||||||
# to registry (e.g., crates.io) dependencies.
|
|
||||||
#
|
|
||||||
# If you are reading this file be aware that the original Cargo.toml
|
|
||||||
# will likely look very different (and much more reasonable).
|
|
||||||
# See Cargo.toml.orig for the original contents.
|
|
||||||
|
|
||||||
[package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "blivedm"
|
|
||||||
version = "0.5.6"
|
|
||||||
authors = ["isomo <jiahaoxing2000@gmail.com>"]
|
|
||||||
build = false
|
|
||||||
publish = true
|
|
||||||
autolib = false
|
|
||||||
autobins = false
|
|
||||||
autoexamples = false
|
|
||||||
autotests = false
|
|
||||||
autobenches = false
|
|
||||||
description = "Bilibili live room danmaku WebSocket client with TTS and plugin support"
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = [
|
|
||||||
"bilibili",
|
|
||||||
"danmaku",
|
|
||||||
"live",
|
|
||||||
"websocket",
|
|
||||||
"tts",
|
|
||||||
]
|
|
||||||
categories = [
|
|
||||||
"command-line-utilities",
|
|
||||||
"network-programming",
|
|
||||||
]
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
repository = "https://github.com/isomoes/blivedm_rs"
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
rustdoc-args = [
|
|
||||||
"--cfg",
|
|
||||||
"docsrs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[features]
|
|
||||||
browser_cookies = [
|
|
||||||
"dep:sqlite",
|
|
||||||
"dep:directories",
|
|
||||||
"dep:chrono",
|
|
||||||
]
|
|
||||||
default = ["browser_cookies"]
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "blivedm"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "blivedm"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "integration_bili_live_client"
|
|
||||||
path = "examples/integration_bili_live_client.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "simple_client"
|
|
||||||
path = "examples/simple_client.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "tts_example"
|
|
||||||
path = "examples/tts_example.rs"
|
|
||||||
|
|
||||||
[dependencies.arboard]
|
|
||||||
version = "3.4"
|
|
||||||
features = ["wayland-data-control"]
|
|
||||||
|
|
||||||
[dependencies.base64]
|
|
||||||
version = "0.21"
|
|
||||||
|
|
||||||
[dependencies.brotlic]
|
|
||||||
version = "0.8.1"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
version = "0.4"
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[dependencies.clap]
|
|
||||||
version = "4.0"
|
|
||||||
features = ["derive"]
|
|
||||||
|
|
||||||
[dependencies.clap_complete]
|
|
||||||
version = "4.0"
|
|
||||||
|
|
||||||
[dependencies.crossterm]
|
|
||||||
version = "0.28"
|
|
||||||
|
|
||||||
[dependencies.directories]
|
|
||||||
version = "5.0"
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[dependencies.dirs]
|
|
||||||
version = "5.0"
|
|
||||||
|
|
||||||
[dependencies.env_logger]
|
|
||||||
version = "0.11.8"
|
|
||||||
|
|
||||||
[dependencies.futures]
|
|
||||||
version = "0.3"
|
|
||||||
|
|
||||||
[dependencies.futures-channel]
|
|
||||||
version = "0.3.28"
|
|
||||||
|
|
||||||
[dependencies.http]
|
|
||||||
version = "0.2.11"
|
|
||||||
|
|
||||||
[dependencies.log]
|
|
||||||
version = "0.4"
|
|
||||||
|
|
||||||
[dependencies.md5]
|
|
||||||
version = "0.7"
|
|
||||||
|
|
||||||
[dependencies.native-tls]
|
|
||||||
version = "0.2.0"
|
|
||||||
|
|
||||||
[dependencies.ratatui]
|
|
||||||
version = "0.29"
|
|
||||||
|
|
||||||
[dependencies.reqwest]
|
|
||||||
version = "0.11.17"
|
|
||||||
features = [
|
|
||||||
"blocking",
|
|
||||||
"cookies",
|
|
||||||
"rustls-tls",
|
|
||||||
"json",
|
|
||||||
"stream",
|
|
||||||
]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dependencies.rodio]
|
|
||||||
version = "0.17"
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
version = "1.0"
|
|
||||||
features = ["derive"]
|
|
||||||
|
|
||||||
[dependencies.serde_json]
|
|
||||||
version = "1.0"
|
|
||||||
|
|
||||||
[dependencies.sqlite]
|
|
||||||
version = "0.36"
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[dependencies.tokio]
|
|
||||||
version = "1"
|
|
||||||
features = [
|
|
||||||
"rt-multi-thread",
|
|
||||||
"macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies.toml]
|
|
||||||
version = "0.8"
|
|
||||||
|
|
||||||
[dependencies.tungstenite]
|
|
||||||
version = "0.20.1"
|
|
||||||
|
|
||||||
[dependencies.unicode-width]
|
|
||||||
version = "0.2.0"
|
|
||||||
|
|
||||||
[dependencies.url]
|
|
||||||
version = "2.3.1"
|
|
||||||
Vendored
-21
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 isomo
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
Vendored
-537
@@ -1,537 +0,0 @@
|
|||||||
// src/client/auth.rs
|
|
||||||
//! Authentication helpers for Bilibili live danmaku WebSocket client
|
|
||||||
|
|
||||||
use md5;
|
|
||||||
use reqwest::StatusCode;
|
|
||||||
use reqwest::header::HeaderMap;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
// Add browser cookie support
|
|
||||||
#[cfg(feature = "browser_cookies")]
|
|
||||||
use crate::browser_cookies;
|
|
||||||
|
|
||||||
/// Get Bilibili cookies from browser (preferred, newest), then fallback to provided cookie string
|
|
||||||
pub fn get_cookies_or_browser(provided_cookie: Option<&str>) -> Option<String> {
|
|
||||||
#[cfg(feature = "browser_cookies")]
|
|
||||||
{
|
|
||||||
// First try browser cookies as they are the newest
|
|
||||||
log::info!("Searching for Bilibili cookies in browser (newest)...");
|
|
||||||
if let Some(browser_cookie) = browser_cookies::find_bilibili_cookies_as_string() {
|
|
||||||
log::info!("Found Bilibili cookies in browser (using newest)");
|
|
||||||
return Some(browser_cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("No Bilibili cookies found in browser, checking provided cookie...");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "browser_cookies"))]
|
|
||||||
{
|
|
||||||
log::debug!("Browser cookie feature not enabled. Skip to find cookies in browser...");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cookie) = provided_cookie {
|
|
||||||
if !cookie.is_empty() && cookie != "dummy_sessdata" && cookie.len() > 20 {
|
|
||||||
log::info!("Using provided cookie as fallback");
|
|
||||||
return Some(cookie.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::warn!("No valid Bilibili cookies found in browser or provided input");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_uid(headers: HeaderMap) -> (StatusCode, String) {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let response = client.get(UID_INIT_URL).headers(request_headers).send();
|
|
||||||
log::debug!("init uid response: {:?}", response);
|
|
||||||
let stat: StatusCode;
|
|
||||||
let body: String;
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
body = resp.text().unwrap();
|
|
||||||
log::info!("init uid response: {:?}", body);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init uid failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the buvid by sending a request and extracting the 'buvid3' cookie.
|
|
||||||
///
|
|
||||||
/// Note: This function is not used for document creation.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics if the request fails.
|
|
||||||
pub fn init_buvid(headers: HeaderMap) -> (StatusCode, String) {
|
|
||||||
// Not used for document creation.
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let response = client.get(BUVID_INIT_URL).headers(request_headers).send();
|
|
||||||
let stat: StatusCode;
|
|
||||||
let mut buvid: String = "".to_string();
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
let cookies = resp.cookies();
|
|
||||||
for i in cookies {
|
|
||||||
log::debug!("init buvid response cookie : {:?}", i);
|
|
||||||
if "buvid3".eq(i.name()) {
|
|
||||||
buvid = i.value().to_string();
|
|
||||||
log::info!("init buvid response: {:?}", buvid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init buvid failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, buvid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the room by sending a request with the given room ID.
|
|
||||||
///
|
|
||||||
/// Note: This function should NOT be used for document creation.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics if the request fails.
|
|
||||||
pub fn init_room(headers: HeaderMap, temp_room_id: &str) -> (StatusCode, String) {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let url = format!("{}?room_id={}", ROOM_INIT_URL, temp_room_id);
|
|
||||||
let response = client.get(url).headers(request_headers).send();
|
|
||||||
let stat: StatusCode;
|
|
||||||
let body: String;
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
body = resp.text().unwrap();
|
|
||||||
log::info!("init room response: {:?}", body);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init buvid failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_host_server(headers: HeaderMap, room_id: u64) -> (StatusCode, String) {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers.clone();
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
// Get WBI keys for signing
|
|
||||||
let wbi_keys = match get_wbi_keys(request_headers.clone()) {
|
|
||||||
Ok(keys) => keys,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get WBI keys: {:?}", e);
|
|
||||||
panic!("Failed to get WBI keys");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare parameters for signing
|
|
||||||
let params = vec![
|
|
||||||
("id", room_id.to_string()),
|
|
||||||
("type", "0".to_string()),
|
|
||||||
("web_location", "444.8".to_string()),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Generate signed query string
|
|
||||||
let signed_query = encode_wbi(params, wbi_keys);
|
|
||||||
|
|
||||||
// Construct final URL
|
|
||||||
let url = format!("{}?{}", DANMAKU_SERVER_CONF_URL, signed_query);
|
|
||||||
|
|
||||||
// debug log the total request
|
|
||||||
let response = client.get(url).headers(request_headers).send();
|
|
||||||
log::debug!("init host server response: {:?}", response);
|
|
||||||
let stat: StatusCode;
|
|
||||||
let body: String;
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
body = resp.text().unwrap();
|
|
||||||
log::info!("init host server response body: {:?}", body);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init host server failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WBI signing constants and functions
|
|
||||||
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
|
|
||||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,
|
|
||||||
28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25,
|
|
||||||
54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct WbiImg {
|
|
||||||
img_url: String,
|
|
||||||
sub_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Data {
|
|
||||||
wbi_img: WbiImg,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ResWbi {
|
|
||||||
data: Data,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对 imgKey 和 subKey 进行字符顺序打乱编码
|
|
||||||
fn get_mixin_key(orig: &[u8]) -> String {
|
|
||||||
MIXIN_KEY_ENC_TAB
|
|
||||||
.iter()
|
|
||||||
.take(32)
|
|
||||||
.map(|&i| orig[i] as char)
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_url_encoded(s: &str) -> String {
|
|
||||||
s.chars()
|
|
||||||
.filter_map(|c| match c.is_ascii_alphanumeric() || "-_.~".contains(c) {
|
|
||||||
true => Some(c.to_string()),
|
|
||||||
false => {
|
|
||||||
// 过滤 value 中的 "!'()*" 字符
|
|
||||||
if "!'()*".contains(c) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let encoded = c
|
|
||||||
.encode_utf8(&mut [0; 4])
|
|
||||||
.bytes()
|
|
||||||
.fold("".to_string(), |acc, b| acc + &format!("%{:02X}", b));
|
|
||||||
Some(encoded)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为请求参数进行 wbi 签名
|
|
||||||
fn encode_wbi(params: Vec<(&str, String)>, (img_key, sub_key): (String, String)) -> String {
|
|
||||||
let cur_time = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
|
||||||
Ok(t) => t.as_secs(),
|
|
||||||
Err(_) => panic!("SystemTime before UNIX EPOCH!"),
|
|
||||||
};
|
|
||||||
_encode_wbi(params, (img_key, sub_key), cur_time)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _encode_wbi(
|
|
||||||
mut params: Vec<(&str, String)>,
|
|
||||||
(img_key, sub_key): (String, String),
|
|
||||||
timestamp: u64,
|
|
||||||
) -> String {
|
|
||||||
let mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
|
|
||||||
// 添加当前时间戳
|
|
||||||
params.push(("wts", timestamp.to_string()));
|
|
||||||
// 重新排序
|
|
||||||
params.sort_by(|a, b| a.0.cmp(b.0));
|
|
||||||
// 拼接参数
|
|
||||||
let query = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", get_url_encoded(k), get_url_encoded(v)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
// 计算签名
|
|
||||||
let web_sign = format!("{:x}", md5::compute(query.clone() + &mixin_key));
|
|
||||||
// 返回最终的 query
|
|
||||||
query + &format!("&w_rid={}", web_sign)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_wbi_keys(headers: HeaderMap) -> Result<(String, String), reqwest::Error> {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get("https://api.bilibili.com/x/web-interface/nav")
|
|
||||||
.headers(request_headers)
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
let res_wbi: ResWbi = response.json()?;
|
|
||||||
Ok((
|
|
||||||
take_filename(res_wbi.data.wbi_img.img_url).unwrap(),
|
|
||||||
take_filename(res_wbi.data.wbi_img.sub_url).unwrap(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_filename(url: String) -> Option<String> {
|
|
||||||
url.rsplit_once('/')
|
|
||||||
.and_then(|(_, s)| s.rsplit_once('.'))
|
|
||||||
.map(|(s, _)| s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const UID_INIT_URL: &str = "https://api.bilibili.com/x/web-interface/nav";
|
|
||||||
pub const BUVID_INIT_URL: &str = "https://data.bilibili.com/v/";
|
|
||||||
pub const ROOM_INIT_URL: &str =
|
|
||||||
"https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom";
|
|
||||||
pub const DANMAKU_SERVER_CONF_URL: &str =
|
|
||||||
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo";
|
|
||||||
pub const USER_AGENT: &str =
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0";
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_uid_url_constant() {
|
|
||||||
assert!(UID_INIT_URL.contains("bilibili.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_take_filename() {
|
|
||||||
assert_eq!(
|
|
||||||
take_filename(
|
|
||||||
"https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string()
|
|
||||||
),
|
|
||||||
Some("7cd084941338484aae1ad9425b84077c".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
take_filename(
|
|
||||||
"https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string()
|
|
||||||
),
|
|
||||||
Some("4932caff0ff746eab6f01bf08b70ac45".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test edge case with no extension
|
|
||||||
assert_eq!(
|
|
||||||
take_filename("https://example.com/path/file".to_string()),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encode_wbi_with_known_values() {
|
|
||||||
let params = vec![
|
|
||||||
("foo", String::from("114")),
|
|
||||||
("bar", String::from("514")),
|
|
||||||
("zab", String::from("1919810")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1702204169,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
"bar=514&foo=114&wts=1702204169&zab=1919810&w_rid=8f6f2b5b3d485fe1886cec6a0be8c5d4"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encode_wbi_bilibili_danmu_params() {
|
|
||||||
// Test with the actual Bilibili danmu parameters from the example
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Using the timestamp from the example URL (1748308267)
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748308267,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The result should contain the correct parameters and w_rid
|
|
||||||
assert!(result.contains("id=24779526"));
|
|
||||||
assert!(result.contains("type=0"));
|
|
||||||
assert!(result.contains("web_location=444.8"));
|
|
||||||
assert!(result.contains("wts=1748308267"));
|
|
||||||
assert!(result.contains("w_rid="));
|
|
||||||
|
|
||||||
// Check the parameter order (should be alphabetical)
|
|
||||||
let expected_order = "id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=";
|
|
||||||
assert!(result.starts_with(expected_order));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_wbi_signature_consistency() {
|
|
||||||
// Test that the same parameters always generate the same signature
|
|
||||||
let params1 = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let params2 = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let keys = (
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let timestamp = 1748308267;
|
|
||||||
|
|
||||||
let result1 = _encode_wbi(params1, keys.clone(), timestamp);
|
|
||||||
let result2 = _encode_wbi(params2, keys, timestamp);
|
|
||||||
|
|
||||||
assert_eq!(result1, result2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_wbi_parameter_sorting() {
|
|
||||||
// Test that parameters are properly sorted alphabetically
|
|
||||||
let params = vec![
|
|
||||||
("z_param", String::from("last")),
|
|
||||||
("a_param", String::from("first")),
|
|
||||||
("m_param", String::from("middle")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748308267,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that parameters appear in alphabetical order
|
|
||||||
let parts: Vec<&str> = result.split('&').collect();
|
|
||||||
assert!(parts[0].starts_with("a_param="));
|
|
||||||
assert!(parts[1].starts_with("m_param="));
|
|
||||||
assert!(parts[2].starts_with("wts="));
|
|
||||||
assert!(parts[3].starts_with("z_param="));
|
|
||||||
assert!(parts[4].starts_with("w_rid="));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_correct_bilibili_url_signature() {
|
|
||||||
// Test the exact URL from the working example:
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=884cf361b8ad4e239b4a9dbbb7134679"
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=d1e619744b4977f88ed67524a1f567cc&wts=1751072897"
|
|
||||||
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1751072897,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expected complete query string from working URL
|
|
||||||
let expected = "id=24779526&type=0&web_location=444.8&wts=1751072897&w_rid=d1e619744b4977f88ed67524a1f567cc";
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
|
|
||||||
// Extract and verify the w_rid specifically
|
|
||||||
let w_rid = result.split("w_rid=").nth(1).unwrap();
|
|
||||||
assert_eq!(w_rid, "d1e619744b4977f88ed67524a1f567cc");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_second_bilibili_url_signature() {
|
|
||||||
// Test the second URL example:
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=fa20533eb27334ba6f2ec7263721319a&wts=1748311635"
|
|
||||||
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748311635,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expected complete query string from working URL
|
|
||||||
let expected = "id=24779526&type=0&web_location=444.8&wts=1748311635&w_rid=fa20533eb27334ba6f2ec7263721319a";
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
|
|
||||||
// Extract and verify the w_rid specifically
|
|
||||||
let w_rid = result.split("w_rid=").nth(1).unwrap();
|
|
||||||
assert_eq!(w_rid, "fa20533eb27334ba6f2ec7263721319a");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_third_bilibili_url_signature() {
|
|
||||||
// Test the third URL example from README:
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507"
|
|
||||||
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748312554,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expected complete query string from working URL
|
|
||||||
let expected = "id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507";
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
|
|
||||||
// Extract and verify the w_rid specifically
|
|
||||||
let w_rid = result.split("w_rid=").nth(1).unwrap();
|
|
||||||
assert_eq!(w_rid, "30f250e8abd9effea1bcb88aab416507");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-416
@@ -1,416 +0,0 @@
|
|||||||
// src/client/browser_cookies.rs
|
|
||||||
//! Browser cookie reading functionality for automatic SESSDATA detection
|
|
||||||
|
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
|
||||||
use directories::UserDirs;
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use sqlite::Connection;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Cookie {
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
pub domain: String,
|
|
||||||
pub path: String,
|
|
||||||
pub expires: Option<DateTime<Utc>>,
|
|
||||||
pub secure: bool,
|
|
||||||
pub http_only: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Browser {
|
|
||||||
Chrome,
|
|
||||||
Firefox,
|
|
||||||
Edge,
|
|
||||||
Chromium,
|
|
||||||
Opera,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Browser {
|
|
||||||
pub fn get_cookie_db_path(&self) -> Option<PathBuf> {
|
|
||||||
let user_dirs = UserDirs::new()?;
|
|
||||||
let home_dir = user_dirs.home_dir();
|
|
||||||
|
|
||||||
match self {
|
|
||||||
Browser::Chrome => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/google-chrome/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join("Library/Application Support/Google/Chrome/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir
|
|
||||||
.join("AppData/Local/Google/Chrome/User Data/Default/Network/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Firefox => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let firefox_dir = vec![
|
|
||||||
home_dir.join(".mozilla/firefox"),
|
|
||||||
home_dir.join("snap/firefox/common/.mozilla/firefox"),
|
|
||||||
home_dir.join(".var/app/org.mozilla.firefox/.mozilla/firefox"),
|
|
||||||
home_dir.join("snap/firefox/current/.mozilla/firefox"),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.find(|p| p.exists())?;
|
|
||||||
|
|
||||||
Self::find_firefox_profile_cookies(&firefox_dir)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let firefox_dir = home_dir.join("Library/Application Support/Firefox/Profiles");
|
|
||||||
Self::find_firefox_profile_cookies(&firefox_dir)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let firefox_dir = home_dir.join("AppData/Roaming/Mozilla/Firefox/Profiles");
|
|
||||||
Self::find_firefox_profile_cookies(&firefox_dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Edge => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/microsoft-edge/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir.join("Library/Application Support/Microsoft Edge/Default/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir
|
|
||||||
.join("AppData/Local/Microsoft/Edge/User Data/Default/Network/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Chromium => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/chromium/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join("Library/Application Support/Chromium/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join("AppData/Local/Chromium/User Data/Default/Network/Cookies"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Opera => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/opera/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(
|
|
||||||
"Library/Application Support/com.operasoftware.Opera/Default/Cookies",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir
|
|
||||||
.join("AppData/Roaming/Opera Software/Opera Stable/Network/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_firefox_profile_cookies(firefox_dir: &Path) -> Option<PathBuf> {
|
|
||||||
if !firefox_dir.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for the default profile directory
|
|
||||||
let entries = fs::read_dir(firefox_dir).ok()?;
|
|
||||||
for entry in entries {
|
|
||||||
if let Ok(entry) = entry {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
let dir_name = path.file_name()?.to_str()?;
|
|
||||||
if dir_name.contains(".default") || dir_name.contains(".default-release") {
|
|
||||||
let cookies_path = path.join("cookies.sqlite");
|
|
||||||
if cookies_path.exists() {
|
|
||||||
return Some(cookies_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_all_supported() -> Vec<Browser> {
|
|
||||||
vec![
|
|
||||||
Browser::Chrome,
|
|
||||||
Browser::Firefox,
|
|
||||||
Browser::Edge,
|
|
||||||
Browser::Chromium,
|
|
||||||
Browser::Opera,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read cookies from a browser's cookie database
|
|
||||||
pub fn read_cookies_from_browser(
|
|
||||||
browser: &Browser,
|
|
||||||
domain_filter: Option<&str>,
|
|
||||||
) -> Result<Vec<Cookie>, String> {
|
|
||||||
let db_path = browser
|
|
||||||
.get_cookie_db_path()
|
|
||||||
.ok_or_else(|| "Could not determine cookie database path".to_string())?;
|
|
||||||
|
|
||||||
if !db_path.exists() {
|
|
||||||
return Err(format!("Cookie database not found at: {:?}", db_path));
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Reading cookies from: {:?}", db_path);
|
|
||||||
|
|
||||||
// Create a temporary copy of the database since browsers might have it locked
|
|
||||||
let temp_path = std::env::temp_dir().join(format!("temp_cookies_{}.db", std::process::id()));
|
|
||||||
if let Err(e) = fs::copy(&db_path, &temp_path) {
|
|
||||||
return Err(format!("Failed to copy cookie database: {}", e));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = match browser {
|
|
||||||
Browser::Firefox => read_firefox_cookies(&temp_path, domain_filter),
|
|
||||||
_ => read_chromium_cookies(&temp_path, domain_filter),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
let _ = fs::remove_file(&temp_path);
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_chromium_cookies(
|
|
||||||
db_path: &Path,
|
|
||||||
domain_filter: Option<&str>,
|
|
||||||
) -> Result<Vec<Cookie>, String> {
|
|
||||||
let connection =
|
|
||||||
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
|
|
||||||
|
|
||||||
let mut query =
|
|
||||||
"SELECT name, value, host_key, path, expires_utc, is_secure, is_httponly FROM cookies"
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if let Some(domain) = domain_filter {
|
|
||||||
query.push_str(&format!(" WHERE host_key LIKE '%{}'", domain));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cookies = Vec::new();
|
|
||||||
|
|
||||||
connection
|
|
||||||
.iterate(query, |pairs| {
|
|
||||||
let mut cookie_data = HashMap::new();
|
|
||||||
for &(column, value) in pairs.iter() {
|
|
||||||
cookie_data.insert(column, value.unwrap_or(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expires = if let Some(expires_str) = cookie_data.get("expires_utc") {
|
|
||||||
if let Ok(expires_microseconds) = expires_str.parse::<i64>() {
|
|
||||||
// Chrome stores time as microseconds since Windows epoch (1601-01-01)
|
|
||||||
// Convert to Unix timestamp (seconds since 1970-01-01)
|
|
||||||
let windows_epoch_offset = 11644473600_i64; // seconds between 1601 and 1970
|
|
||||||
let unix_timestamp = (expires_microseconds / 1_000_000) - windows_epoch_offset;
|
|
||||||
Utc.timestamp_opt(unix_timestamp, 0).single()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let cookie = Cookie {
|
|
||||||
name: cookie_data.get("name").unwrap_or(&"").to_string(),
|
|
||||||
value: cookie_data.get("value").unwrap_or(&"").to_string(),
|
|
||||||
domain: cookie_data.get("host_key").unwrap_or(&"").to_string(),
|
|
||||||
path: cookie_data.get("path").unwrap_or(&"").to_string(),
|
|
||||||
expires,
|
|
||||||
secure: cookie_data.get("is_secure").unwrap_or(&"0") == &"1",
|
|
||||||
http_only: cookie_data.get("is_httponly").unwrap_or(&"0") == &"1",
|
|
||||||
};
|
|
||||||
|
|
||||||
cookies.push(cookie);
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to query cookies: {}", e))?;
|
|
||||||
|
|
||||||
Ok(cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_firefox_cookies(
|
|
||||||
db_path: &Path,
|
|
||||||
domain_filter: Option<&str>,
|
|
||||||
) -> Result<Vec<Cookie>, String> {
|
|
||||||
let connection =
|
|
||||||
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
|
|
||||||
|
|
||||||
let mut query =
|
|
||||||
"SELECT name, value, host, path, expiry, isSecure, isHttpOnly FROM moz_cookies".to_string();
|
|
||||||
|
|
||||||
if let Some(domain) = domain_filter {
|
|
||||||
query.push_str(&format!(" WHERE host LIKE '%{}'", domain));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cookies = Vec::new();
|
|
||||||
|
|
||||||
connection
|
|
||||||
.iterate(query, |pairs| {
|
|
||||||
let mut cookie_data = HashMap::new();
|
|
||||||
for &(column, value) in pairs.iter() {
|
|
||||||
cookie_data.insert(column, value.unwrap_or(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expires = if let Some(expires_str) = cookie_data.get("expiry") {
|
|
||||||
if let Ok(expires_timestamp) = expires_str.parse::<i64>() {
|
|
||||||
Utc.timestamp_opt(expires_timestamp, 0).single()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let cookie = Cookie {
|
|
||||||
name: cookie_data.get("name").unwrap_or(&"").to_string(),
|
|
||||||
value: cookie_data.get("value").unwrap_or(&"").to_string(),
|
|
||||||
domain: cookie_data.get("host").unwrap_or(&"").to_string(),
|
|
||||||
path: cookie_data.get("path").unwrap_or(&"").to_string(),
|
|
||||||
expires,
|
|
||||||
secure: cookie_data.get("isSecure").unwrap_or(&"0") == &"1",
|
|
||||||
http_only: cookie_data.get("isHttpOnly").unwrap_or(&"0") == &"1",
|
|
||||||
};
|
|
||||||
|
|
||||||
cookies.push(cookie);
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to query cookies: {}", e))?;
|
|
||||||
|
|
||||||
Ok(cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find SESSDATA cookie from all supported browsers
|
|
||||||
pub fn find_bilibili_cookies_as_string() -> Option<String> {
|
|
||||||
let browsers = Browser::get_all_supported();
|
|
||||||
let mut all_cookies = vec![];
|
|
||||||
|
|
||||||
for browser in browsers {
|
|
||||||
info!("Checking browser: {:?}", browser);
|
|
||||||
|
|
||||||
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
|
|
||||||
all_cookies.extend(cookies);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut valid_cookies = all_cookies
|
|
||||||
.into_iter()
|
|
||||||
.filter(|cookie| {
|
|
||||||
if let Some(expires) = cookie.expires {
|
|
||||||
if Utc::now() > expires {
|
|
||||||
warn!(
|
|
||||||
"Found expired {} cookie, expires: {:?}",
|
|
||||||
cookie.name, expires
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
// Deduplicate cookies, keeping the one with the latest expiry
|
|
||||||
valid_cookies.sort_by(|a, b| {
|
|
||||||
if a.name != b.name {
|
|
||||||
a.name.cmp(&b.name)
|
|
||||||
} else {
|
|
||||||
b.expires.cmp(&a.expires) // None is smaller
|
|
||||||
}
|
|
||||||
});
|
|
||||||
valid_cookies.dedup_by(|a, b| a.name == b.name);
|
|
||||||
|
|
||||||
if valid_cookies.is_empty() {
|
|
||||||
warn!("No valid bilibili cookies found in any browser");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Found {} valid bilibili cookies", valid_cookies.len());
|
|
||||||
|
|
||||||
let cookie_string = valid_cookies
|
|
||||||
.iter()
|
|
||||||
.map(|c| format!("{}={}", c.name, c.value))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("; ");
|
|
||||||
|
|
||||||
if cookie_string.contains("SESSDATA") {
|
|
||||||
Some(cookie_string)
|
|
||||||
} else {
|
|
||||||
warn!("No SESSDATA cookie found among the valid cookies");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all bilibili cookies from browsers for debugging
|
|
||||||
pub fn get_all_bilibili_cookies() -> HashMap<String, String> {
|
|
||||||
let mut all_cookies = HashMap::new();
|
|
||||||
let browsers = Browser::get_all_supported();
|
|
||||||
|
|
||||||
for browser in browsers {
|
|
||||||
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
|
|
||||||
for cookie in cookies {
|
|
||||||
// Only include non-expired cookies
|
|
||||||
if let Some(expires) = cookie.expires {
|
|
||||||
if Utc::now() > expires {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the most recent cookie if duplicates exist
|
|
||||||
let key = format!("{}_{}", cookie.name, cookie.domain);
|
|
||||||
all_cookies.insert(key, cookie.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
all_cookies
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_browser_path_detection() {
|
|
||||||
for browser in Browser::get_all_supported() {
|
|
||||||
let path = browser.get_cookie_db_path();
|
|
||||||
println!("{:?} cookie path: {:?}", browser, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_sessdata() {
|
|
||||||
// This test will only work if you have bilibili cookies in your browser
|
|
||||||
if let Some(sessdata) = find_bilibili_cookies_as_string() {
|
|
||||||
println!("Found SESSDATA: {}", &sessdata[..20.min(sessdata.len())]);
|
|
||||||
assert!(!sessdata.is_empty());
|
|
||||||
} else {
|
|
||||||
println!("No SESSDATA found - this is normal if you're not logged into bilibili");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-12
@@ -1,12 +0,0 @@
|
|||||||
// src/client/lib.rs
|
|
||||||
//! Library entry for the client package
|
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
#[cfg(feature = "browser_cookies")]
|
|
||||||
pub mod browser_cookies;
|
|
||||||
pub mod models;
|
|
||||||
pub mod scheduler;
|
|
||||||
pub mod websocket;
|
|
||||||
|
|
||||||
// Re-export commonly used functions
|
|
||||||
pub use auth::get_cookies_or_browser;
|
|
||||||
Vendored
-97
@@ -1,97 +0,0 @@
|
|||||||
// src/client/models.rs
|
|
||||||
//! Data models for Bilibili live danmaku WebSocket client
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct DanmuServer {
|
|
||||||
pub host: String,
|
|
||||||
pub port: i32,
|
|
||||||
pub wss_port: i32,
|
|
||||||
pub ws_port: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DanmuServer {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
host: String::from("broadcastlv.chat.bilibili.com"),
|
|
||||||
port: 2243,
|
|
||||||
wss_port: 443,
|
|
||||||
ws_port: 2244,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
|
||||||
pub struct MsgHead {
|
|
||||||
pub pack_len: u32,
|
|
||||||
pub raw_header_size: u16,
|
|
||||||
pub ver: u16,
|
|
||||||
pub operation: u32,
|
|
||||||
pub seq_id: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct AuthMessage {
|
|
||||||
pub uid: u64,
|
|
||||||
pub roomid: u64,
|
|
||||||
pub protover: i32,
|
|
||||||
pub platform: String,
|
|
||||||
pub type_: i32,
|
|
||||||
pub key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthMessage {
|
|
||||||
pub fn from(map: &HashMap<String, String>) -> AuthMessage {
|
|
||||||
AuthMessage {
|
|
||||||
uid: map.get("uid").unwrap().parse::<u64>().unwrap(),
|
|
||||||
roomid: map.get("room_id").unwrap().parse::<u64>().unwrap(),
|
|
||||||
protover: 3,
|
|
||||||
platform: "web".to_string(),
|
|
||||||
type_: 2,
|
|
||||||
key: map.get("token").unwrap().to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum BiliMessage {
|
|
||||||
Danmu {
|
|
||||||
user: String,
|
|
||||||
text: String,
|
|
||||||
},
|
|
||||||
Gift {
|
|
||||||
user: String,
|
|
||||||
gift: String,
|
|
||||||
num: String,
|
|
||||||
},
|
|
||||||
/// Online rank count message (ONLINE_RANK_COUNT)
|
|
||||||
OnlineRankCount {
|
|
||||||
/// Number of high-energy users in the live room
|
|
||||||
count: u64,
|
|
||||||
/// Number of online users in the live room
|
|
||||||
online_count: u64,
|
|
||||||
},
|
|
||||||
// Add more variants as needed
|
|
||||||
Raw(serde_json::Value),
|
|
||||||
#[deprecated(note = "Use Raw variant instead")]
|
|
||||||
Unsupported,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_auth_message_from_map() {
|
|
||||||
let mut map = std::collections::HashMap::new();
|
|
||||||
map.insert("uid".to_string(), "12345".to_string());
|
|
||||||
map.insert("room_id".to_string(), "67890".to_string());
|
|
||||||
map.insert("token".to_string(), "test_token".to_string());
|
|
||||||
let auth = AuthMessage::from(&map);
|
|
||||||
assert_eq!(auth.uid, 12345);
|
|
||||||
assert_eq!(auth.roomid, 67890);
|
|
||||||
assert_eq!(auth.key, "test_token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-192
@@ -1,192 +0,0 @@
|
|||||||
// In Cargo.toml, ensure you have: client = { path = "../client" }
|
|
||||||
use models::BiliMessage;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::models;
|
|
||||||
|
|
||||||
/// Context information passed to event handlers
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EventContext {
|
|
||||||
/// Bilibili cookies for authentication
|
|
||||||
pub cookies: Option<String>,
|
|
||||||
/// Room ID where the event occurred
|
|
||||||
pub room_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventContext {
|
|
||||||
/// Create a new EventContext with automatic cookie detection
|
|
||||||
pub fn new_with_auto_cookies(room_id: u64) -> Self {
|
|
||||||
let cookies = crate::auth::get_cookies_or_browser(None);
|
|
||||||
Self { cookies, room_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new EventContext with provided cookies
|
|
||||||
pub fn new(cookies: Option<String>, room_id: u64) -> Self {
|
|
||||||
Self { cookies, room_id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for event handlers (plugins) that process BiliMessage.
|
|
||||||
pub trait EventHandler: Send + Sync {
|
|
||||||
fn handle(&self, msg: &BiliMessage, context: &EventContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scheduling mode: Parallel or Sequential.
|
|
||||||
pub enum ScheduleMode {
|
|
||||||
Parallel,
|
|
||||||
Sequential,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scheduler struct: manages event handlers and dispatches messages.
|
|
||||||
pub struct Scheduler {
|
|
||||||
/// Each stage is a Vec of handlers to run in parallel; stages run sequentially.
|
|
||||||
stages: Vec<Vec<Arc<dyn EventHandler>>>,
|
|
||||||
/// Context information for event handlers
|
|
||||||
context: EventContext,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Scheduler {
|
|
||||||
pub fn new(context: EventContext) -> Self {
|
|
||||||
Scheduler {
|
|
||||||
stages: Vec::new(),
|
|
||||||
context,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a new stage (group of handlers to run in parallel)
|
|
||||||
pub fn add_stage(&mut self, handlers: Vec<Arc<dyn EventHandler>>) {
|
|
||||||
self.stages.push(handlers);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a single handler as a new sequential stage
|
|
||||||
pub fn add_sequential_handler(&mut self, handler: Arc<dyn EventHandler>) {
|
|
||||||
self.stages.push(vec![handler]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trigger all stages with the given BiliMessage.
|
|
||||||
pub fn trigger(&self, msg: BiliMessage) {
|
|
||||||
for stage in &self.stages {
|
|
||||||
let mut handles = vec![];
|
|
||||||
for handler in stage {
|
|
||||||
let msg = msg.clone();
|
|
||||||
let context = self.context.clone();
|
|
||||||
let handler = Arc::clone(handler);
|
|
||||||
handles.push(std::thread::spawn(move || {
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// Wait for all handlers in this stage to finish before next stage
|
|
||||||
for handle in handles {
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(left: u64, right: u64) -> u64 {
|
|
||||||
left + right
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::models::BiliMessage;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
|
||||||
|
|
||||||
struct AssertHandler {
|
|
||||||
called: Arc<AtomicBool>,
|
|
||||||
last_msg: Arc<Mutex<Option<BiliMessage>>>,
|
|
||||||
}
|
|
||||||
impl super::EventHandler for AssertHandler {
|
|
||||||
fn handle(&self, msg: &BiliMessage, _context: &super::EventContext) {
|
|
||||||
self.called.store(true, Ordering::SeqCst);
|
|
||||||
let mut lock = self.last_msg.lock().unwrap();
|
|
||||||
*lock = Some(msg.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scheduler_with_mpsc_channel() {
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
let called = Arc::new(AtomicBool::new(false));
|
|
||||||
let last_msg = Arc::new(Mutex::new(None));
|
|
||||||
let handler = AssertHandler {
|
|
||||||
called: Arc::clone(&called),
|
|
||||||
last_msg: Arc::clone(&last_msg),
|
|
||||||
};
|
|
||||||
let context = super::EventContext {
|
|
||||||
cookies: Some("test_cookies".to_string()),
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
let mut scheduler = super::Scheduler::new(context);
|
|
||||||
scheduler.add_sequential_handler(Arc::new(handler));
|
|
||||||
|
|
||||||
// Send a test message
|
|
||||||
let test_msg = BiliMessage::Danmu {
|
|
||||||
user: "user1".to_string(),
|
|
||||||
text: "hello".to_string(),
|
|
||||||
};
|
|
||||||
tx.send(test_msg.clone()).unwrap();
|
|
||||||
|
|
||||||
// Simulate receiving and triggering
|
|
||||||
if let Ok(msg) = rx.recv() {
|
|
||||||
scheduler.trigger(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert handler was called and message matches
|
|
||||||
assert!(called.load(Ordering::SeqCst), "Handler was not called");
|
|
||||||
let lock = last_msg.lock().unwrap();
|
|
||||||
assert!(lock.is_some(), "No message stored in handler");
|
|
||||||
assert_eq!(lock.as_ref().unwrap(), &test_msg, "Message does not match");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scheduler_add_stage_and_sequential_handler() {
|
|
||||||
use crate::models::BiliMessage;
|
|
||||||
|
|
||||||
struct CounterHandler {
|
|
||||||
counter: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
impl super::EventHandler for CounterHandler {
|
|
||||||
fn handle(&self, _msg: &BiliMessage, _context: &super::EventContext) {
|
|
||||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let counter1 = Arc::new(AtomicUsize::new(0));
|
|
||||||
let counter2 = Arc::new(AtomicUsize::new(0));
|
|
||||||
let counter3 = Arc::new(AtomicUsize::new(0));
|
|
||||||
|
|
||||||
let handler1 = Arc::new(CounterHandler {
|
|
||||||
counter: Arc::clone(&counter1),
|
|
||||||
});
|
|
||||||
let handler2 = Arc::new(CounterHandler {
|
|
||||||
counter: Arc::clone(&counter2),
|
|
||||||
});
|
|
||||||
let handler3 = Arc::new(CounterHandler {
|
|
||||||
counter: Arc::clone(&counter3),
|
|
||||||
});
|
|
||||||
|
|
||||||
let context = super::EventContext {
|
|
||||||
cookies: Some("test_cookies".to_string()),
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
let mut scheduler = super::Scheduler::new(context);
|
|
||||||
// Add a parallel stage (handler1 and handler2)
|
|
||||||
scheduler.add_stage(vec![handler1, handler2]);
|
|
||||||
// Add a sequential stage (handler3)
|
|
||||||
scheduler.add_sequential_handler(handler3);
|
|
||||||
|
|
||||||
let test_msg = BiliMessage::Danmu {
|
|
||||||
user: "user2".to_string(),
|
|
||||||
text: "test".to_string(),
|
|
||||||
};
|
|
||||||
scheduler.trigger(test_msg);
|
|
||||||
|
|
||||||
// Both handler1 and handler2 should be called once (parallel stage)
|
|
||||||
assert_eq!(counter1.load(Ordering::SeqCst), 1, "Handler1 not called");
|
|
||||||
assert_eq!(counter2.load(Ordering::SeqCst), 1, "Handler2 not called");
|
|
||||||
// handler3 should be called once (sequential stage)
|
|
||||||
assert_eq!(counter3.load(Ordering::SeqCst), 1, "Handler3 not called");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-544
@@ -1,544 +0,0 @@
|
|||||||
// src/client/websocket.rs
|
|
||||||
//! WebSocket client for Bilibili live danmaku messages (refactored from bili_live_dm)
|
|
||||||
|
|
||||||
use native_tls::TlsStream;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::io::ErrorKind;
|
|
||||||
use std::net::TcpStream;
|
|
||||||
use std::panic;
|
|
||||||
use tungstenite::{client, Message, WebSocket};
|
|
||||||
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use futures_channel::mpsc::Sender;
|
|
||||||
use http::Response;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::auth::*;
|
|
||||||
use crate::models::{AuthMessage, BiliMessage, DanmuServer, MsgHead};
|
|
||||||
|
|
||||||
pub struct BiliLiveClient {
|
|
||||||
ws: WebSocket<TlsStream<TcpStream>>,
|
|
||||||
cookies: String,
|
|
||||||
room_id: String,
|
|
||||||
auth_msg: String,
|
|
||||||
ss: Sender<BiliMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BiliLiveClient {
|
|
||||||
pub fn new(cookies: &str, room_id: &str, r: Sender<BiliMessage>) -> Self {
|
|
||||||
let (ws, auth_msg) = Self::connect_with_auth(cookies, room_id)
|
|
||||||
.unwrap_or_else(|e| panic!("Failed to create websocket client: {}", e));
|
|
||||||
BiliLiveClient {
|
|
||||||
ws,
|
|
||||||
cookies: cookies.to_string(),
|
|
||||||
room_id: room_id.to_string(),
|
|
||||||
auth_msg,
|
|
||||||
ss: r,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new client with automatic browser cookie detection
|
|
||||||
/// If cookies is None or empty, it will try to find cookies from browser
|
|
||||||
pub fn new_auto(
|
|
||||||
cookies: Option<&str>,
|
|
||||||
room_id: &str,
|
|
||||||
r: Sender<BiliMessage>,
|
|
||||||
) -> Result<Self, String> {
|
|
||||||
let resolved_cookies = get_cookies_or_browser(cookies)
|
|
||||||
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
|
|
||||||
let (ws, auth_msg) = Self::connect_with_auth(&resolved_cookies, room_id)?;
|
|
||||||
Ok(BiliLiveClient {
|
|
||||||
ws,
|
|
||||||
cookies: resolved_cookies,
|
|
||||||
room_id: room_id.to_string(),
|
|
||||||
auth_msg,
|
|
||||||
ss: r,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send authentication, including the built-in reconnect fallback.
|
|
||||||
///
|
|
||||||
/// The boolean lets embedding applications rebuild the whole client when
|
|
||||||
/// all socket-level recovery attempts fail. Existing CLI callers may
|
|
||||||
/// safely ignore it.
|
|
||||||
pub fn send_auth(&mut self) -> bool {
|
|
||||||
match self.send_auth_internal() {
|
|
||||||
Ok(()) => true,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("failed to send auth packet: {}", e);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a heartbeat and report whether socket-level recovery succeeded.
|
|
||||||
pub fn send_heart_beat(&mut self) -> bool {
|
|
||||||
match self.send_heart_beat_internal() {
|
|
||||||
Ok(()) => true,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("failed to send heartbeat: {}", e);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_ws_message(&mut self, resv: Vec<u8>) {
|
|
||||||
let mut offset = 0;
|
|
||||||
let header = &resv[0..16];
|
|
||||||
let mut head_1 = get_msg_header(header);
|
|
||||||
if head_1.operation == 5 || head_1.operation == 8 {
|
|
||||||
loop {
|
|
||||||
let body: &[u8] = &resv[offset + 16..offset + (head_1.pack_len as usize)];
|
|
||||||
self.parse_business_message(head_1, body);
|
|
||||||
offset += head_1.pack_len as usize;
|
|
||||||
if offset >= resv.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let temp_head = &resv[offset..(offset + 16)];
|
|
||||||
head_1 = get_msg_header(temp_head);
|
|
||||||
}
|
|
||||||
} else if head_1.operation == 3 {
|
|
||||||
let mut body: [u8; 4] = [0, 0, 0, 0];
|
|
||||||
body[0] = resv[16];
|
|
||||||
body[1] = resv[17];
|
|
||||||
body[2] = resv[18];
|
|
||||||
body[3] = resv[19];
|
|
||||||
let popularity = i32::from_be_bytes(body);
|
|
||||||
log::info!("popularity:{}", popularity);
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
"unknown message operation={:?}, header={:?}}}",
|
|
||||||
head_1.operation,
|
|
||||||
head_1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_business_message(&mut self, h: MsgHead, b: &[u8]) {
|
|
||||||
if h.operation == 5 {
|
|
||||||
if h.ver == 3 {
|
|
||||||
let res: Vec<u8> = decompress(b).unwrap();
|
|
||||||
self.parse_ws_message(res);
|
|
||||||
} else if h.ver == 0 {
|
|
||||||
let s = String::from_utf8(b.to_vec()).unwrap();
|
|
||||||
let res_json: Value = serde_json::from_str(s.as_str()).unwrap();
|
|
||||||
if let Some(msg) = handle(res_json) {
|
|
||||||
let _ = self.ss.try_send(msg);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::error!("Unknown compression format");
|
|
||||||
}
|
|
||||||
} else if h.operation == 8 {
|
|
||||||
self.send_heart_beat();
|
|
||||||
} else {
|
|
||||||
log::error!("Unknown message format {}", h.operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn receive(&mut self) -> Result<(), String> {
|
|
||||||
if self.ws.can_read() {
|
|
||||||
let msg = self.ws.read();
|
|
||||||
match msg {
|
|
||||||
Ok(m) => {
|
|
||||||
let res = m.into_data();
|
|
||||||
if res.len() >= 16 {
|
|
||||||
self.parse_ws_message(res);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(tungstenite::Error::Io(error))
|
|
||||||
if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) =>
|
|
||||||
{
|
|
||||||
// A short read timeout lets an embedding application check
|
|
||||||
// its cancellation token without treating idle rooms as a
|
|
||||||
// disconnected WebSocket.
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("read msg error: {}", e);
|
|
||||||
log::warn!("{}", msg);
|
|
||||||
self.reconnect().map_err(|reconnect_err| {
|
|
||||||
format!("{}; reconnect failed: {}", msg, reconnect_err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bound a blocking read so supervisors can stop/restart one tenant's
|
|
||||||
/// listener without leaking the previous connection.
|
|
||||||
pub fn set_read_timeout(&mut self, timeout: Option<Duration>) -> Result<(), String> {
|
|
||||||
self.ws
|
|
||||||
.get_mut()
|
|
||||||
.get_mut()
|
|
||||||
.set_read_timeout(timeout)
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close(&mut self) {
|
|
||||||
let _ = self.ws.close(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect_with_auth(
|
|
||||||
cookies: &str,
|
|
||||||
room_id: &str,
|
|
||||||
) -> Result<(WebSocket<TlsStream<TcpStream>>, String), String> {
|
|
||||||
panic::catch_unwind(|| {
|
|
||||||
let (v, auth) = init_server(cookies, room_id);
|
|
||||||
let (ws, _res) = connect_result(v["host_list"].clone())?;
|
|
||||||
let auth_msg = serde_json::to_string(&auth)
|
|
||||||
.map_err(|e| format!("serialize auth payload failed: {}", e))?;
|
|
||||||
Ok((ws, auth_msg))
|
|
||||||
})
|
|
||||||
.map_err(|_| format!("websocket setup panicked for room {}", room_id))?
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_auth_internal(&mut self) -> Result<(), String> {
|
|
||||||
match self.ws.send(Message::Binary(make_packet(
|
|
||||||
self.auth_msg.as_str(),
|
|
||||||
Operation::AUTH,
|
|
||||||
))) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("send auth error: {}", e);
|
|
||||||
log::warn!("{}", msg);
|
|
||||||
self.reconnect()?;
|
|
||||||
self.ws
|
|
||||||
.send(Message::Binary(make_packet(
|
|
||||||
self.auth_msg.as_str(),
|
|
||||||
Operation::AUTH,
|
|
||||||
)))
|
|
||||||
.map_err(|retry_err| format!("{}; resend auth failed: {}", msg, retry_err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_heart_beat_internal(&mut self) -> Result<(), String> {
|
|
||||||
match self
|
|
||||||
.ws
|
|
||||||
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
|
|
||||||
{
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("send heartbeat error: {}", e);
|
|
||||||
log::warn!("{}", msg);
|
|
||||||
self.reconnect()?;
|
|
||||||
self.ws
|
|
||||||
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
|
|
||||||
.map_err(|retry_err| format!("{}; resend heartbeat failed: {}", msg, retry_err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconnect(&mut self) -> Result<(), String> {
|
|
||||||
let backoff = [1_u64, 2, 5];
|
|
||||||
let mut last_err = None;
|
|
||||||
|
|
||||||
for (idx, delay_secs) in backoff.iter().enumerate() {
|
|
||||||
if idx > 0 {
|
|
||||||
thread::sleep(Duration::from_secs(*delay_secs));
|
|
||||||
}
|
|
||||||
|
|
||||||
match Self::connect_with_auth(&self.cookies, &self.room_id) {
|
|
||||||
Ok((ws, auth_msg)) => {
|
|
||||||
self.ws = ws;
|
|
||||||
self.auth_msg = auth_msg;
|
|
||||||
let auth_resend = self.ws.send(Message::Binary(make_packet(
|
|
||||||
self.auth_msg.as_str(),
|
|
||||||
Operation::AUTH,
|
|
||||||
)));
|
|
||||||
let heartbeat_resend = self
|
|
||||||
.ws
|
|
||||||
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)));
|
|
||||||
|
|
||||||
match (auth_resend, heartbeat_resend) {
|
|
||||||
(Ok(()), Ok(())) => {
|
|
||||||
log::info!(
|
|
||||||
"websocket reconnected on attempt {} for room {}",
|
|
||||||
idx + 1,
|
|
||||||
self.room_id
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
(auth_result, heartbeat_result) => {
|
|
||||||
let auth_err = auth_result.err().map(|e| e.to_string());
|
|
||||||
let heartbeat_err = heartbeat_result.err().map(|e| e.to_string());
|
|
||||||
let reconnect_err = match (auth_err, heartbeat_err) {
|
|
||||||
(Some(auth_err), Some(heartbeat_err)) => format!(
|
|
||||||
"reconnected socket but auth resend failed: {}; heartbeat resend failed: {}",
|
|
||||||
auth_err, heartbeat_err
|
|
||||||
),
|
|
||||||
(Some(auth_err), None) => {
|
|
||||||
format!("reconnected socket but auth resend failed: {}", auth_err)
|
|
||||||
}
|
|
||||||
(None, Some(heartbeat_err)) => format!(
|
|
||||||
"reconnected socket but heartbeat resend failed: {}",
|
|
||||||
heartbeat_err
|
|
||||||
),
|
|
||||||
(None, None) => unreachable!(),
|
|
||||||
};
|
|
||||||
log::warn!(
|
|
||||||
"websocket reconnect attempt {} did not fully recover for room {}: {}",
|
|
||||||
idx + 1,
|
|
||||||
self.room_id,
|
|
||||||
reconnect_err
|
|
||||||
);
|
|
||||||
last_err = Some(reconnect_err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"websocket reconnect attempt {} failed for room {}: {}",
|
|
||||||
idx + 1,
|
|
||||||
self.room_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
last_err = Some(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(last_err.unwrap_or_else(|| "unknown reconnect failure".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn gen_damu_list(list: &Value) -> Vec<DanmuServer> {
|
|
||||||
let server_list = list.as_array().unwrap();
|
|
||||||
let mut res: Vec<DanmuServer> = Vec::new();
|
|
||||||
if server_list.len() == 0 {
|
|
||||||
let d = DanmuServer::default();
|
|
||||||
res.push(d);
|
|
||||||
}
|
|
||||||
for s in server_list {
|
|
||||||
res.push(DanmuServer {
|
|
||||||
host: s["host"].as_str().unwrap().to_string(),
|
|
||||||
port: s["port"].as_u64().unwrap() as i32,
|
|
||||||
wss_port: s["wss_port"].as_u64().unwrap() as i32,
|
|
||||||
ws_port: s["ws_port"].as_u64().unwrap() as i32,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_server(vd: Vec<DanmuServer>) -> (String, String, String) {
|
|
||||||
let (host, wss_port) = (vd.get(0).unwrap().host.clone(), vd.get(0).unwrap().wss_port);
|
|
||||||
(
|
|
||||||
host.clone(),
|
|
||||||
format!("{}:{}", host.clone(), wss_port),
|
|
||||||
format!("wss://{}:{}/sub", host, wss_port),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_server(cookies: &str, room_id: &str) -> (Value, AuthMessage) {
|
|
||||||
let mut auth_map = HashMap::new();
|
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::COOKIE,
|
|
||||||
reqwest::header::HeaderValue::from_str(cookies).unwrap(),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::USER_AGENT,
|
|
||||||
reqwest::header::HeaderValue::from_static(crate::auth::USER_AGENT),
|
|
||||||
);
|
|
||||||
log::debug!("headers: {:?}", headers);
|
|
||||||
|
|
||||||
// Extract SESSDATA from cookies for authentication
|
|
||||||
let sessdata = cookies
|
|
||||||
.split(';')
|
|
||||||
.find_map(|kv| {
|
|
||||||
let mut parts = kv.trim().splitn(2, '=');
|
|
||||||
let key = parts.next()?.trim();
|
|
||||||
let value = parts.next()?.trim();
|
|
||||||
if key == "SESSDATA" {
|
|
||||||
Some(value.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "".to_string());
|
|
||||||
|
|
||||||
if !sessdata.is_empty() {
|
|
||||||
let (_, body1) = init_uid(headers.clone());
|
|
||||||
let body1_v: Value = serde_json::from_str(body1.as_str()).unwrap();
|
|
||||||
|
|
||||||
// Check if the authentication was successful
|
|
||||||
if let Some(mid) = body1_v["data"]["mid"].as_i64() {
|
|
||||||
auth_map.insert("uid".to_string(), mid.to_string());
|
|
||||||
log::info!("Successfully authenticated with uid: {}", mid);
|
|
||||||
} else {
|
|
||||||
log::warn!("Authentication failed - SESSDATA may be invalid or expired");
|
|
||||||
log::debug!("Auth response: {}", body1);
|
|
||||||
auth_map.insert("uid".to_string(), "0".to_string());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
auth_map.insert("uid".to_string(), "0".to_string());
|
|
||||||
}
|
|
||||||
// here the live room id is easily obtained, so we not get it by url.
|
|
||||||
auth_map.insert("room_id".to_string(), room_id.to_string());
|
|
||||||
|
|
||||||
let room_id_num = room_id.parse::<u64>().expect("room_id must be a valid u64");
|
|
||||||
let (_, body4) = init_host_server(headers.clone(), room_id_num);
|
|
||||||
let body4_res: Value = serde_json::from_str(body4.as_str()).unwrap();
|
|
||||||
let server_info = &body4_res["data"];
|
|
||||||
let token = &body4_res["data"]["token"].as_str().unwrap();
|
|
||||||
auth_map.insert("token".to_string(), token.to_string());
|
|
||||||
|
|
||||||
let auth_msg = AuthMessage::from(&auth_map);
|
|
||||||
(server_info.clone(), auth_msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connect(v: Value) -> (WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>) {
|
|
||||||
connect_result(v).expect("Can't connect")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connect_result(
|
|
||||||
v: Value,
|
|
||||||
) -> Result<(WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>), String> {
|
|
||||||
let danmu_server = gen_damu_list(&v);
|
|
||||||
let (host, url, ws_url) = find_server(danmu_server);
|
|
||||||
let connector: native_tls::TlsConnector =
|
|
||||||
native_tls::TlsConnector::new().map_err(|e| format!("tls init failed: {}", e))?;
|
|
||||||
let stream: TcpStream = TcpStream::connect(url.as_str())
|
|
||||||
.map_err(|e| format!("tcp connect to {} failed: {}", url, e))?;
|
|
||||||
let stream: native_tls::TlsStream<TcpStream> = connector
|
|
||||||
.connect(host.as_str(), stream)
|
|
||||||
.map_err(|e| format!("tls connect to {} failed: {}", host, e))?;
|
|
||||||
let parsed_url =
|
|
||||||
Url::parse(ws_url.as_str()).map_err(|e| format!("invalid websocket url: {}", e))?;
|
|
||||||
client(parsed_url, stream).map_err(|e| format!("websocket handshake failed: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Operation {
|
|
||||||
AUTH,
|
|
||||||
HEARTBEAT,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn make_packet(body: &str, ops: Operation) -> Vec<u8> {
|
|
||||||
let json: Value = serde_json::from_str(body).unwrap();
|
|
||||||
let temp = json.to_string();
|
|
||||||
let body_content: &[u8] = temp.as_bytes();
|
|
||||||
let pack_len: [u8; 4] = ((16 + body.len()) as u32).to_be_bytes();
|
|
||||||
let raw_header_size: [u8; 2] = (16 as u16).to_be_bytes();
|
|
||||||
let ver: [u8; 2] = (1 as u16).to_be_bytes();
|
|
||||||
let operation: [u8; 4] = match ops {
|
|
||||||
Operation::AUTH => (7 as u32).to_be_bytes(),
|
|
||||||
Operation::HEARTBEAT => (2 as u32).to_be_bytes(),
|
|
||||||
};
|
|
||||||
let seq_id: [u8; 4] = (1 as u32).to_be_bytes();
|
|
||||||
let mut res = pack_len.to_vec();
|
|
||||||
res.append(&mut raw_header_size.to_vec());
|
|
||||||
res.append(&mut ver.to_vec());
|
|
||||||
res.append(&mut operation.to_vec());
|
|
||||||
res.append(&mut seq_id.to_vec());
|
|
||||||
res.append(&mut body_content.to_vec());
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_msg_header(v_s: &[u8]) -> MsgHead {
|
|
||||||
let mut pack_len: [u8; 4] = [0; 4];
|
|
||||||
let mut raw_header_size: [u8; 2] = [0; 2];
|
|
||||||
let mut ver: [u8; 2] = [0; 2];
|
|
||||||
let mut operation: [u8; 4] = [0; 4];
|
|
||||||
let mut seq_id: [u8; 4] = [0; 4];
|
|
||||||
for (i, v) in v_s.iter().enumerate() {
|
|
||||||
if i < 4 {
|
|
||||||
pack_len[i] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 6 {
|
|
||||||
raw_header_size[i - 4] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 8 {
|
|
||||||
ver[i - 6] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 12 {
|
|
||||||
operation[i - 8] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 16 {
|
|
||||||
seq_id[i - 12] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MsgHead {
|
|
||||||
pack_len: u32::from_be_bytes(pack_len),
|
|
||||||
raw_header_size: u16::from_be_bytes(raw_header_size),
|
|
||||||
ver: u16::from_be_bytes(ver),
|
|
||||||
operation: u32::from_be_bytes(operation),
|
|
||||||
seq_id: u32::from_be_bytes(seq_id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decompress(body: &[u8]) -> std::io::Result<Vec<u8>> {
|
|
||||||
use brotlic::DecompressorReader;
|
|
||||||
use std::io::Read;
|
|
||||||
let mut decompressed_reader: DecompressorReader<&[u8]> = DecompressorReader::new(body);
|
|
||||||
let mut decoded_input = Vec::new();
|
|
||||||
let _ = decompressed_reader.read_to_end(&mut decoded_input)?;
|
|
||||||
Ok(decoded_input)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// here we detail [info format is online](https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/live/message_stream.md)
|
|
||||||
/// .
|
|
||||||
pub fn handle(json: Value) -> Option<BiliMessage> {
|
|
||||||
let category = json["cmd"].as_str().unwrap_or("");
|
|
||||||
match category {
|
|
||||||
// Preserve all fields for callers that need a stable UID, gift price
|
|
||||||
// and upstream event identifier. The previous typed variants discard
|
|
||||||
// those values, which makes reliable accounting impossible.
|
|
||||||
"DANMU_MSG" | "SEND_GIFT" => Some(BiliMessage::Raw(json)),
|
|
||||||
"ONLINE_RANK_COUNT" => Some(BiliMessage::OnlineRankCount {
|
|
||||||
count: json["data"]["count"].as_u64().unwrap_or(0),
|
|
||||||
online_count: json["data"]["online_count"].as_u64().unwrap_or(0),
|
|
||||||
}),
|
|
||||||
// Add more cases for other types as needed
|
|
||||||
_ => Some(BiliMessage::Raw(json)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced init_server that can automatically detect cookies from browser
|
|
||||||
pub fn init_server_auto(
|
|
||||||
provided_cookies: Option<&str>,
|
|
||||||
room_id: &str,
|
|
||||||
) -> Result<(Value, AuthMessage), String> {
|
|
||||||
// Try to get cookies from provided value or browser cookies
|
|
||||||
let cookies = get_cookies_or_browser(provided_cookies)
|
|
||||||
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Using cookies for authentication: {}...",
|
|
||||||
&cookies[..10.min(cookies.len())]
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = init_server(&cookies, room_id);
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use futures_channel::mpsc::channel;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_bili_live_client_connect() {
|
|
||||||
// Always enable debug log output for test
|
|
||||||
let _ = env_logger::builder()
|
|
||||||
.is_test(true)
|
|
||||||
.filter_level(log::LevelFilter::Debug)
|
|
||||||
.try_init();
|
|
||||||
// Get cookies from environment variable for real test
|
|
||||||
let cookies =
|
|
||||||
std::env::var("Cookie").unwrap_or_else(|_| "SESSDATA=dummy_sessdata".to_string());
|
|
||||||
let room_id = "24779526";
|
|
||||||
let (tx, _rx) = channel(10);
|
|
||||||
let _client = BiliLiveClient::new(&cookies, room_id, tx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-276
@@ -1,276 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(default)]
|
|
||||||
pub connection: Option<ConnectionConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tts: Option<TtsConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_reply: Option<AutoReplyConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub debug: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct ConnectionConfig {
|
|
||||||
pub cookies: Option<String>,
|
|
||||||
pub room_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct TtsConfig {
|
|
||||||
pub server: Option<String>,
|
|
||||||
pub voice: Option<String>,
|
|
||||||
pub backend: Option<String>,
|
|
||||||
pub quality: Option<String>,
|
|
||||||
pub format: Option<String>,
|
|
||||||
pub sample_rate: Option<u32>,
|
|
||||||
pub volume: Option<f32>,
|
|
||||||
pub command: Option<String>,
|
|
||||||
pub args: Option<String>,
|
|
||||||
/// Alibaba DashScope TTS configuration
|
|
||||||
pub ali_api_key: Option<String>,
|
|
||||||
pub ali_model: Option<String>,
|
|
||||||
pub ali_voice: Option<String>,
|
|
||||||
pub ali_language_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TriggerConfig {
|
|
||||||
pub keywords: Vec<String>,
|
|
||||||
pub response: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AutoReplyConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(default = "default_cooldown")]
|
|
||||||
pub cooldown_seconds: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub triggers: Vec<TriggerConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AutoReplyConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: false,
|
|
||||||
cooldown_seconds: default_cooldown(),
|
|
||||||
triggers: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_cooldown() -> u64 {
|
|
||||||
5
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoReplyConfig {
|
|
||||||
/// Convert to blivedm::plugins::auto_reply::AutoReplyConfig
|
|
||||||
pub fn to_plugin_config(&self) -> blivedm::plugins::auto_reply::AutoReplyConfig {
|
|
||||||
blivedm::plugins::auto_reply::AutoReplyConfig {
|
|
||||||
enabled: self.enabled,
|
|
||||||
cooldown_seconds: self.cooldown_seconds,
|
|
||||||
triggers: self
|
|
||||||
.triggers
|
|
||||||
.iter()
|
|
||||||
.map(|t| t.to_plugin_trigger())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TriggerConfig {
|
|
||||||
/// Convert to blivedm::plugins::auto_reply::TriggerConfig
|
|
||||||
pub fn to_plugin_trigger(&self) -> blivedm::plugins::auto_reply::TriggerConfig {
|
|
||||||
blivedm::plugins::auto_reply::TriggerConfig {
|
|
||||||
keywords: self.keywords.clone(),
|
|
||||||
response: self.response.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Load configuration from file with fallback locations
|
|
||||||
pub fn load_from_file(config_path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
let config_file = if let Some(path) = config_path {
|
|
||||||
// Use provided path
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
// Try current directory first
|
|
||||||
let current_dir_config = PathBuf::from("config.toml");
|
|
||||||
if current_dir_config.exists() {
|
|
||||||
current_dir_config
|
|
||||||
} else {
|
|
||||||
// Try XDG config directory
|
|
||||||
Self::get_default_config_path()?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !config_file.exists() {
|
|
||||||
log::debug!("Config file {:?} not found", config_file);
|
|
||||||
|
|
||||||
// Create config file if it doesn't exist and we're using default locations
|
|
||||||
if config_path.is_none() {
|
|
||||||
match Self::create_example_config(&config_file) {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("Created configuration file: {:?}", config_file);
|
|
||||||
println!("You can customize it as needed.");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to create config file: {}", e);
|
|
||||||
return Ok(Config::default());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Ok(Config::default());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Loading configuration from {:?}", config_file);
|
|
||||||
let content = fs::read_to_string(&config_file)
|
|
||||||
.map_err(|e| format!("Failed to read config file {:?}: {}", config_file, e))?;
|
|
||||||
|
|
||||||
let config: Config = toml::from_str(&content)
|
|
||||||
.map_err(|e| format!("Failed to parse config file {:?}: {}", config_file, e))?;
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the default configuration file path (~/.config/blivedm_rs/config.toml)
|
|
||||||
fn get_default_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
||||||
let config_dir = dirs::config_dir()
|
|
||||||
.ok_or("Unable to determine config directory")?
|
|
||||||
.join("blivedm_rs");
|
|
||||||
|
|
||||||
// Create config directory if it doesn't exist
|
|
||||||
if !config_dir.exists() {
|
|
||||||
fs::create_dir_all(&config_dir).map_err(|e| {
|
|
||||||
format!("Failed to create config directory {:?}: {}", config_dir, e)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config_dir.join("config.toml"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an example configuration file
|
|
||||||
pub fn create_example_config(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let example_config = Config {
|
|
||||||
connection: None,
|
|
||||||
tts: Some(TtsConfig {
|
|
||||||
server: Some("http://localhost:8000".to_string()),
|
|
||||||
voice: None,
|
|
||||||
backend: None,
|
|
||||||
quality: None,
|
|
||||||
format: None,
|
|
||||||
sample_rate: None,
|
|
||||||
volume: None,
|
|
||||||
command: None,
|
|
||||||
args: None,
|
|
||||||
ali_api_key: None,
|
|
||||||
ali_model: None,
|
|
||||||
ali_voice: None,
|
|
||||||
ali_language_type: None,
|
|
||||||
}),
|
|
||||||
auto_reply: Some(AutoReplyConfig {
|
|
||||||
enabled: false,
|
|
||||||
cooldown_seconds: 5,
|
|
||||||
triggers: vec![
|
|
||||||
TriggerConfig {
|
|
||||||
keywords: vec!["你好".to_string(), "hello".to_string()],
|
|
||||||
response: "欢迎来到直播间!".to_string(),
|
|
||||||
},
|
|
||||||
TriggerConfig {
|
|
||||||
keywords: vec!["谢谢".to_string(), "thanks".to_string()],
|
|
||||||
response: "不客气~".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
debug: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let toml_string = toml::to_string_pretty(&example_config)
|
|
||||||
.map_err(|e| format!("Failed to serialize example config: {}", e))?;
|
|
||||||
|
|
||||||
fs::write(path, toml_string)
|
|
||||||
.map_err(|e| format!("Failed to write example config to {:?}: {}", path, e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the effective configuration (for debugging)
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn print_effective_config(
|
|
||||||
cookies: &Option<String>,
|
|
||||||
room_id: &str,
|
|
||||||
tts_server: &Option<String>,
|
|
||||||
tts_voice: &Option<String>,
|
|
||||||
tts_backend: &Option<String>,
|
|
||||||
tts_quality: &Option<String>,
|
|
||||||
tts_format: &Option<String>,
|
|
||||||
tts_sample_rate: &Option<u32>,
|
|
||||||
tts_volume: &Option<f32>,
|
|
||||||
tts_command: &Option<String>,
|
|
||||||
tts_args: &Option<String>,
|
|
||||||
ali_api_key: &Option<String>,
|
|
||||||
ali_model: &Option<String>,
|
|
||||||
ali_voice: &Option<String>,
|
|
||||||
ali_language_type: &Option<String>,
|
|
||||||
auto_reply: &Option<AutoReplyConfig>,
|
|
||||||
debug: bool,
|
|
||||||
) {
|
|
||||||
println!("=== Effective Configuration ===");
|
|
||||||
println!("Connection:");
|
|
||||||
println!(" room_id: {}", room_id);
|
|
||||||
if let Some(cookies_val) = cookies {
|
|
||||||
println!(
|
|
||||||
" cookies: {}...",
|
|
||||||
&cookies_val.chars().take(20).collect::<String>()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(" cookies: None (will auto-detect)");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("TTS (REST API):");
|
|
||||||
println!(" server: {:?}", tts_server);
|
|
||||||
println!(" voice: {:?}", tts_voice);
|
|
||||||
println!(" backend: {:?}", tts_backend);
|
|
||||||
println!(" quality: {:?}", tts_quality);
|
|
||||||
println!(" format: {:?}", tts_format);
|
|
||||||
println!(" sample_rate: {:?}", tts_sample_rate);
|
|
||||||
println!(" volume: {:?}", tts_volume);
|
|
||||||
println!(" command: {:?}", tts_command);
|
|
||||||
println!(" args: {:?}", tts_args);
|
|
||||||
|
|
||||||
println!("TTS (Alibaba DashScope):");
|
|
||||||
if let Some(key) = ali_api_key {
|
|
||||||
println!(
|
|
||||||
" api_key: {}...",
|
|
||||||
&key.chars().take(10).collect::<String>()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(" api_key: None");
|
|
||||||
}
|
|
||||||
println!(" model: {:?}", ali_model);
|
|
||||||
println!(" voice: {:?}", ali_voice);
|
|
||||||
println!(" language_type: {:?}", ali_language_type);
|
|
||||||
|
|
||||||
println!("Auto Reply:");
|
|
||||||
if let Some(auto_reply_config) = auto_reply {
|
|
||||||
println!(" enabled: {}", auto_reply_config.enabled);
|
|
||||||
println!(" cooldown_seconds: {}", auto_reply_config.cooldown_seconds);
|
|
||||||
println!(
|
|
||||||
" triggers: {} configured",
|
|
||||||
auto_reply_config.triggers.len()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(" enabled: false (not configured)");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Debug: {}", debug);
|
|
||||||
println!("===============================");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-17
@@ -1,17 +0,0 @@
|
|||||||
// src/lib.rs
|
|
||||||
//! Bilibili live room danmaku WebSocket client library with TTS and plugin support
|
|
||||||
|
|
||||||
pub mod client;
|
|
||||||
pub mod plugins;
|
|
||||||
pub mod tui;
|
|
||||||
|
|
||||||
// Re-export commonly used items from client
|
|
||||||
pub use client::{auth, get_cookies_or_browser, models, scheduler, websocket};
|
|
||||||
#[cfg(feature = "browser_cookies")]
|
|
||||||
pub use client::browser_cookies;
|
|
||||||
|
|
||||||
// Re-export plugin modules and helpers
|
|
||||||
pub use plugins::{
|
|
||||||
auto_reply, auto_reply_handler, terminal_display, terminal_display_handler, tts, tts_handler,
|
|
||||||
tts_handler_command, tts_handler_default,
|
|
||||||
};
|
|
||||||
Vendored
-481
@@ -1,481 +0,0 @@
|
|||||||
// src/main.rs
|
|
||||||
// Standalone binary to test integration of the terminal display plugin with the BiliLiveClient
|
|
||||||
|
|
||||||
mod config;
|
|
||||||
|
|
||||||
use blivedm::client::get_cookies_or_browser;
|
|
||||||
use blivedm::client::scheduler::{EventContext, Scheduler};
|
|
||||||
use blivedm::client::websocket::BiliLiveClient;
|
|
||||||
use blivedm::plugins::terminal_display::TerminalDisplayHandler;
|
|
||||||
use blivedm::plugins::tts::TtsHandler;
|
|
||||||
use blivedm::tui::{TuiApp, TuiLogger, run_tui};
|
|
||||||
use clap::{CommandFactory, Parser};
|
|
||||||
use clap_complete::{Shell, generate};
|
|
||||||
use config::Config;
|
|
||||||
use futures::channel::mpsc;
|
|
||||||
use futures::stream::StreamExt;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::env;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::AtomicU64;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(author, version, about, long_about = None)]
|
|
||||||
struct Args {
|
|
||||||
/// Path to configuration file
|
|
||||||
#[arg(long, value_name = "PATH")]
|
|
||||||
config: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Print effective configuration and exit
|
|
||||||
#[arg(long)]
|
|
||||||
print_config: bool,
|
|
||||||
/// Cookies for Bilibili authentication (optional - will auto-detect from browser if not provided)
|
|
||||||
#[arg(long, value_name = "COOKIES")]
|
|
||||||
cookies: Option<String>,
|
|
||||||
|
|
||||||
/// Room ID to connect to
|
|
||||||
#[arg(long, value_name = "ROOM_ID")]
|
|
||||||
room_id: Option<String>,
|
|
||||||
|
|
||||||
/// TTS REST API server URL
|
|
||||||
#[arg(long, value_name = "URL")]
|
|
||||||
tts_server: Option<String>,
|
|
||||||
|
|
||||||
/// TTS voice ID (e.g., "zh-CN-XiaoxiaoNeural")
|
|
||||||
#[arg(long, value_name = "VOICE")]
|
|
||||||
tts_voice: Option<String>,
|
|
||||||
|
|
||||||
/// TTS backend ("edge", "xtts", "piper")
|
|
||||||
#[arg(long, value_name = "BACKEND")]
|
|
||||||
tts_backend: Option<String>,
|
|
||||||
|
|
||||||
/// TTS audio quality ("low", "medium", "high")
|
|
||||||
#[arg(long, value_name = "QUALITY")]
|
|
||||||
tts_quality: Option<String>,
|
|
||||||
|
|
||||||
/// TTS audio format (e.g., "wav")
|
|
||||||
#[arg(long, value_name = "FORMAT")]
|
|
||||||
tts_format: Option<String>,
|
|
||||||
|
|
||||||
/// TTS sample rate (e.g., 22050, 44100)
|
|
||||||
#[arg(long, value_name = "RATE")]
|
|
||||||
tts_sample_rate: Option<u32>,
|
|
||||||
|
|
||||||
/// TTS audio volume (0.0 to 1.0)
|
|
||||||
#[arg(long, value_name = "VOLUME")]
|
|
||||||
tts_volume: Option<f32>,
|
|
||||||
|
|
||||||
/// Local TTS command (e.g., "say", "espeak-ng")
|
|
||||||
#[arg(long, value_name = "COMMAND")]
|
|
||||||
tts_command: Option<String>,
|
|
||||||
|
|
||||||
/// Comma-separated arguments for TTS command
|
|
||||||
#[arg(long, value_name = "ARGS", allow_hyphen_values = true)]
|
|
||||||
tts_args: Option<String>,
|
|
||||||
|
|
||||||
/// Alibaba DashScope API key for ali-tts (can also use DASHSCOPE_API_KEY env)
|
|
||||||
#[arg(long, value_name = "KEY")]
|
|
||||||
ali_api_key: Option<String>,
|
|
||||||
|
|
||||||
/// Alibaba TTS model (e.g., "qwen3-tts-flash")
|
|
||||||
#[arg(long, value_name = "MODEL")]
|
|
||||||
ali_model: Option<String>,
|
|
||||||
|
|
||||||
/// Alibaba TTS voice (e.g., "Cherry", "Chelsie")
|
|
||||||
#[arg(long, value_name = "VOICE")]
|
|
||||||
ali_voice: Option<String>,
|
|
||||||
|
|
||||||
/// Alibaba TTS language type (e.g., "Chinese", "English")
|
|
||||||
#[arg(long, value_name = "LANG")]
|
|
||||||
ali_language_type: Option<String>,
|
|
||||||
|
|
||||||
/// Enable debug logging
|
|
||||||
#[arg(long)]
|
|
||||||
debug: bool,
|
|
||||||
|
|
||||||
/// Enable auto reply plugin
|
|
||||||
#[arg(long)]
|
|
||||||
auto_reply: bool,
|
|
||||||
|
|
||||||
/// Generate shell completion script (bash, zsh, fish, powershell, elvish)
|
|
||||||
#[arg(long, value_name = "SHELL")]
|
|
||||||
generate_completion: Option<Shell>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args = Args::parse();
|
|
||||||
|
|
||||||
// Handle shell completion generation early (before any other processing)
|
|
||||||
if let Some(shell) = args.generate_completion {
|
|
||||||
let mut cmd = Args::command();
|
|
||||||
generate(shell, &mut cmd, "blivedm", &mut std::io::stdout());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load configuration from file first
|
|
||||||
let config = match Config::load_from_file(args.config.as_deref()) {
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error loading configuration: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize logging with precedence: CLI args > env vars > config file
|
|
||||||
let debug_enabled =
|
|
||||||
args.debug || env::var("DEBUG").unwrap_or_default() == "1" || config.debug.unwrap_or(false);
|
|
||||||
|
|
||||||
// Load cookies and room_id with precedence: CLI args > env vars > config file > defaults
|
|
||||||
let cookies = args
|
|
||||||
.cookies
|
|
||||||
.or_else(|| {
|
|
||||||
env::var("Cookie")
|
|
||||||
.ok()
|
|
||||||
.filter(|s| !s.is_empty() && s != "SESSDATA=dummy_sessdata")
|
|
||||||
})
|
|
||||||
.or_else(|| config.connection.as_ref().and_then(|c| c.cookies.clone()));
|
|
||||||
|
|
||||||
// If no manual cookies provided, try browser auto-detection
|
|
||||||
let cookies = if cookies.is_none() {
|
|
||||||
if debug_enabled {
|
|
||||||
log::info!("No manual cookies provided, attempting browser auto-detection...");
|
|
||||||
}
|
|
||||||
get_cookies_or_browser(None)
|
|
||||||
} else {
|
|
||||||
if debug_enabled {
|
|
||||||
log::info!("Using manually provided cookies");
|
|
||||||
}
|
|
||||||
cookies
|
|
||||||
};
|
|
||||||
|
|
||||||
let room_id = args
|
|
||||||
.room_id
|
|
||||||
.or_else(|| env::var("ROOM_ID").ok())
|
|
||||||
.or_else(|| config.connection.as_ref().and_then(|c| c.room_id.clone()))
|
|
||||||
.unwrap_or_else(|| "24779526".to_string());
|
|
||||||
|
|
||||||
// Configure TTS with precedence: CLI args > config file
|
|
||||||
let tts_server = args
|
|
||||||
.tts_server
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.server.clone()));
|
|
||||||
let tts_voice = args
|
|
||||||
.tts_voice
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.voice.clone()));
|
|
||||||
let tts_backend = args
|
|
||||||
.tts_backend
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.backend.clone()));
|
|
||||||
let tts_quality = args
|
|
||||||
.tts_quality
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.quality.clone()));
|
|
||||||
let tts_format = args
|
|
||||||
.tts_format
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.format.clone()));
|
|
||||||
let tts_sample_rate = args
|
|
||||||
.tts_sample_rate
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.sample_rate));
|
|
||||||
let tts_volume = args
|
|
||||||
.tts_volume
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.volume));
|
|
||||||
let tts_command = args
|
|
||||||
.tts_command
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.command.clone()));
|
|
||||||
let tts_args = args
|
|
||||||
.tts_args
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.args.clone()));
|
|
||||||
|
|
||||||
// Configure Alibaba TTS with precedence: CLI args > env vars > config file
|
|
||||||
let ali_api_key = args
|
|
||||||
.ali_api_key
|
|
||||||
.or_else(|| env::var("DASHSCOPE_API_KEY").ok())
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_api_key.clone()));
|
|
||||||
let ali_model = args
|
|
||||||
.ali_model
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_model.clone()));
|
|
||||||
let ali_voice = args
|
|
||||||
.ali_voice
|
|
||||||
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_voice.clone()));
|
|
||||||
let ali_language_type = args.ali_language_type.or_else(|| {
|
|
||||||
config
|
|
||||||
.tts
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|t| t.ali_language_type.clone())
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure auto reply with precedence: CLI args > config file
|
|
||||||
let auto_reply_config = if let Some(config_auto_reply) = &config.auto_reply {
|
|
||||||
// Use config file settings, but allow CLI flag to override enabled
|
|
||||||
let mut plugin_config = config_auto_reply.to_plugin_config();
|
|
||||||
if args.auto_reply {
|
|
||||||
plugin_config.enabled = true;
|
|
||||||
}
|
|
||||||
plugin_config
|
|
||||||
} else {
|
|
||||||
// No config file section, use defaults with CLI flag
|
|
||||||
let mut default_config = blivedm::plugins::auto_reply::AutoReplyConfig::default();
|
|
||||||
default_config.enabled = args.auto_reply;
|
|
||||||
default_config
|
|
||||||
};
|
|
||||||
|
|
||||||
// If user wants to see config, print and exit
|
|
||||||
if args.print_config {
|
|
||||||
// Create a temporary config struct for display that reflects the effective settings
|
|
||||||
let effective_auto_reply = if auto_reply_config.enabled {
|
|
||||||
Some(config::AutoReplyConfig {
|
|
||||||
enabled: auto_reply_config.enabled,
|
|
||||||
cooldown_seconds: auto_reply_config.cooldown_seconds,
|
|
||||||
triggers: auto_reply_config
|
|
||||||
.triggers
|
|
||||||
.iter()
|
|
||||||
.map(|t| config::TriggerConfig {
|
|
||||||
keywords: t.keywords.clone(),
|
|
||||||
response: t.response.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Config::print_effective_config(
|
|
||||||
&cookies,
|
|
||||||
&room_id,
|
|
||||||
&tts_server,
|
|
||||||
&tts_voice,
|
|
||||||
&tts_backend,
|
|
||||||
&tts_quality,
|
|
||||||
&tts_format,
|
|
||||||
&tts_sample_rate,
|
|
||||||
&tts_volume,
|
|
||||||
&tts_command,
|
|
||||||
&tts_args,
|
|
||||||
&ali_api_key,
|
|
||||||
&ali_model,
|
|
||||||
&ali_voice,
|
|
||||||
&ali_language_type,
|
|
||||||
&effective_auto_reply,
|
|
||||||
debug_enabled,
|
|
||||||
);
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize TuiLogger to capture logs into a shared buffer for the TUI logs panel.
|
|
||||||
// When debug is enabled, capture Debug level; otherwise capture Info level.
|
|
||||||
let log_level = if debug_enabled {
|
|
||||||
log::LevelFilter::Debug
|
|
||||||
} else {
|
|
||||||
log::LevelFilter::Info
|
|
||||||
};
|
|
||||||
let log_buffer = TuiLogger::init(log_level);
|
|
||||||
|
|
||||||
// Create client with automatic browser cookie detection
|
|
||||||
let (tx, mut rx) = mpsc::channel(64);
|
|
||||||
let mut client = match BiliLiveClient::new_auto(cookies.as_deref(), &room_id, tx) {
|
|
||||||
Ok(client) => {
|
|
||||||
log::info!("Successfully created client with automatic cookie detection");
|
|
||||||
client
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to create client: {}", e);
|
|
||||||
eprintln!(
|
|
||||||
"Please ensure you are logged into bilibili.com in your browser, or provide cookies manually."
|
|
||||||
);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
client.send_auth();
|
|
||||||
client.send_heart_beat();
|
|
||||||
let shared_client: Arc<Mutex<BiliLiveClient>> = Arc::new(Mutex::new(client));
|
|
||||||
let heart_beats: Arc<Mutex<BiliLiveClient>> = Arc::clone(&shared_client);
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
match heart_beats.lock() {
|
|
||||||
Ok(mut heart_beats_c) => {
|
|
||||||
heart_beats_c.send_heart_beat();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error acquiring lock on stream: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::new(20, 0));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let rec_msg: Arc<Mutex<BiliLiveClient>> = Arc::clone(&shared_client);
|
|
||||||
thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
match rec_msg.lock() {
|
|
||||||
Ok(mut rec_c) => {
|
|
||||||
if let Err(e) = rec_c.receive() {
|
|
||||||
log::error!("{}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error acquiring lock on stream: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(10)); // instead of 10 microseconds
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up the scheduler with context and add the terminal display handler
|
|
||||||
if debug_enabled {
|
|
||||||
match &cookies {
|
|
||||||
Some(cookie_str) => {
|
|
||||||
log::debug!(
|
|
||||||
"Cookies found and passed to context: {}...",
|
|
||||||
&cookie_str.chars().take(50).collect::<String>()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
log::warn!(
|
|
||||||
"No cookies found for EventContext - auto-reply will not be able to send messages"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create shared message buffer for TUI
|
|
||||||
let message_buffer: Arc<Mutex<VecDeque<String>>> = Arc::new(Mutex::new(VecDeque::new()));
|
|
||||||
|
|
||||||
// Create shared online count for TUI title display
|
|
||||||
let online_count: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
|
|
||||||
|
|
||||||
let context = EventContext::new(cookies.clone(), room_id.parse::<u64>().unwrap_or(0));
|
|
||||||
let mut scheduler = Scheduler::new(context);
|
|
||||||
let terminal_handler = Arc::new(TerminalDisplayHandler::with_online_count(
|
|
||||||
Arc::clone(&message_buffer),
|
|
||||||
Arc::clone(&online_count),
|
|
||||||
));
|
|
||||||
scheduler.add_sequential_handler(terminal_handler);
|
|
||||||
if let Some(server_url) = tts_server {
|
|
||||||
// REST API TTS configuration
|
|
||||||
let tts_handler = Arc::new(TtsHandler::new_rest_api_with_volume(
|
|
||||||
server_url,
|
|
||||||
tts_voice,
|
|
||||||
tts_backend,
|
|
||||||
tts_quality,
|
|
||||||
tts_format,
|
|
||||||
tts_sample_rate,
|
|
||||||
tts_volume,
|
|
||||||
));
|
|
||||||
scheduler.add_sequential_handler(tts_handler);
|
|
||||||
println!("TTS configured with REST API server");
|
|
||||||
} else if let Some(api_key) = ali_api_key {
|
|
||||||
// Alibaba DashScope TTS configuration
|
|
||||||
let model = ali_model.unwrap_or_else(|| "qwen3-tts-flash".to_string());
|
|
||||||
let voice = ali_voice.unwrap_or_else(|| "Cherry".to_string());
|
|
||||||
let tts_handler = Arc::new(TtsHandler::new_ali_tts(
|
|
||||||
api_key,
|
|
||||||
model.clone(),
|
|
||||||
voice.clone(),
|
|
||||||
ali_language_type,
|
|
||||||
tts_volume,
|
|
||||||
));
|
|
||||||
scheduler.add_sequential_handler(tts_handler);
|
|
||||||
println!(
|
|
||||||
"TTS configured with Alibaba DashScope (model: {}, voice: {})",
|
|
||||||
model, voice
|
|
||||||
);
|
|
||||||
} else if let Some(tts_cmd) = tts_command {
|
|
||||||
// Command-line TTS configuration
|
|
||||||
let cmd_args = tts_args
|
|
||||||
.map(|s| s.split(',').map(|s| s.to_string()).collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let tts_handler = Arc::new(TtsHandler::new_command(tts_cmd, cmd_args));
|
|
||||||
scheduler.add_sequential_handler(tts_handler);
|
|
||||||
println!("TTS configured with local command");
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"No TTS configuration provided. Use --ali-api-key, --tts-server, or --tts-command to enable TTS."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add auto reply plugin if enabled
|
|
||||||
if auto_reply_config.enabled {
|
|
||||||
let auto_reply_handler = blivedm::plugins::auto_reply_handler(auto_reply_config);
|
|
||||||
scheduler.add_sequential_handler(auto_reply_handler);
|
|
||||||
println!("Auto reply plugin enabled");
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"Auto reply plugin disabled. Use --auto-reply or configure in config file to enable."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add initial system message to buffer
|
|
||||||
TuiApp::add_message(&message_buffer, format!("[System] Bilibili Danmu Client"));
|
|
||||||
TuiApp::add_message(
|
|
||||||
&message_buffer,
|
|
||||||
format!("[System] Connected to room: {}", room_id),
|
|
||||||
);
|
|
||||||
if let Some(cookies_val) = &cookies {
|
|
||||||
TuiApp::add_message(
|
|
||||||
&message_buffer,
|
|
||||||
format!(
|
|
||||||
"[System] Using provided cookies: {}...",
|
|
||||||
&cookies_val.chars().take(30).collect::<String>()
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
TuiApp::add_message(
|
|
||||||
&message_buffer,
|
|
||||||
"[System] Using auto-detected cookies from browser".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// create a thread to process the rx channel messages using tokio runtime and pass to scheduler
|
|
||||||
let rt = Arc::new(Runtime::new().unwrap());
|
|
||||||
let rt_clone = Arc::clone(&rt);
|
|
||||||
rt.spawn(async move {
|
|
||||||
while let Some(msg) = rx.next().await {
|
|
||||||
scheduler.trigger(msg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create TUI app
|
|
||||||
let mut tui_app = TuiApp::with_online_count(
|
|
||||||
Arc::clone(&message_buffer),
|
|
||||||
room_id.clone(),
|
|
||||||
Arc::clone(&online_count),
|
|
||||||
);
|
|
||||||
tui_app.set_log_buffer(log_buffer);
|
|
||||||
|
|
||||||
let context_for_chat = EventContext::new(cookies.clone(), room_id.parse::<u64>().unwrap_or(0));
|
|
||||||
let message_buffer_for_feedback = Arc::clone(&message_buffer);
|
|
||||||
|
|
||||||
// Run TUI with message sending callback
|
|
||||||
let tui_result = run_tui(tui_app, move |message| {
|
|
||||||
let context_clone = context_for_chat.clone();
|
|
||||||
let rt_for_send = Arc::clone(&rt_clone);
|
|
||||||
let buffer_clone = Arc::clone(&message_buffer_for_feedback);
|
|
||||||
|
|
||||||
rt_for_send.spawn(async move {
|
|
||||||
if let Err(e) = blivedm::plugins::send_danmaku_message(&message, &context_clone).await {
|
|
||||||
TuiApp::add_message(
|
|
||||||
&buffer_clone,
|
|
||||||
format!("[System] Error sending message: {}", e),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = tui_result {
|
|
||||||
eprintln!("TUI error: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// close the client
|
|
||||||
match shared_client.lock() {
|
|
||||||
Ok(mut _client) => {}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error acquiring lock on stream: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// wait for the threads to finish
|
|
||||||
thread::sleep(Duration::new(1, 0));
|
|
||||||
}
|
|
||||||
-478
@@ -1,478 +0,0 @@
|
|||||||
use crate::client::models::BiliMessage;
|
|
||||||
use crate::client::scheduler::{EventContext, EventHandler};
|
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
|
|
||||||
/// Configuration for keyword-response triggers
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TriggerConfig {
|
|
||||||
/// Keywords that trigger this response
|
|
||||||
pub keywords: Vec<String>,
|
|
||||||
/// Response message to send
|
|
||||||
pub response: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for the auto reply plugin
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AutoReplyConfig {
|
|
||||||
/// Whether the plugin is enabled
|
|
||||||
pub enabled: bool,
|
|
||||||
/// Minimum cooldown between replies in seconds
|
|
||||||
pub cooldown_seconds: u64,
|
|
||||||
/// List of trigger configurations
|
|
||||||
pub triggers: Vec<TriggerConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AutoReplyConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: false,
|
|
||||||
cooldown_seconds: 5,
|
|
||||||
triggers: vec![
|
|
||||||
TriggerConfig {
|
|
||||||
keywords: vec!["你好".to_string(), "hello".to_string()],
|
|
||||||
response: "欢迎来到直播间!".to_string(),
|
|
||||||
},
|
|
||||||
TriggerConfig {
|
|
||||||
keywords: vec!["谢谢".to_string(), "thanks".to_string()],
|
|
||||||
response: "不客气~".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parameters for sending a danmaku message to Bilibili API
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
struct SendDanmakuRequest {
|
|
||||||
csrf: String,
|
|
||||||
roomid: u64,
|
|
||||||
msg: String,
|
|
||||||
rnd: u64,
|
|
||||||
fontsize: u32,
|
|
||||||
color: u32,
|
|
||||||
mode: u32,
|
|
||||||
bubble: u32,
|
|
||||||
room_type: u32,
|
|
||||||
jumpfrom: u32,
|
|
||||||
reply_mid: u32,
|
|
||||||
reply_attr: u32,
|
|
||||||
reply_uname: String,
|
|
||||||
replay_dmid: String,
|
|
||||||
statistics: String,
|
|
||||||
csrf_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract CSRF token from cookies string
|
|
||||||
pub fn extract_csrf_token(cookies: &str) -> Option<String> {
|
|
||||||
for cookie in cookies.split(';') {
|
|
||||||
let cookie = cookie.trim();
|
|
||||||
if cookie.starts_with("bili_jct=") {
|
|
||||||
return Some(cookie[9..].to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a danmaku message to the Bilibili live room
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `message` - The text message to send
|
|
||||||
/// * `context` - Event context containing cookies and room_id
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// Returns Ok(()) on success, or an error if the request fails
|
|
||||||
pub async fn send_danmaku_message(
|
|
||||||
message: &str,
|
|
||||||
context: &EventContext,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let cookies = match &context.cookies {
|
|
||||||
Some(cookies) => cookies,
|
|
||||||
None => {
|
|
||||||
return Err("No cookies available for sending danmaku".into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let csrf_token = match extract_csrf_token(cookies) {
|
|
||||||
Some(token) => token,
|
|
||||||
None => {
|
|
||||||
return Err("Could not extract CSRF token from cookies".into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Current timestamp
|
|
||||||
let rnd = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let request = SendDanmakuRequest {
|
|
||||||
csrf: csrf_token.clone(),
|
|
||||||
roomid: context.room_id,
|
|
||||||
msg: message.to_string(),
|
|
||||||
rnd,
|
|
||||||
fontsize: 25,
|
|
||||||
color: 16777215, // White color
|
|
||||||
mode: 1, // Scroll mode
|
|
||||||
bubble: 0,
|
|
||||||
room_type: 0,
|
|
||||||
jumpfrom: 0,
|
|
||||||
reply_mid: 0,
|
|
||||||
reply_attr: 0,
|
|
||||||
reply_uname: String::new(),
|
|
||||||
replay_dmid: String::new(),
|
|
||||||
statistics: r#"{"appId":100,"platform":5}"#.to_string(),
|
|
||||||
csrf_token,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up headers
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("Cookie", HeaderValue::from_str(cookies)?);
|
|
||||||
headers.insert(
|
|
||||||
"User-Agent",
|
|
||||||
HeaderValue::from_static(
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
"Referer",
|
|
||||||
HeaderValue::from_str(&format!("https://live.bilibili.com/{}", context.room_id))?,
|
|
||||||
);
|
|
||||||
|
|
||||||
debug!("Sending danmaku: {}", message);
|
|
||||||
|
|
||||||
let http_client = reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let response = http_client
|
|
||||||
.post("https://api.live.bilibili.com/msg/send")
|
|
||||||
.headers(headers)
|
|
||||||
.form(&request)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
info!("Successfully sent danmaku: {}", message);
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
warn!("Failed to send danmaku, status: {}", status);
|
|
||||||
debug!("Response body: {}", body);
|
|
||||||
Err(format!("Failed to send danmaku: {} - {}", status, body).into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto reply handler that monitors danmaku for keywords and sends responses
|
|
||||||
pub struct AutoReplyHandler {
|
|
||||||
config: AutoReplyConfig,
|
|
||||||
last_reply: Arc<Mutex<Option<Instant>>>,
|
|
||||||
http_client: reqwest::Client,
|
|
||||||
runtime: Arc<Runtime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoReplyHandler {
|
|
||||||
/// Create a new auto reply handler with the given configuration
|
|
||||||
pub fn new(config: AutoReplyConfig) -> Self {
|
|
||||||
let http_client = reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.expect("Failed to create HTTP client");
|
|
||||||
|
|
||||||
let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime"));
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
last_reply: Arc::new(Mutex::new(None)),
|
|
||||||
http_client,
|
|
||||||
runtime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if any keyword matches the message text
|
|
||||||
fn find_matching_trigger(&self, text: &str) -> Option<&TriggerConfig> {
|
|
||||||
let text_lower = text.to_lowercase();
|
|
||||||
|
|
||||||
for trigger in &self.config.triggers {
|
|
||||||
for keyword in &trigger.keywords {
|
|
||||||
if text_lower.contains(&keyword.to_lowercase()) {
|
|
||||||
return Some(trigger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the response from the trigger
|
|
||||||
fn select_response(&self, trigger: &TriggerConfig) -> Option<String> {
|
|
||||||
if trigger.response.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(trigger.response.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if enough time has passed since the last reply
|
|
||||||
fn check_cooldown(&self) -> bool {
|
|
||||||
let last_reply = self.last_reply.lock().unwrap();
|
|
||||||
|
|
||||||
match *last_reply {
|
|
||||||
Some(last_time) => {
|
|
||||||
let elapsed = last_time.elapsed();
|
|
||||||
elapsed >= Duration::from_secs(self.config.cooldown_seconds)
|
|
||||||
}
|
|
||||||
None => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the last reply timestamp
|
|
||||||
fn update_last_reply(&self) {
|
|
||||||
let mut last_reply = self.last_reply.lock().unwrap();
|
|
||||||
*last_reply = Some(Instant::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract CSRF token from cookies
|
|
||||||
fn extract_csrf_token(&self, cookies: &str) -> Option<String> {
|
|
||||||
extract_csrf_token(cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a danmaku message to the Bilibili API
|
|
||||||
async fn send_danmaku(
|
|
||||||
&self,
|
|
||||||
message: &str,
|
|
||||||
context: &EventContext,
|
|
||||||
) -> Result<(), reqwest::Error> {
|
|
||||||
let cookies = match &context.cookies {
|
|
||||||
Some(cookies) => cookies,
|
|
||||||
None => {
|
|
||||||
warn!("No cookies available for sending danmaku");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let csrf_token = match self.extract_csrf_token(cookies) {
|
|
||||||
Some(token) => token,
|
|
||||||
None => {
|
|
||||||
error!("Could not extract CSRF token from cookies");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Current timestamp
|
|
||||||
let rnd = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let request = SendDanmakuRequest {
|
|
||||||
csrf: csrf_token.clone(),
|
|
||||||
roomid: context.room_id,
|
|
||||||
msg: message.to_string(),
|
|
||||||
rnd,
|
|
||||||
fontsize: 25,
|
|
||||||
color: 16777215, // White color
|
|
||||||
mode: 1, // Scroll mode
|
|
||||||
bubble: 0,
|
|
||||||
room_type: 0,
|
|
||||||
jumpfrom: 0,
|
|
||||||
reply_mid: 0,
|
|
||||||
reply_attr: 0,
|
|
||||||
reply_uname: String::new(),
|
|
||||||
replay_dmid: String::new(),
|
|
||||||
statistics: r#"{"appId":100,"platform":5}"#.to_string(),
|
|
||||||
csrf_token,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up headers
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert("Cookie", HeaderValue::from_str(cookies).unwrap());
|
|
||||||
headers.insert(
|
|
||||||
"User-Agent",
|
|
||||||
HeaderValue::from_static(
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
"Referer",
|
|
||||||
HeaderValue::from_str(&format!("https://live.bilibili.com/{}", context.room_id))
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
debug!("Sending danmaku: {}", message);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.http_client
|
|
||||||
.post("https://api.live.bilibili.com/msg/send")
|
|
||||||
.headers(headers)
|
|
||||||
.form(&request)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
info!("Successfully sent danmaku: {}", message);
|
|
||||||
} else {
|
|
||||||
warn!("Failed to send danmaku, status: {}", response.status());
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
debug!("Response body: {}", body);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventHandler for AutoReplyHandler {
|
|
||||||
fn handle(&self, msg: &BiliMessage, context: &EventContext) {
|
|
||||||
if !self.config.enabled {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process danmaku messages
|
|
||||||
if let BiliMessage::Danmu { user: _, text } = msg {
|
|
||||||
// Check for keyword match
|
|
||||||
if let Some(trigger) = self.find_matching_trigger(text) {
|
|
||||||
// Check cooldown
|
|
||||||
if !self.check_cooldown() {
|
|
||||||
debug!("Auto reply on cooldown, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select response
|
|
||||||
if let Some(response) = self.select_response(trigger) {
|
|
||||||
debug!(
|
|
||||||
"Auto reply triggered by '{}', responding with '{}'",
|
|
||||||
text, response
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update cooldown
|
|
||||||
self.update_last_reply();
|
|
||||||
|
|
||||||
// Send the reply asynchronously
|
|
||||||
let runtime = Arc::clone(&self.runtime);
|
|
||||||
let _http_client = self.http_client.clone();
|
|
||||||
let response_msg = response.clone();
|
|
||||||
let context_clone = context.clone();
|
|
||||||
let handler = self.clone();
|
|
||||||
|
|
||||||
runtime.spawn(async move {
|
|
||||||
if let Err(e) = handler.send_danmaku(&response_msg, &context_clone).await {
|
|
||||||
error!("Failed to send auto reply: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement Clone for AutoReplyHandler
|
|
||||||
impl Clone for AutoReplyHandler {
|
|
||||||
fn clone(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
config: self.config.clone(),
|
|
||||||
last_reply: Arc::clone(&self.last_reply),
|
|
||||||
http_client: self.http_client.clone(),
|
|
||||||
runtime: Arc::clone(&self.runtime),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::client::models::BiliMessage;
|
|
||||||
use crate::client::scheduler::{EventContext, EventHandler};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_keyword_matching() {
|
|
||||||
let config = AutoReplyConfig::default();
|
|
||||||
let handler = AutoReplyHandler::new(config);
|
|
||||||
|
|
||||||
// Test keyword matching
|
|
||||||
assert!(handler.find_matching_trigger("你好世界").is_some());
|
|
||||||
assert!(handler.find_matching_trigger("Hello world").is_some());
|
|
||||||
assert!(handler.find_matching_trigger("谢谢大家").is_some());
|
|
||||||
assert!(handler.find_matching_trigger("Thanks everyone").is_some());
|
|
||||||
assert!(handler.find_matching_trigger("random text").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_response_selection() {
|
|
||||||
let config = AutoReplyConfig::default();
|
|
||||||
let handler = AutoReplyHandler::new(config);
|
|
||||||
|
|
||||||
let trigger = &handler.config.triggers[0];
|
|
||||||
let response = handler.select_response(trigger);
|
|
||||||
assert!(response.is_some());
|
|
||||||
assert_eq!(response.unwrap(), trigger.response);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cooldown() {
|
|
||||||
let config = AutoReplyConfig {
|
|
||||||
enabled: true,
|
|
||||||
cooldown_seconds: 1,
|
|
||||||
triggers: vec![],
|
|
||||||
};
|
|
||||||
let handler = AutoReplyHandler::new(config);
|
|
||||||
|
|
||||||
// Initial check should pass
|
|
||||||
assert!(handler.check_cooldown());
|
|
||||||
|
|
||||||
// Update timestamp
|
|
||||||
handler.update_last_reply();
|
|
||||||
|
|
||||||
// Should be on cooldown now
|
|
||||||
assert!(!handler.check_cooldown());
|
|
||||||
|
|
||||||
// Wait for cooldown
|
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
|
||||||
|
|
||||||
// Should be off cooldown now
|
|
||||||
assert!(handler.check_cooldown());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_csrf_extraction() {
|
|
||||||
let config = AutoReplyConfig::default();
|
|
||||||
let handler = AutoReplyHandler::new(config);
|
|
||||||
|
|
||||||
let cookies = "SESSDATA=abc123; bili_jct=csrf_token_here; other=value";
|
|
||||||
let csrf = handler.extract_csrf_token(cookies);
|
|
||||||
assert_eq!(csrf, Some("csrf_token_here".to_string()));
|
|
||||||
|
|
||||||
let cookies_no_csrf = "SESSDATA=abc123; other=value";
|
|
||||||
let csrf = handler.extract_csrf_token(cookies_no_csrf);
|
|
||||||
assert_eq!(csrf, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_event_handler() {
|
|
||||||
let config = AutoReplyConfig {
|
|
||||||
enabled: true,
|
|
||||||
cooldown_seconds: 0, // No cooldown for testing
|
|
||||||
triggers: vec![TriggerConfig {
|
|
||||||
keywords: vec!["test".to_string()],
|
|
||||||
response: "test response".to_string(),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
let handler = AutoReplyHandler::new(config);
|
|
||||||
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: Some("bili_jct=test_csrf; SESSDATA=test".to_string()),
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "test_user".to_string(),
|
|
||||||
text: "this is a test message".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// This should trigger the auto reply (but won't actually send due to test environment)
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-58
@@ -1,58 +0,0 @@
|
|||||||
pub mod auto_reply;
|
|
||||||
pub mod terminal_display;
|
|
||||||
pub mod tts;
|
|
||||||
|
|
||||||
use crate::client::scheduler::EventHandler;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
// Re-export danmaku sending utility for easy access
|
|
||||||
pub use auto_reply::send_danmaku_message;
|
|
||||||
|
|
||||||
/// Helper to create the handler as Arc<dyn EventHandler>
|
|
||||||
pub fn terminal_display_handler(
|
|
||||||
message_buffer: Arc<Mutex<VecDeque<String>>>,
|
|
||||||
) -> Arc<dyn EventHandler> {
|
|
||||||
Arc::new(terminal_display::TerminalDisplayHandler::new(
|
|
||||||
message_buffer,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to create the TTS handler as Arc<dyn EventHandler>
|
|
||||||
/// Uses default Chinese voice settings with REST API
|
|
||||||
pub fn tts_handler_default(server_url: String) -> Arc<dyn EventHandler> {
|
|
||||||
Arc::new(tts::TtsHandler::new_rest_api_default(server_url))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to create the TTS handler with REST API and custom configuration as Arc<dyn EventHandler>
|
|
||||||
pub fn tts_handler(
|
|
||||||
server_url: String,
|
|
||||||
voice: Option<String>,
|
|
||||||
backend: Option<String>,
|
|
||||||
quality: Option<String>,
|
|
||||||
format: Option<String>,
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
) -> Arc<dyn EventHandler> {
|
|
||||||
Arc::new(tts::TtsHandler::new_rest_api(
|
|
||||||
server_url,
|
|
||||||
voice,
|
|
||||||
backend,
|
|
||||||
quality,
|
|
||||||
format,
|
|
||||||
sample_rate,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to create the command-based TTS handler as Arc<dyn EventHandler>
|
|
||||||
/// For local TTS commands like `say` on macOS or `espeak-ng` on Linux
|
|
||||||
pub fn tts_handler_command(tts_command: String, tts_args: Vec<String>) -> Arc<dyn EventHandler> {
|
|
||||||
Arc::new(tts::TtsHandler::new_command(tts_command, tts_args))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to create the auto reply handler as Arc<dyn EventHandler>
|
|
||||||
pub fn auto_reply_handler(config: auto_reply::AutoReplyConfig) -> Arc<dyn EventHandler> {
|
|
||||||
Arc::new(auto_reply::AutoReplyHandler::new(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {}
|
|
||||||
-125
@@ -1,125 +0,0 @@
|
|||||||
use crate::client::models::BiliMessage;
|
|
||||||
use crate::client::scheduler::{EventContext, EventHandler};
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::atomic::AtomicU64;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
/// A plugin that adds BiliMessages to a shared message buffer for TUI display.
|
|
||||||
pub struct TerminalDisplayHandler {
|
|
||||||
/// Shared message buffer for TUI
|
|
||||||
message_buffer: Arc<Mutex<VecDeque<String>>>,
|
|
||||||
/// Shared online count for TUI title display
|
|
||||||
online_count: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TerminalDisplayHandler {
|
|
||||||
/// Create a new TerminalDisplayHandler with a shared message buffer
|
|
||||||
pub fn new(message_buffer: Arc<Mutex<VecDeque<String>>>) -> Self {
|
|
||||||
Self {
|
|
||||||
message_buffer,
|
|
||||||
online_count: Arc::new(AtomicU64::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TerminalDisplayHandler with shared message buffer and online count
|
|
||||||
pub fn with_online_count(
|
|
||||||
message_buffer: Arc<Mutex<VecDeque<String>>>,
|
|
||||||
online_count: Arc<AtomicU64>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
message_buffer,
|
|
||||||
online_count,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventHandler for TerminalDisplayHandler {
|
|
||||||
fn handle(&self, msg: &BiliMessage, _context: &EventContext) {
|
|
||||||
let formatted_msg = match msg {
|
|
||||||
BiliMessage::Danmu { user, text } => {
|
|
||||||
format!("[Danmu] {}: {}", user, text)
|
|
||||||
}
|
|
||||||
BiliMessage::Gift { user, gift , num} => {
|
|
||||||
format!("[Gift] {} sent a gift: {} X {}", user, gift, num)
|
|
||||||
}
|
|
||||||
BiliMessage::OnlineRankCount { online_count, .. } => {
|
|
||||||
// Update the shared online count for TUI title display
|
|
||||||
crate::tui::app::TuiApp::set_online_count(&self.online_count, *online_count);
|
|
||||||
// Don't add to message buffer - just update the title counter
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BiliMessage::Raw(json) => {
|
|
||||||
format!("[Raw] {}", json["cmd"].as_str().unwrap_or("Unknown"))
|
|
||||||
}
|
|
||||||
#[allow(deprecated)]
|
|
||||||
BiliMessage::Unsupported => "[Unsupported message type]".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add message to buffer using the TuiApp helper method
|
|
||||||
crate::tui::app::TuiApp::add_message(&self.message_buffer, formatted_msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::client::models::BiliMessage;
|
|
||||||
use crate::client::scheduler::EventHandler;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_terminal_display_handler_adds_danmu() {
|
|
||||||
let buffer = Arc::new(Mutex::new(VecDeque::new()));
|
|
||||||
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "test_user".to_string(),
|
|
||||||
text: "hello world".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
|
|
||||||
let messages = buffer.lock().unwrap();
|
|
||||||
assert_eq!(messages.len(), 1);
|
|
||||||
assert_eq!(messages[0], "[Danmu] test_user: hello world");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_terminal_display_handler_adds_gift() {
|
|
||||||
let buffer = Arc::new(Mutex::new(VecDeque::new()));
|
|
||||||
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
|
|
||||||
let msg = BiliMessage::Gift {
|
|
||||||
user: "gift_user".to_string(),
|
|
||||||
gift: "rocket".to_string(),
|
|
||||||
num: "count".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
|
|
||||||
let messages = buffer.lock().unwrap();
|
|
||||||
assert_eq!(messages.len(), 1);
|
|
||||||
assert_eq!(messages[0], "[Gift] gift_user sent a gift: rocket");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_terminal_display_handler_adds_unsupported() {
|
|
||||||
let buffer = Arc::new(Mutex::new(VecDeque::new()));
|
|
||||||
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
|
|
||||||
let msg = BiliMessage::Unsupported;
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
|
|
||||||
let messages = buffer.lock().unwrap();
|
|
||||||
assert_eq!(messages.len(), 1);
|
|
||||||
assert_eq!(messages[0], "[Unsupported message type]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-856
@@ -1,856 +0,0 @@
|
|||||||
use crate::client::models::BiliMessage;
|
|
||||||
use crate::client::scheduler::{EventContext, EventHandler};
|
|
||||||
use base64::{Engine as _, engine::general_purpose};
|
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
use rodio::{Decoder, OutputStream, Sink};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::io::Cursor;
|
|
||||||
use std::process::Command;
|
|
||||||
use std::sync::mpsc::{self, Sender};
|
|
||||||
use std::thread;
|
|
||||||
use std::thread::JoinHandle;
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
struct TtsRequest {
|
|
||||||
text: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
voice: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
backend: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
quality: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
format: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct TtsResponse {
|
|
||||||
audio_data: String,
|
|
||||||
metadata: TtsMetadata,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
cached: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct TtsMetadata {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
backend: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
voice: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
duration: Option<f64>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
format: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
size_bytes: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alibaba DashScope TTS request structure
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
struct AliTtsRequest {
|
|
||||||
model: String,
|
|
||||||
input: AliTtsInput,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
struct AliTtsInput {
|
|
||||||
text: String,
|
|
||||||
voice: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
language_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alibaba DashScope TTS SSE response structure
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct AliTtsResponse {
|
|
||||||
output: Option<AliTtsOutput>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
request_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct AliTtsOutput {
|
|
||||||
#[serde(default)]
|
|
||||||
audio: Option<AliTtsAudio>,
|
|
||||||
/// Finish reason: "null" for intermediate, "stop" for final
|
|
||||||
#[serde(default)]
|
|
||||||
finish_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
struct AliTtsAudio {
|
|
||||||
/// Base64 encoded audio data chunk (may be empty)
|
|
||||||
#[serde(default)]
|
|
||||||
data: Option<String>,
|
|
||||||
/// Audio URL (only in the final response when finish_reason is "stop")
|
|
||||||
#[serde(default)]
|
|
||||||
url: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[serde(default)]
|
|
||||||
id: Option<String>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[serde(default)]
|
|
||||||
expires_at: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TTS backend configuration
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum TtsMode {
|
|
||||||
/// Use REST API for TTS with advanced neural voices
|
|
||||||
RestApi {
|
|
||||||
/// The base URL of the TTS server (e.g., "http://localhost:8000")
|
|
||||||
server_url: String,
|
|
||||||
/// Voice ID to use for TTS (e.g., "zh-CN-XiaoxiaoNeural")
|
|
||||||
voice: Option<String>,
|
|
||||||
/// TTS backend to use (e.g., "edge", "xtts", "piper")
|
|
||||||
backend: Option<String>,
|
|
||||||
/// Audio quality ("low", "medium", "high")
|
|
||||||
quality: Option<String>,
|
|
||||||
/// Audio format (e.g., "wav")
|
|
||||||
format: Option<String>,
|
|
||||||
/// Sample rate for audio
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
/// Audio volume (0.0 to 1.0, default is 1.0)
|
|
||||||
volume: Option<f32>,
|
|
||||||
},
|
|
||||||
/// Use Alibaba DashScope TTS API (qwen3-tts)
|
|
||||||
AliTts {
|
|
||||||
/// DashScope API key (from DASHSCOPE_API_KEY env or config)
|
|
||||||
api_key: String,
|
|
||||||
/// Model to use (e.g., "qwen3-tts-flash")
|
|
||||||
model: String,
|
|
||||||
/// Voice ID to use (e.g., "Cherry", "Chelsie", etc.)
|
|
||||||
voice: String,
|
|
||||||
/// Language type (e.g., "Chinese", "English")
|
|
||||||
language_type: Option<String>,
|
|
||||||
/// Audio volume (0.0 to 1.0, default is 1.0)
|
|
||||||
volume: Option<f32>,
|
|
||||||
},
|
|
||||||
/// Use local command-line TTS programs
|
|
||||||
Command {
|
|
||||||
/// The TTS command to use (e.g., "say" on macOS, "espeak-ng" on Linux)
|
|
||||||
tts_command: String,
|
|
||||||
/// Optional extra arguments for the TTS command (e.g., ["-v", "SinJi"])
|
|
||||||
tts_args: Vec<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A plugin that sends Danmaku text to a TTS service and plays the audio sequentially.
|
|
||||||
///
|
|
||||||
/// This handler supports two modes:
|
|
||||||
/// 1. REST API mode: Sends text to a TTS REST API server, receives base64-encoded audio data,
|
|
||||||
/// decodes it and plays through the system's audio output
|
|
||||||
/// 2. Command mode: Uses local command-line TTS programs (like `say` on macOS or `espeak-ng` on Linux)
|
|
||||||
///
|
|
||||||
/// Messages are processed sequentially to avoid overlapping audio.
|
|
||||||
pub struct TtsHandler {
|
|
||||||
/// TTS configuration (either REST API or command-based)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
mode: TtsMode,
|
|
||||||
/// Channel sender for queuing TTS messages
|
|
||||||
sender: Sender<String>,
|
|
||||||
/// Background thread handle for TTS processing
|
|
||||||
_worker_handle: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TtsHandler {
|
|
||||||
/// Create a new TTS handler with the specified mode
|
|
||||||
pub fn new(mode: TtsMode) -> Self {
|
|
||||||
let (sender, receiver) = mpsc::channel::<String>();
|
|
||||||
|
|
||||||
// Clone the mode for the worker thread
|
|
||||||
let mode_clone = mode.clone();
|
|
||||||
|
|
||||||
// Spawn worker thread to process TTS queue sequentially
|
|
||||||
let worker_handle = thread::spawn(move || match &mode_clone {
|
|
||||||
TtsMode::RestApi { .. } => {
|
|
||||||
Self::run_rest_api_worker(receiver, mode_clone);
|
|
||||||
}
|
|
||||||
TtsMode::AliTts { .. } => {
|
|
||||||
Self::run_ali_tts_worker(receiver, mode_clone);
|
|
||||||
}
|
|
||||||
TtsMode::Command { .. } => {
|
|
||||||
Self::run_command_worker(receiver, mode_clone);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
TtsHandler {
|
|
||||||
mode,
|
|
||||||
sender,
|
|
||||||
_worker_handle: worker_handle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TTS handler with REST API using default Chinese voice settings
|
|
||||||
pub fn new_rest_api_default(server_url: String) -> Self {
|
|
||||||
Self::new_rest_api_default_with_volume(server_url, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TTS handler with REST API using default Chinese voice settings and custom volume
|
|
||||||
pub fn new_rest_api_default_with_volume(server_url: String, volume: f32) -> Self {
|
|
||||||
let mode = TtsMode::RestApi {
|
|
||||||
server_url,
|
|
||||||
voice: Some("zh-CN-XiaoxiaoNeural".to_string()),
|
|
||||||
backend: Some("edge".to_string()),
|
|
||||||
quality: Some("medium".to_string()),
|
|
||||||
format: Some("wav".to_string()),
|
|
||||||
sample_rate: Some(22050),
|
|
||||||
volume: Some(volume),
|
|
||||||
};
|
|
||||||
Self::new(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TTS handler with REST API and custom configuration
|
|
||||||
pub fn new_rest_api(
|
|
||||||
server_url: String,
|
|
||||||
voice: Option<String>,
|
|
||||||
backend: Option<String>,
|
|
||||||
quality: Option<String>,
|
|
||||||
format: Option<String>,
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
) -> Self {
|
|
||||||
Self::new_rest_api_with_volume(
|
|
||||||
server_url,
|
|
||||||
voice,
|
|
||||||
backend,
|
|
||||||
quality,
|
|
||||||
format,
|
|
||||||
sample_rate,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TTS handler with REST API and custom configuration including volume
|
|
||||||
pub fn new_rest_api_with_volume(
|
|
||||||
server_url: String,
|
|
||||||
voice: Option<String>,
|
|
||||||
backend: Option<String>,
|
|
||||||
quality: Option<String>,
|
|
||||||
format: Option<String>,
|
|
||||||
sample_rate: Option<u32>,
|
|
||||||
volume: Option<f32>,
|
|
||||||
) -> Self {
|
|
||||||
let mode = TtsMode::RestApi {
|
|
||||||
server_url,
|
|
||||||
voice,
|
|
||||||
backend,
|
|
||||||
quality,
|
|
||||||
format,
|
|
||||||
sample_rate,
|
|
||||||
volume,
|
|
||||||
};
|
|
||||||
Self::new(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TTS handler with command-line TTS
|
|
||||||
pub fn new_command(tts_command: String, tts_args: Vec<String>) -> Self {
|
|
||||||
let mode = TtsMode::Command {
|
|
||||||
tts_command,
|
|
||||||
tts_args,
|
|
||||||
};
|
|
||||||
Self::new(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TTS handler with Alibaba DashScope TTS using default settings
|
|
||||||
pub fn new_ali_tts_default(api_key: String) -> Self {
|
|
||||||
Self::new_ali_tts(
|
|
||||||
api_key,
|
|
||||||
"qwen3-tts-flash".to_string(),
|
|
||||||
"Cherry".to_string(),
|
|
||||||
Some("Chinese".to_string()),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TTS handler with Alibaba DashScope TTS and custom configuration
|
|
||||||
pub fn new_ali_tts(
|
|
||||||
api_key: String,
|
|
||||||
model: String,
|
|
||||||
voice: String,
|
|
||||||
language_type: Option<String>,
|
|
||||||
volume: Option<f32>,
|
|
||||||
) -> Self {
|
|
||||||
let mode = TtsMode::AliTts {
|
|
||||||
api_key,
|
|
||||||
model,
|
|
||||||
voice,
|
|
||||||
language_type,
|
|
||||||
volume,
|
|
||||||
};
|
|
||||||
Self::new(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Worker thread for REST API TTS processing
|
|
||||||
fn run_rest_api_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
|
|
||||||
if let TtsMode::RestApi {
|
|
||||||
server_url,
|
|
||||||
voice,
|
|
||||||
backend,
|
|
||||||
quality,
|
|
||||||
format,
|
|
||||||
sample_rate,
|
|
||||||
volume,
|
|
||||||
} = mode
|
|
||||||
{
|
|
||||||
// Create a tokio runtime for HTTP requests
|
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Initialize audio output stream (this will be reused for all audio playback)
|
|
||||||
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
|
|
||||||
|
|
||||||
while let Ok(message) = receiver.recv() {
|
|
||||||
let request = TtsRequest {
|
|
||||||
text: message,
|
|
||||||
voice: voice.clone(),
|
|
||||||
backend: backend.clone(),
|
|
||||||
quality: quality.clone(),
|
|
||||||
format: format.clone(),
|
|
||||||
sample_rate,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make HTTP request to TTS service
|
|
||||||
rt.block_on(async {
|
|
||||||
match client
|
|
||||||
.post(&format!("{}/tts", server_url))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.json(&request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(response) => {
|
|
||||||
if response.status().is_success() {
|
|
||||||
match response.json::<TtsResponse>().await {
|
|
||||||
Ok(tts_response) => {
|
|
||||||
info!("TTS generated successfully");
|
|
||||||
|
|
||||||
// Decode base64 audio data and play it
|
|
||||||
match general_purpose::STANDARD
|
|
||||||
.decode(&tts_response.audio_data)
|
|
||||||
{
|
|
||||||
Ok(audio_bytes) => {
|
|
||||||
// Create a cursor from the audio bytes
|
|
||||||
let cursor = Cursor::new(audio_bytes);
|
|
||||||
|
|
||||||
// Create a decoder for the audio format
|
|
||||||
match Decoder::new(cursor) {
|
|
||||||
Ok(source) => {
|
|
||||||
// Create a new sink for this audio
|
|
||||||
let sink =
|
|
||||||
Sink::try_new(&stream_handle).unwrap();
|
|
||||||
|
|
||||||
// Set volume if specified (default to 1.0 if not set)
|
|
||||||
let audio_volume = volume.unwrap_or(1.0);
|
|
||||||
sink.set_volume(audio_volume);
|
|
||||||
|
|
||||||
// Append the audio source to the sink
|
|
||||||
sink.append(source);
|
|
||||||
|
|
||||||
// Wait for the audio to finish playing
|
|
||||||
sink.sleep_until_end();
|
|
||||||
|
|
||||||
debug!("Audio playback completed");
|
|
||||||
}
|
|
||||||
Err(e) => error!(
|
|
||||||
"Failed to decode audio format: {}",
|
|
||||||
e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to decode base64 audio data: {}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => error!("Failed to parse TTS response: {}", e),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("TTS request failed with status: {}", response.status());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => error!("Failed to send TTS request: {}", e),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Worker thread for Alibaba DashScope TTS processing with SSE streaming
|
|
||||||
fn run_ali_tts_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
|
|
||||||
use futures::StreamExt;
|
|
||||||
|
|
||||||
if let TtsMode::AliTts {
|
|
||||||
api_key,
|
|
||||||
model,
|
|
||||||
voice,
|
|
||||||
language_type,
|
|
||||||
volume,
|
|
||||||
} = mode
|
|
||||||
{
|
|
||||||
// Create a tokio runtime for HTTP requests
|
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Initialize audio output stream (this will be reused for all audio playback)
|
|
||||||
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
|
|
||||||
|
|
||||||
while let Ok(message) = receiver.recv() {
|
|
||||||
let request = AliTtsRequest {
|
|
||||||
model: model.clone(),
|
|
||||||
input: AliTtsInput {
|
|
||||||
text: message,
|
|
||||||
voice: voice.clone(),
|
|
||||||
language_type: language_type.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make HTTP request to DashScope TTS service with SSE
|
|
||||||
rt.block_on(async {
|
|
||||||
match client
|
|
||||||
.post("https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation")
|
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("X-DashScope-SSE", "enable")
|
|
||||||
.json(&request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(response) => {
|
|
||||||
if response.status().is_success() {
|
|
||||||
// Collect all audio chunks from SSE stream
|
|
||||||
let mut audio_chunks: Vec<Vec<u8>> = Vec::new();
|
|
||||||
let mut audio_url: Option<String> = None;
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
|
|
||||||
let mut buffer = String::new();
|
|
||||||
|
|
||||||
while let Some(chunk_result) = stream.next().await {
|
|
||||||
match chunk_result {
|
|
||||||
Ok(chunk) => {
|
|
||||||
// Append chunk to buffer
|
|
||||||
if let Ok(text) = std::str::from_utf8(&chunk) {
|
|
||||||
buffer.push_str(text);
|
|
||||||
|
|
||||||
// Process complete SSE events in buffer
|
|
||||||
while let Some(event_end) = buffer.find("\n\n") {
|
|
||||||
let event = buffer[..event_end].to_string();
|
|
||||||
buffer = buffer[event_end + 2..].to_string();
|
|
||||||
|
|
||||||
// Parse SSE event - look for data: lines
|
|
||||||
for line in event.lines() {
|
|
||||||
if let Some(data) = line.strip_prefix("data:") {
|
|
||||||
let data = data.trim();
|
|
||||||
if data.is_empty() || data == "[DONE]" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match serde_json::from_str::<AliTtsResponse>(data) {
|
|
||||||
Ok(ali_response) => {
|
|
||||||
if let Some(output) = ali_response.output {
|
|
||||||
if let Some(audio) = output.audio {
|
|
||||||
// Check for base64 audio data (non-empty)
|
|
||||||
if let Some(ref audio_data) = audio.data {
|
|
||||||
if !audio_data.is_empty() {
|
|
||||||
match general_purpose::STANDARD.decode(audio_data) {
|
|
||||||
Ok(decoded) => {
|
|
||||||
if !decoded.is_empty() {
|
|
||||||
audio_chunks.push(decoded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!("Failed to decode audio chunk: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check for audio URL (final response)
|
|
||||||
if let Some(url) = audio.url {
|
|
||||||
debug!("Audio URL received: {}", url);
|
|
||||||
audio_url = Some(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check if this is the final response
|
|
||||||
if let Some(ref reason) = output.finish_reason {
|
|
||||||
if reason == "stop" {
|
|
||||||
debug!("Received final response with finish_reason: stop");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!("Failed to parse SSE data: {} - data: {}", e, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error reading SSE stream: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to play audio - prefer URL download over streamed chunks
|
|
||||||
// Streamed MP3 chunks cannot be simply concatenated due to headers/frames
|
|
||||||
let audio_data = if let Some(url) = audio_url {
|
|
||||||
// Download complete audio from URL (preferred method)
|
|
||||||
info!("AliTTS: downloading audio from URL");
|
|
||||||
match client.get(&url).send().await {
|
|
||||||
Ok(audio_response) => {
|
|
||||||
if audio_response.status().is_success() {
|
|
||||||
match audio_response.bytes().await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
info!("AliTTS: downloaded {} bytes", bytes.len());
|
|
||||||
Some(bytes.to_vec())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to read audio bytes: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error!("Failed to download audio: {}", audio_response.status());
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to fetch audio URL: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !audio_chunks.is_empty() {
|
|
||||||
// Fallback: try to use collected base64 chunks
|
|
||||||
// Note: This may not work correctly for MP3 format due to concatenation issues
|
|
||||||
warn!("AliTTS: No URL provided, attempting to use streamed chunks (may have decoding issues)");
|
|
||||||
let combined: Vec<u8> = audio_chunks.into_iter().flatten().collect();
|
|
||||||
info!("AliTTS: using {} bytes from streamed chunks", combined.len());
|
|
||||||
Some(combined)
|
|
||||||
} else {
|
|
||||||
warn!("No audio data or URL received from AliTTS");
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Play the audio
|
|
||||||
if let Some(audio_bytes) = audio_data {
|
|
||||||
let cursor = Cursor::new(audio_bytes);
|
|
||||||
match Decoder::new(cursor) {
|
|
||||||
Ok(source) => {
|
|
||||||
let sink = Sink::try_new(&stream_handle).unwrap();
|
|
||||||
let audio_volume = volume.unwrap_or(1.0);
|
|
||||||
sink.set_volume(audio_volume);
|
|
||||||
sink.append(source);
|
|
||||||
sink.sleep_until_end();
|
|
||||||
debug!("Audio playback completed");
|
|
||||||
}
|
|
||||||
Err(e) => error!("Failed to decode audio format: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
warn!("AliTTS request failed with status: {} - {}", status, body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => error!("Failed to send AliTTS request: {}", e),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Worker thread for command-line TTS processing
|
|
||||||
fn run_command_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
|
|
||||||
if let TtsMode::Command {
|
|
||||||
tts_command,
|
|
||||||
tts_args,
|
|
||||||
} = mode
|
|
||||||
{
|
|
||||||
while let Ok(message) = receiver.recv() {
|
|
||||||
let mut command = Command::new(&tts_command);
|
|
||||||
for arg in &tts_args {
|
|
||||||
command.arg(arg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute TTS command and wait for it to complete
|
|
||||||
match command.arg(&message).status() {
|
|
||||||
Ok(status) => {
|
|
||||||
if status.success() {
|
|
||||||
debug!("TTS command completed successfully");
|
|
||||||
} else {
|
|
||||||
warn!("TTS command failed with status: {}", status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => error!("Failed to execute TTS command: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Legacy method - kept for backward compatibility
|
|
||||||
#[deprecated(note = "Use new_rest_api_default instead")]
|
|
||||||
pub fn new_default(server_url: String) -> Self {
|
|
||||||
Self::new_rest_api_default(server_url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventHandler for TtsHandler {
|
|
||||||
fn handle(&self, msg: &BiliMessage, _context: &EventContext) {
|
|
||||||
if let BiliMessage::Danmu { user, text } = msg {
|
|
||||||
let message = format!("{}说:{}", user, text);
|
|
||||||
// Send message to the queue for sequential processing
|
|
||||||
let _ = self.sender.send(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::client::models::BiliMessage;
|
|
||||||
use crate::client::scheduler::EventHandler;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tts_handler_danmu() {
|
|
||||||
// Test with a mock server URL (won't actually make requests in this test)
|
|
||||||
let handler = TtsHandler::new_rest_api_default("http://localhost:8000".to_string());
|
|
||||||
|
|
||||||
let text = "您好,欢迎来到直播间。".to_string();
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "测试用户".to_string(),
|
|
||||||
text: text.clone(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tts_handler_custom_config() {
|
|
||||||
let handler = TtsHandler::new_rest_api(
|
|
||||||
"http://localhost:8000".to_string(),
|
|
||||||
Some("zh-CN-XiaoxiaoNeural".to_string()),
|
|
||||||
Some("edge".to_string()),
|
|
||||||
Some("high".to_string()),
|
|
||||||
Some("wav".to_string()),
|
|
||||||
Some(44100),
|
|
||||||
);
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "test_user".to_string(),
|
|
||||||
text: "hello world".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tts_handler_sequential_processing() {
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
// Use default configuration for testing
|
|
||||||
let handler = TtsHandler::new_rest_api_default("http://localhost:8000".to_string());
|
|
||||||
|
|
||||||
// Send multiple messages quickly
|
|
||||||
let messages = vec![
|
|
||||||
("User1", "First message"),
|
|
||||||
("User2", "Second message"),
|
|
||||||
("User3", "Third message"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (user, text) in messages {
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: user.to_string(),
|
|
||||||
text: text.to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the worker thread some time to process the queue
|
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
|
||||||
|
|
||||||
// The test passes if no panic occurs - the sequential processing
|
|
||||||
// is ensured by the worker thread design
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tts_handler_command_mode() {
|
|
||||||
// Test command-based TTS (cross-platform using echo)
|
|
||||||
let handler = TtsHandler::new_command("echo".to_string(), vec![]);
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "test_user".to_string(),
|
|
||||||
text: "test message".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
|
|
||||||
// Give the worker thread some time to process the message
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn test_tts_handler_macos_voice() {
|
|
||||||
let handler = TtsHandler::new_command(
|
|
||||||
"say".to_string(),
|
|
||||||
vec!["-v".to_string(), "Mei-Jia".to_string()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "用户".to_string(),
|
|
||||||
text: "你好".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[test]
|
|
||||||
fn test_tts_handler_linux_voice() {
|
|
||||||
let handler = TtsHandler::new_command(
|
|
||||||
"espeak-ng".to_string(),
|
|
||||||
vec!["-v".to_string(), "cmn".to_string()],
|
|
||||||
);
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "用户".to_string(),
|
|
||||||
text: "你好".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tts_request_serialization() {
|
|
||||||
let request = TtsRequest {
|
|
||||||
text: "Hello world".to_string(),
|
|
||||||
voice: Some("zh-CN-XiaoxiaoNeural".to_string()),
|
|
||||||
backend: Some("edge".to_string()),
|
|
||||||
quality: Some("medium".to_string()),
|
|
||||||
format: Some("wav".to_string()),
|
|
||||||
sample_rate: Some(22050),
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&request).unwrap();
|
|
||||||
assert!(json.contains("Hello world"));
|
|
||||||
assert!(json.contains("zh-CN-XiaoxiaoNeural"));
|
|
||||||
assert!(json.contains("edge"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tts_handler_with_volume() {
|
|
||||||
// Test with custom volume setting
|
|
||||||
let handler =
|
|
||||||
TtsHandler::new_rest_api_default_with_volume("http://localhost:8000".to_string(), 0.5);
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "test_user".to_string(),
|
|
||||||
text: "volume test".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
|
|
||||||
// Test with custom configuration including volume
|
|
||||||
let handler_custom = TtsHandler::new_rest_api_with_volume(
|
|
||||||
"http://localhost:8000".to_string(),
|
|
||||||
Some("zh-CN-XiaoxiaoNeural".to_string()),
|
|
||||||
Some("edge".to_string()),
|
|
||||||
Some("high".to_string()),
|
|
||||||
Some("wav".to_string()),
|
|
||||||
Some(44100),
|
|
||||||
Some(0.8),
|
|
||||||
);
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler_custom.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ali_tts_handler_default() {
|
|
||||||
// Test with a mock API key (won't actually make requests in this test)
|
|
||||||
let handler = TtsHandler::new_ali_tts_default("test_api_key".to_string());
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "测试用户".to_string(),
|
|
||||||
text: "你好".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ali_tts_handler_custom_config() {
|
|
||||||
let handler = TtsHandler::new_ali_tts(
|
|
||||||
"test_api_key".to_string(),
|
|
||||||
"qwen3-tts-flash".to_string(),
|
|
||||||
"Chelsie".to_string(),
|
|
||||||
Some("English".to_string()),
|
|
||||||
Some(0.8),
|
|
||||||
);
|
|
||||||
|
|
||||||
let msg = BiliMessage::Danmu {
|
|
||||||
user: "test_user".to_string(),
|
|
||||||
text: "hello world".to_string(),
|
|
||||||
};
|
|
||||||
let context = EventContext {
|
|
||||||
cookies: None,
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ali_tts_request_serialization() {
|
|
||||||
let request = AliTtsRequest {
|
|
||||||
model: "qwen3-tts-flash".to_string(),
|
|
||||||
input: AliTtsInput {
|
|
||||||
text: "你好世界".to_string(),
|
|
||||||
voice: "Cherry".to_string(),
|
|
||||||
language_type: Some("Chinese".to_string()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&request).unwrap();
|
|
||||||
assert!(json.contains("qwen3-tts-flash"));
|
|
||||||
assert!(json.contains("你好世界"));
|
|
||||||
assert!(json.contains("Cherry"));
|
|
||||||
assert!(json.contains("Chinese"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-624
@@ -1,624 +0,0 @@
|
|||||||
// src/tui/app.rs
|
|
||||||
//! TUI application state management
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
/// Maximum number of messages to keep in buffer
|
|
||||||
const MAX_MESSAGES: usize = 1000;
|
|
||||||
|
|
||||||
/// TUI Application state
|
|
||||||
pub struct TuiApp {
|
|
||||||
/// Shared message buffer (thread-safe)
|
|
||||||
pub message_buffer: Arc<Mutex<VecDeque<String>>>,
|
|
||||||
/// Current scroll offset (0 = bottom, 1 = one line up, etc.)
|
|
||||||
pub scroll_offset: usize,
|
|
||||||
/// Whether auto-scroll is enabled
|
|
||||||
pub auto_scroll: bool,
|
|
||||||
/// Current input text
|
|
||||||
pub input: String,
|
|
||||||
/// Cursor position in input
|
|
||||||
pub cursor_position: usize,
|
|
||||||
/// Room ID being monitored
|
|
||||||
pub room_id: String,
|
|
||||||
/// Whether to quit the application
|
|
||||||
pub should_quit: bool,
|
|
||||||
/// Shared online user count (thread-safe, updated from event handler)
|
|
||||||
pub online_count: Arc<AtomicU64>,
|
|
||||||
/// Whether to show raw event messages
|
|
||||||
pub show_raw: bool,
|
|
||||||
/// Shared log buffer for capturing log messages (thread-safe)
|
|
||||||
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
|
|
||||||
/// Whether to show the logs panel
|
|
||||||
pub show_logs: bool,
|
|
||||||
/// Scroll offset for logs panel (0 = bottom)
|
|
||||||
pub log_scroll_offset: usize,
|
|
||||||
/// Whether auto-scroll is enabled for logs panel
|
|
||||||
pub log_auto_scroll: bool,
|
|
||||||
/// Whether the help overlay is visible
|
|
||||||
pub show_help: bool,
|
|
||||||
/// Whether Vim-style visual selection is active
|
|
||||||
pub visual_mode: bool,
|
|
||||||
/// Frozen message snapshot used while visual mode is active
|
|
||||||
frozen_messages: Vec<String>,
|
|
||||||
/// Frozen log snapshot used while visual mode is active
|
|
||||||
frozen_logs: Vec<String>,
|
|
||||||
/// Rendered wrapped lines for the active pane
|
|
||||||
rendered_lines: Vec<String>,
|
|
||||||
/// First visible rendered line for the active pane
|
|
||||||
rendered_start_line: usize,
|
|
||||||
/// Visible height for the active pane
|
|
||||||
rendered_visible_height: usize,
|
|
||||||
/// Selection anchor in rendered line coordinates
|
|
||||||
visual_anchor: usize,
|
|
||||||
/// Current cursor in rendered line coordinates
|
|
||||||
visual_cursor: usize,
|
|
||||||
/// Current line cursor in normal pane navigation
|
|
||||||
pane_cursor: usize,
|
|
||||||
/// Whether the pane cursor has been initialized
|
|
||||||
pane_cursor_initialized: bool,
|
|
||||||
/// History of submitted input messages (oldest first)
|
|
||||||
input_history: Vec<String>,
|
|
||||||
/// Position when browsing input history; None means editing the live draft
|
|
||||||
history_index: Option<usize>,
|
|
||||||
/// Draft saved when we start browsing input history
|
|
||||||
history_draft: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TuiApp {
|
|
||||||
/// Create a new TUI application with shared message buffer
|
|
||||||
pub fn new(message_buffer: Arc<Mutex<VecDeque<String>>>, room_id: String) -> Self {
|
|
||||||
Self::with_online_count(message_buffer, room_id, Arc::new(AtomicU64::new(0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TUI application with shared message buffer and online count
|
|
||||||
pub fn with_online_count(
|
|
||||||
message_buffer: Arc<Mutex<VecDeque<String>>>,
|
|
||||||
room_id: String,
|
|
||||||
online_count: Arc<AtomicU64>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
message_buffer,
|
|
||||||
scroll_offset: 0,
|
|
||||||
auto_scroll: true,
|
|
||||||
input: String::new(),
|
|
||||||
cursor_position: 0,
|
|
||||||
room_id,
|
|
||||||
should_quit: false,
|
|
||||||
online_count,
|
|
||||||
show_raw: false,
|
|
||||||
log_buffer: Arc::new(Mutex::new(VecDeque::new())),
|
|
||||||
show_logs: false,
|
|
||||||
log_scroll_offset: 0,
|
|
||||||
log_auto_scroll: true,
|
|
||||||
show_help: false,
|
|
||||||
visual_mode: false,
|
|
||||||
frozen_messages: Vec::new(),
|
|
||||||
frozen_logs: Vec::new(),
|
|
||||||
rendered_lines: Vec::new(),
|
|
||||||
rendered_start_line: 0,
|
|
||||||
rendered_visible_height: 0,
|
|
||||||
visual_anchor: 0,
|
|
||||||
visual_cursor: 0,
|
|
||||||
pane_cursor: 0,
|
|
||||||
pane_cursor_initialized: false,
|
|
||||||
input_history: Vec::new(),
|
|
||||||
history_index: None,
|
|
||||||
history_draft: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current online count
|
|
||||||
pub fn get_online_count(&self) -> u64 {
|
|
||||||
self.online_count.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the online count (called from event handler)
|
|
||||||
pub fn set_online_count(online_count: &Arc<AtomicU64>, count: u64) {
|
|
||||||
online_count.store(count, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a message to the buffer (called from event handler)
|
|
||||||
pub fn add_message(buffer: &Arc<Mutex<VecDeque<String>>>, message: String) {
|
|
||||||
if let Ok(mut messages) = buffer.lock() {
|
|
||||||
messages.push_back(message);
|
|
||||||
while messages.len() > MAX_MESSAGES {
|
|
||||||
messages.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get messages for display (returns a copy of the buffer)
|
|
||||||
pub fn get_messages(&self) -> Vec<String> {
|
|
||||||
if self.visual_mode {
|
|
||||||
return self.frozen_messages.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(messages) = self.message_buffer.lock() {
|
|
||||||
messages.iter().cloned().collect()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of messages in buffer
|
|
||||||
pub fn message_count(&self) -> usize {
|
|
||||||
if let Ok(messages) = self.message_buffer.lock() {
|
|
||||||
messages.len()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll up (increase offset)
|
|
||||||
pub fn scroll_up(&mut self, amount: usize) {
|
|
||||||
let max_offset = self.message_count().saturating_sub(1);
|
|
||||||
self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
|
|
||||||
if self.scroll_offset > 0 {
|
|
||||||
self.auto_scroll = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll down (decrease offset)
|
|
||||||
pub fn scroll_down(&mut self, amount: usize) {
|
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
|
|
||||||
if self.scroll_offset == 0 {
|
|
||||||
self.auto_scroll = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll to bottom
|
|
||||||
pub fn scroll_to_bottom(&mut self) {
|
|
||||||
self.scroll_offset = 0;
|
|
||||||
self.auto_scroll = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle character input
|
|
||||||
pub fn enter_char(&mut self, c: char) {
|
|
||||||
let byte_pos = self.byte_index();
|
|
||||||
self.input.insert(byte_pos, c);
|
|
||||||
self.cursor_position += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete character before cursor
|
|
||||||
pub fn delete_char(&mut self) {
|
|
||||||
if self.cursor_position > 0 {
|
|
||||||
let byte_pos = self.byte_index_at(self.cursor_position - 1);
|
|
||||||
self.input.remove(byte_pos);
|
|
||||||
self.cursor_position -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor left
|
|
||||||
pub fn move_cursor_left(&mut self) {
|
|
||||||
if self.cursor_position > 0 {
|
|
||||||
self.cursor_position -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor right
|
|
||||||
pub fn move_cursor_right(&mut self) {
|
|
||||||
let char_count = self.input.chars().count();
|
|
||||||
if self.cursor_position < char_count {
|
|
||||||
self.cursor_position += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn byte_index(&self) -> usize {
|
|
||||||
self.input
|
|
||||||
.char_indices()
|
|
||||||
.nth(self.cursor_position)
|
|
||||||
.map(|(idx, _)| idx)
|
|
||||||
.unwrap_or(self.input.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn byte_index_at(&self, char_pos: usize) -> usize {
|
|
||||||
self.input
|
|
||||||
.char_indices()
|
|
||||||
.nth(char_pos)
|
|
||||||
.map(|(idx, _)| idx)
|
|
||||||
.unwrap_or(self.input.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current input and clear it
|
|
||||||
pub fn take_input(&mut self) -> String {
|
|
||||||
let input = self.input.clone();
|
|
||||||
self.input.clear();
|
|
||||||
self.cursor_position = 0;
|
|
||||||
if !input.is_empty() && self.input_history.last() != Some(&input) {
|
|
||||||
self.input_history.push(input.clone());
|
|
||||||
}
|
|
||||||
self.history_index = None;
|
|
||||||
self.history_draft.clear();
|
|
||||||
input
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recall the previous (older) entry from input history into the input box
|
|
||||||
pub fn history_prev(&mut self) {
|
|
||||||
if self.input_history.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_index = match self.history_index {
|
|
||||||
None => {
|
|
||||||
self.history_draft = self.input.clone();
|
|
||||||
self.input_history.len() - 1
|
|
||||||
}
|
|
||||||
Some(0) => 0,
|
|
||||||
Some(i) => i - 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.history_index = Some(new_index);
|
|
||||||
self.input = self.input_history[new_index].clone();
|
|
||||||
self.cursor_position = self.input.chars().count();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recall the next (newer) entry from input history, or restore the draft
|
|
||||||
pub fn history_next(&mut self) {
|
|
||||||
let Some(index) = self.history_index else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if index + 1 < self.input_history.len() {
|
|
||||||
let new_index = index + 1;
|
|
||||||
self.history_index = Some(new_index);
|
|
||||||
self.input = self.input_history[new_index].clone();
|
|
||||||
} else {
|
|
||||||
self.history_index = None;
|
|
||||||
self.input = std::mem::take(&mut self.history_draft);
|
|
||||||
}
|
|
||||||
self.cursor_position = self.input.chars().count();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quit the application
|
|
||||||
pub fn quit(&mut self) {
|
|
||||||
self.should_quit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle raw message visibility
|
|
||||||
pub fn toggle_show_raw(&mut self) {
|
|
||||||
self.show_raw = !self.show_raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle logs panel visibility
|
|
||||||
pub fn toggle_show_logs(&mut self) {
|
|
||||||
self.show_logs = !self.show_logs;
|
|
||||||
self.show_help = false;
|
|
||||||
if self.show_logs {
|
|
||||||
self.log_scroll_offset = 0;
|
|
||||||
self.log_auto_scroll = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle help overlay visibility
|
|
||||||
pub fn toggle_help(&mut self) {
|
|
||||||
self.show_help = !self.show_help;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of log messages in buffer
|
|
||||||
pub fn log_message_count(&self) -> usize {
|
|
||||||
if let Ok(logs) = self.log_buffer.lock() {
|
|
||||||
logs.len()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll logs up (increase offset)
|
|
||||||
pub fn log_scroll_up(&mut self, amount: usize) {
|
|
||||||
let max_offset = self.log_message_count().saturating_sub(1);
|
|
||||||
self.log_scroll_offset = (self.log_scroll_offset + amount).min(max_offset);
|
|
||||||
if self.log_scroll_offset > 0 {
|
|
||||||
self.log_auto_scroll = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll logs down (decrease offset)
|
|
||||||
pub fn log_scroll_down(&mut self, amount: usize) {
|
|
||||||
self.log_scroll_offset = self.log_scroll_offset.saturating_sub(amount);
|
|
||||||
if self.log_scroll_offset == 0 {
|
|
||||||
self.log_auto_scroll = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll logs to bottom
|
|
||||||
pub fn log_scroll_to_bottom(&mut self) {
|
|
||||||
self.log_scroll_offset = 0;
|
|
||||||
self.log_auto_scroll = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the log buffer (used to share with the TuiLogger)
|
|
||||||
pub fn set_log_buffer(&mut self, log_buffer: Arc<Mutex<VecDeque<String>>>) {
|
|
||||||
self.log_buffer = log_buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get log messages for display
|
|
||||||
pub fn get_log_messages(&self) -> Vec<String> {
|
|
||||||
if self.visual_mode {
|
|
||||||
return self.frozen_logs.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(logs) = self.log_buffer.lock() {
|
|
||||||
logs.iter().cloned().collect()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store the current wrapped-line model for the active pane
|
|
||||||
pub fn set_rendered_lines(
|
|
||||||
&mut self,
|
|
||||||
lines: Vec<String>,
|
|
||||||
start_line: usize,
|
|
||||||
visible_height: usize,
|
|
||||||
) -> usize {
|
|
||||||
let old_total_lines = self.rendered_lines.len();
|
|
||||||
let old_start_line = self.rendered_start_line;
|
|
||||||
let old_visible_height = self.rendered_visible_height.max(1);
|
|
||||||
let old_last_visible = old_start_line
|
|
||||||
.saturating_add(old_visible_height.saturating_sub(1))
|
|
||||||
.min(old_total_lines.saturating_sub(1));
|
|
||||||
let was_following_bottom = self.pane_cursor_initialized
|
|
||||||
&& old_total_lines > 0
|
|
||||||
&& self.pane_cursor >= old_last_visible
|
|
||||||
&& self.active_auto_scroll();
|
|
||||||
|
|
||||||
self.rendered_lines = lines;
|
|
||||||
self.rendered_start_line = start_line;
|
|
||||||
self.rendered_visible_height = visible_height.max(1);
|
|
||||||
|
|
||||||
if self.visual_mode {
|
|
||||||
let max_index = self.rendered_lines.len().saturating_sub(1);
|
|
||||||
self.visual_anchor = self.visual_anchor.min(max_index);
|
|
||||||
self.visual_cursor = self.visual_cursor.min(max_index);
|
|
||||||
self.sync_visual_view();
|
|
||||||
} else if self.rendered_lines.is_empty() {
|
|
||||||
self.pane_cursor = 0;
|
|
||||||
self.pane_cursor_initialized = false;
|
|
||||||
} else {
|
|
||||||
let max_index = self.rendered_lines.len() - 1;
|
|
||||||
if !self.pane_cursor_initialized || was_following_bottom {
|
|
||||||
self.pane_cursor = self.initial_visible_cursor();
|
|
||||||
self.pane_cursor_initialized = true;
|
|
||||||
} else {
|
|
||||||
self.pane_cursor = self.pane_cursor.min(max_index);
|
|
||||||
self.sync_pane_view();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.rendered_start_line
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter Vim-style visual selection mode
|
|
||||||
pub fn enter_visual_mode(&mut self) {
|
|
||||||
if self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.frozen_messages = self
|
|
||||||
.message_buffer
|
|
||||||
.lock()
|
|
||||||
.map(|messages| messages.iter().cloned().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
self.frozen_logs = self
|
|
||||||
.log_buffer
|
|
||||||
.lock()
|
|
||||||
.map(|logs| logs.iter().cloned().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
self.show_help = false;
|
|
||||||
self.visual_mode = true;
|
|
||||||
self.visual_cursor = self.pane_cursor;
|
|
||||||
self.visual_anchor = self.visual_cursor;
|
|
||||||
self.sync_visual_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exit visual selection mode and resume live updates
|
|
||||||
pub fn exit_visual_mode(&mut self) {
|
|
||||||
self.visual_mode = false;
|
|
||||||
self.frozen_messages.clear();
|
|
||||||
self.frozen_logs.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle visual selection mode
|
|
||||||
pub fn toggle_visual_mode(&mut self) {
|
|
||||||
if self.visual_mode {
|
|
||||||
self.exit_visual_mode();
|
|
||||||
} else {
|
|
||||||
self.enter_visual_mode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the normal pane cursor up
|
|
||||||
pub fn pane_up(&mut self, amount: usize) {
|
|
||||||
if self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.pane_cursor = self.pane_cursor.saturating_sub(amount);
|
|
||||||
self.sync_pane_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the normal pane cursor down
|
|
||||||
pub fn pane_down(&mut self, amount: usize) {
|
|
||||||
if self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let max_index = self.rendered_lines.len().saturating_sub(1);
|
|
||||||
self.pane_cursor = (self.pane_cursor + amount).min(max_index);
|
|
||||||
self.sync_pane_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump the normal pane cursor to the first line
|
|
||||||
pub fn pane_top(&mut self) {
|
|
||||||
if self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.pane_cursor = 0;
|
|
||||||
self.sync_pane_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump the normal pane cursor to the last line
|
|
||||||
pub fn pane_bottom(&mut self) {
|
|
||||||
if self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.pane_cursor = self.rendered_lines.len() - 1;
|
|
||||||
self.sync_pane_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the visual cursor up
|
|
||||||
pub fn visual_up(&mut self, amount: usize) {
|
|
||||||
if !self.visual_mode {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.visual_cursor = self.visual_cursor.saturating_sub(amount);
|
|
||||||
self.sync_visual_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the visual cursor down
|
|
||||||
pub fn visual_down(&mut self, amount: usize) {
|
|
||||||
if !self.visual_mode {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let max_index = self.rendered_lines.len().saturating_sub(1);
|
|
||||||
self.visual_cursor = (self.visual_cursor + amount).min(max_index);
|
|
||||||
self.sync_visual_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump the visual cursor to the first line
|
|
||||||
pub fn visual_top(&mut self) {
|
|
||||||
if !self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.visual_cursor = 0;
|
|
||||||
self.sync_visual_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump the visual cursor to the last line
|
|
||||||
pub fn visual_bottom(&mut self) {
|
|
||||||
if !self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.visual_cursor = self.rendered_lines.len() - 1;
|
|
||||||
self.sync_visual_view();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the selected rendered-line range
|
|
||||||
pub fn visual_range(&self) -> Option<(usize, usize)> {
|
|
||||||
if !self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((
|
|
||||||
self.visual_anchor.min(self.visual_cursor),
|
|
||||||
self.visual_anchor.max(self.visual_cursor),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current visual cursor position
|
|
||||||
pub fn visual_cursor(&self) -> Option<usize> {
|
|
||||||
if self.visual_mode && !self.rendered_lines.is_empty() {
|
|
||||||
Some(self.visual_cursor)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current pane cursor position
|
|
||||||
pub fn pane_cursor(&self) -> Option<usize> {
|
|
||||||
if !self.visual_mode && self.pane_cursor_initialized && !self.rendered_lines.is_empty() {
|
|
||||||
Some(self.pane_cursor)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the selected text from the current visual range
|
|
||||||
pub fn selected_text(&self) -> Option<String> {
|
|
||||||
let (start, end) = self.visual_range()?;
|
|
||||||
Some(self.rendered_lines[start..=end].join("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn initial_visible_cursor(&self) -> usize {
|
|
||||||
if self.rendered_lines.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let last_visible = self
|
|
||||||
.rendered_start_line
|
|
||||||
.saturating_add(self.rendered_visible_height.saturating_sub(1));
|
|
||||||
last_visible.min(self.rendered_lines.len() - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_pane_view(&mut self) {
|
|
||||||
if self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_lines = self.rendered_lines.len();
|
|
||||||
let visible_height = self.rendered_visible_height.max(1).min(total_lines);
|
|
||||||
let max_start = total_lines.saturating_sub(visible_height);
|
|
||||||
let mut start_line = self.rendered_start_line.min(max_start);
|
|
||||||
|
|
||||||
if self.pane_cursor < start_line {
|
|
||||||
start_line = self.pane_cursor;
|
|
||||||
} else if self.pane_cursor >= start_line + visible_height {
|
|
||||||
start_line = self.pane_cursor + 1 - visible_height;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.rendered_start_line = start_line;
|
|
||||||
|
|
||||||
let scroll_offset = total_lines.saturating_sub(visible_height + start_line);
|
|
||||||
if self.show_logs {
|
|
||||||
self.log_scroll_offset = scroll_offset;
|
|
||||||
self.log_auto_scroll = scroll_offset == 0;
|
|
||||||
} else {
|
|
||||||
self.scroll_offset = scroll_offset;
|
|
||||||
self.auto_scroll = scroll_offset == 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_auto_scroll(&self) -> bool {
|
|
||||||
if self.show_logs {
|
|
||||||
self.log_auto_scroll
|
|
||||||
} else {
|
|
||||||
self.auto_scroll
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_visual_view(&mut self) {
|
|
||||||
if !self.visual_mode || self.rendered_lines.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_lines = self.rendered_lines.len();
|
|
||||||
let visible_height = self.rendered_visible_height.max(1).min(total_lines);
|
|
||||||
let max_start = total_lines.saturating_sub(visible_height);
|
|
||||||
let mut start_line = self.rendered_start_line.min(max_start);
|
|
||||||
|
|
||||||
if self.visual_cursor < start_line {
|
|
||||||
start_line = self.visual_cursor;
|
|
||||||
} else if self.visual_cursor >= start_line + visible_height {
|
|
||||||
start_line = self.visual_cursor + 1 - visible_height;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.rendered_start_line = start_line;
|
|
||||||
|
|
||||||
let scroll_offset = total_lines.saturating_sub(visible_height + start_line);
|
|
||||||
if self.show_logs {
|
|
||||||
self.log_scroll_offset = scroll_offset;
|
|
||||||
self.log_auto_scroll = scroll_offset == 0;
|
|
||||||
} else {
|
|
||||||
self.scroll_offset = scroll_offset;
|
|
||||||
self.auto_scroll = scroll_offset == 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-254
@@ -1,254 +0,0 @@
|
|||||||
// src/tui/event.rs
|
|
||||||
//! Event handling and main TUI loop
|
|
||||||
|
|
||||||
use crate::tui::app::TuiApp;
|
|
||||||
use crate::tui::ui;
|
|
||||||
use arboard::Clipboard;
|
|
||||||
use crossterm::{
|
|
||||||
event::{self, Event, KeyCode, KeyModifiers},
|
|
||||||
execute,
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
|
||||||
use std::io;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Run the TUI application
|
|
||||||
pub fn run_tui<F>(mut app: TuiApp, mut on_message: F) -> io::Result<()>
|
|
||||||
where
|
|
||||||
F: FnMut(String),
|
|
||||||
{
|
|
||||||
enable_raw_mode()?;
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let mut terminal = Terminal::new(backend)?;
|
|
||||||
|
|
||||||
let result = run_app(&mut terminal, &mut app, &mut on_message);
|
|
||||||
|
|
||||||
disable_raw_mode()?;
|
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
||||||
terminal.show_cursor()?;
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_app<F>(
|
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
||||||
app: &mut TuiApp,
|
|
||||||
on_message: &mut F,
|
|
||||||
) -> io::Result<()>
|
|
||||||
where
|
|
||||||
F: FnMut(String),
|
|
||||||
{
|
|
||||||
let mut needs_redraw = true;
|
|
||||||
let mut clipboard = Clipboard::new().ok();
|
|
||||||
let mut last_message_count = app.message_count();
|
|
||||||
let mut last_log_count = app.log_message_count();
|
|
||||||
let mut last_online_count = app.get_online_count();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let message_count = app.message_count();
|
|
||||||
let log_count = app.log_message_count();
|
|
||||||
let online_count = app.get_online_count();
|
|
||||||
|
|
||||||
if !app.visual_mode
|
|
||||||
&& (message_count != last_message_count
|
|
||||||
|| log_count != last_log_count
|
|
||||||
|| online_count != last_online_count)
|
|
||||||
{
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
last_message_count = message_count;
|
|
||||||
last_log_count = log_count;
|
|
||||||
last_online_count = online_count;
|
|
||||||
|
|
||||||
if needs_redraw {
|
|
||||||
terminal.draw(|f| ui::render(f, app))?;
|
|
||||||
needs_redraw = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(16))? {
|
|
||||||
if let Event::Key(key) = event::read()? {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
app.quit();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
app.toggle_visual_mode();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
if !app.visual_mode {
|
|
||||||
app.toggle_help();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
if !app.visual_mode {
|
|
||||||
app.toggle_show_raw();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
if !app.visual_mode {
|
|
||||||
app.toggle_show_logs();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
if app.visual_mode {
|
|
||||||
app.exit_visual_mode();
|
|
||||||
} else if app.show_help {
|
|
||||||
app.show_help = false;
|
|
||||||
} else if app.show_logs {
|
|
||||||
app.toggle_show_logs();
|
|
||||||
} else {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ if app.show_help => {}
|
|
||||||
|
|
||||||
_ if app.visual_mode => {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('k') | KeyCode::Up => app.visual_up(1),
|
|
||||||
KeyCode::Char('j') | KeyCode::Down => app.visual_down(1),
|
|
||||||
KeyCode::PageUp => app.visual_up(10),
|
|
||||||
KeyCode::PageDown => app.visual_down(10),
|
|
||||||
KeyCode::Char('g') | KeyCode::Home => app.visual_top(),
|
|
||||||
KeyCode::Char('G') | KeyCode::End => app.visual_bottom(),
|
|
||||||
KeyCode::Char('y') => {
|
|
||||||
copy_selection(app, clipboard.as_mut())?;
|
|
||||||
app.exit_visual_mode();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ if app.show_logs => match key.code {
|
|
||||||
KeyCode::Up => {
|
|
||||||
app.pane_up(1);
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Down => {
|
|
||||||
app.pane_down(1);
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => {
|
|
||||||
app.pane_up(10);
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::PageDown => {
|
|
||||||
app.pane_down(10);
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
app.pane_top();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
app.pane_bottom();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Home => {
|
|
||||||
app.pane_top();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::End => {
|
|
||||||
app.pane_bottom();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
app.enter_char(c);
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
app.delete_char();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let input = app.take_input();
|
|
||||||
if !input.is_empty() {
|
|
||||||
if input == "/quit" || input == "/exit" {
|
|
||||||
app.quit();
|
|
||||||
} else {
|
|
||||||
on_message(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Up => {
|
|
||||||
app.history_prev();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Down => {
|
|
||||||
app.history_next();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
app.move_cursor_left();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
app.move_cursor_right();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => {
|
|
||||||
app.pane_up(10);
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::PageDown => {
|
|
||||||
app.pane_down(10);
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
app.pane_top();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
app.pane_bottom();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::Home => {
|
|
||||||
app.cursor_position = 0;
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
KeyCode::End => {
|
|
||||||
app.cursor_position = app.input.chars().count();
|
|
||||||
needs_redraw = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.should_quit {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_selection(app: &TuiApp, clipboard: Option<&mut Clipboard>) -> io::Result<()> {
|
|
||||||
let Some(text) = app.selected_text() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(clipboard) = clipboard else {
|
|
||||||
return Err(io::Error::other("clipboard is unavailable"));
|
|
||||||
};
|
|
||||||
|
|
||||||
clipboard.set_text(text).map_err(io::Error::other)
|
|
||||||
}
|
|
||||||
Vendored
-73
@@ -1,73 +0,0 @@
|
|||||||
// src/tui/logger.rs
|
|
||||||
//! Custom logger that captures log messages into a shared buffer for TUI display
|
|
||||||
|
|
||||||
use log::{Log, Metadata, Record};
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
/// Maximum number of log messages to keep in buffer
|
|
||||||
const MAX_LOG_MESSAGES: usize = 1000;
|
|
||||||
|
|
||||||
/// A logger that writes log messages to a shared buffer for TUI display.
|
|
||||||
/// It also optionally forwards to env_logger for file/stderr output.
|
|
||||||
pub struct TuiLogger {
|
|
||||||
buffer: Arc<Mutex<VecDeque<String>>>,
|
|
||||||
level: log::LevelFilter,
|
|
||||||
start_time: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TuiLogger {
|
|
||||||
/// Create a new TuiLogger with the given shared buffer and level filter.
|
|
||||||
pub fn new(buffer: Arc<Mutex<VecDeque<String>>>, level: log::LevelFilter) -> Self {
|
|
||||||
Self {
|
|
||||||
buffer,
|
|
||||||
level,
|
|
||||||
start_time: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize this logger as the global logger.
|
|
||||||
/// Returns the shared buffer so it can be passed to TuiApp.
|
|
||||||
pub fn init(level: log::LevelFilter) -> Arc<Mutex<VecDeque<String>>> {
|
|
||||||
let buffer = Arc::new(Mutex::new(VecDeque::new()));
|
|
||||||
let logger = TuiLogger::new(Arc::clone(&buffer), level);
|
|
||||||
log::set_boxed_logger(Box::new(logger)).expect("Failed to set TuiLogger");
|
|
||||||
log::set_max_level(level);
|
|
||||||
buffer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Log for TuiLogger {
|
|
||||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
|
||||||
metadata.level() <= self.level
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log(&self, record: &Record) {
|
|
||||||
if !self.enabled(record.metadata()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let elapsed = self.start_time.elapsed();
|
|
||||||
let secs = elapsed.as_secs();
|
|
||||||
let mins = secs / 60;
|
|
||||||
let hours = mins / 60;
|
|
||||||
let timestamp = format!("{:02}:{:02}:{:02}", hours, mins % 60, secs % 60);
|
|
||||||
let msg = format!(
|
|
||||||
"[{}] [{}] [{}] {}",
|
|
||||||
timestamp,
|
|
||||||
record.level(),
|
|
||||||
record.target(),
|
|
||||||
record.args()
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Ok(mut buf) = self.buffer.lock() {
|
|
||||||
buf.push_back(msg);
|
|
||||||
while buf.len() > MAX_LOG_MESSAGES {
|
|
||||||
buf.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(&self) {}
|
|
||||||
}
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
// src/tui/mod.rs
|
|
||||||
//! TUI module for displaying messages and handling user input
|
|
||||||
|
|
||||||
pub mod app;
|
|
||||||
pub mod event;
|
|
||||||
pub mod logger;
|
|
||||||
pub mod ui;
|
|
||||||
|
|
||||||
pub use app::TuiApp;
|
|
||||||
pub use event::run_tui;
|
|
||||||
pub use logger::TuiLogger;
|
|
||||||
Vendored
-355
@@ -1,355 +0,0 @@
|
|||||||
// src/tui/ui.rs
|
|
||||||
//! UI rendering logic for the TUI
|
|
||||||
|
|
||||||
use crate::tui::app::TuiApp;
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
|
||||||
style::{Color, Style},
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, app: &mut TuiApp) {
|
|
||||||
if app.show_logs {
|
|
||||||
render_logs_panel(f, app, f.area());
|
|
||||||
} else {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Percentage(90), Constraint::Percentage(10)])
|
|
||||||
.split(f.area());
|
|
||||||
|
|
||||||
render_message_list(f, app, chunks[0]);
|
|
||||||
render_input_box(f, app, chunks[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.show_help {
|
|
||||||
render_help_overlay(f, app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_message_list(f: &mut Frame, app: &mut TuiApp, area: Rect) {
|
|
||||||
let messages = app.get_messages();
|
|
||||||
let inner_width = area.width.saturating_sub(2) as usize;
|
|
||||||
let visible_height = area.height.saturating_sub(2) as usize;
|
|
||||||
let mut all_lines = Vec::new();
|
|
||||||
|
|
||||||
for msg in &messages {
|
|
||||||
if !app.show_raw && msg.starts_with("[Raw]") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let style = get_message_style(msg);
|
|
||||||
for line_text in wrap_text(msg, inner_width) {
|
|
||||||
all_lines.push((line_text, style));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_lines = all_lines.len();
|
|
||||||
let start_line = if app.auto_scroll {
|
|
||||||
total_lines.saturating_sub(visible_height)
|
|
||||||
} else {
|
|
||||||
total_lines.saturating_sub(visible_height + app.scroll_offset)
|
|
||||||
};
|
|
||||||
|
|
||||||
let start_line = app.set_rendered_lines(
|
|
||||||
all_lines.iter().map(|(text, _)| text.clone()).collect(),
|
|
||||||
start_line,
|
|
||||||
visible_height,
|
|
||||||
);
|
|
||||||
|
|
||||||
let visible_lines = all_lines
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.skip(start_line)
|
|
||||||
.take(visible_height)
|
|
||||||
.map(|(idx, (line_text, style))| {
|
|
||||||
Line::from(Span::styled(
|
|
||||||
line_text,
|
|
||||||
style_for_line(app, idx, style, Color::Blue),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let scroll_indicator = if app.visual_mode {
|
|
||||||
"VISUAL | j/k move | g/G jump | y copy | Esc cancel"
|
|
||||||
} else if app.pane_cursor().is_some() {
|
|
||||||
"Up/Down history | PgUp/Dn scroll | Ctrl+Y select"
|
|
||||||
} else if app.auto_scroll {
|
|
||||||
"Auto-scroll"
|
|
||||||
} else {
|
|
||||||
"Paused - PgUp/Dn to scroll"
|
|
||||||
};
|
|
||||||
|
|
||||||
let online_count = app.get_online_count();
|
|
||||||
let online_display = if online_count > 0 {
|
|
||||||
format!(" | Online: {}", online_count)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let raw_indicator = if app.show_raw { "Raw:ON" } else { "Raw:OFF" };
|
|
||||||
let title = format!(
|
|
||||||
" Room {}{} | {} | {} ",
|
|
||||||
app.room_id, online_display, scroll_indicator, raw_indicator
|
|
||||||
);
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(visible_lines)
|
|
||||||
.block(Block::default().borders(Borders::ALL).title(title))
|
|
||||||
.wrap(Wrap { trim: false });
|
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_message_style(msg: &str) -> Style {
|
|
||||||
if msg.starts_with("[Danmu]") {
|
|
||||||
Style::default().fg(Color::Cyan)
|
|
||||||
} else if msg.starts_with("[Gift]") {
|
|
||||||
Style::default().fg(Color::Yellow)
|
|
||||||
} else if msg.starts_with("[Raw]") {
|
|
||||||
Style::default().fg(Color::Magenta)
|
|
||||||
} else if msg.starts_with("[Unsupported") {
|
|
||||||
Style::default().fg(Color::DarkGray)
|
|
||||||
} else if msg.starts_with("[System]") {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
|
|
||||||
if max_width == 0 {
|
|
||||||
return vec![text.to_string()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
let mut current_line = String::new();
|
|
||||||
let mut current_width = 0;
|
|
||||||
|
|
||||||
for ch in text.chars() {
|
|
||||||
let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
|
|
||||||
|
|
||||||
if current_width + char_width > max_width && !current_line.is_empty() {
|
|
||||||
lines.push(current_line);
|
|
||||||
current_line = String::new();
|
|
||||||
current_width = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
current_line.push(ch);
|
|
||||||
current_width += char_width;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !current_line.is_empty() {
|
|
||||||
lines.push(current_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
if lines.is_empty() {
|
|
||||||
lines.push(String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_logs_panel(f: &mut Frame, app: &mut TuiApp, area: Rect) {
|
|
||||||
let logs = app.get_log_messages();
|
|
||||||
let inner_width = area.width.saturating_sub(2) as usize;
|
|
||||||
let visible_height = area.height.saturating_sub(2) as usize;
|
|
||||||
let mut all_lines = Vec::new();
|
|
||||||
|
|
||||||
for log_msg in &logs {
|
|
||||||
let style = get_log_style(log_msg);
|
|
||||||
for line_text in wrap_text(log_msg, inner_width) {
|
|
||||||
all_lines.push((line_text, style));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_lines = all_lines.len();
|
|
||||||
let start_line = if app.log_auto_scroll {
|
|
||||||
total_lines.saturating_sub(visible_height)
|
|
||||||
} else {
|
|
||||||
total_lines.saturating_sub(visible_height + app.log_scroll_offset)
|
|
||||||
};
|
|
||||||
|
|
||||||
let start_line = app.set_rendered_lines(
|
|
||||||
all_lines.iter().map(|(text, _)| text.clone()).collect(),
|
|
||||||
start_line,
|
|
||||||
visible_height,
|
|
||||||
);
|
|
||||||
|
|
||||||
let visible_lines = all_lines
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.skip(start_line)
|
|
||||||
.take(visible_height)
|
|
||||||
.map(|(idx, (line_text, style))| {
|
|
||||||
Line::from(Span::styled(
|
|
||||||
line_text,
|
|
||||||
style_for_line(app, idx, style, Color::LightBlue),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let scroll_indicator = if app.visual_mode {
|
|
||||||
"VISUAL | j/k move | g/G jump | y copy | Esc cancel"
|
|
||||||
} else if app.pane_cursor().is_some() {
|
|
||||||
"CURSOR | Up/Down move | Ctrl+Y visual from cursor"
|
|
||||||
} else if app.log_auto_scroll {
|
|
||||||
"Auto-scroll"
|
|
||||||
} else {
|
|
||||||
"Paused"
|
|
||||||
};
|
|
||||||
|
|
||||||
let title = format!(
|
|
||||||
" Logs ({} entries) | {} | Ctrl+Y: visual | Ctrl+H: help | Ctrl+L: close ",
|
|
||||||
logs.len(),
|
|
||||||
scroll_indicator
|
|
||||||
);
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(visible_lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(title)
|
|
||||||
.border_style(Style::default().fg(Color::LightBlue)),
|
|
||||||
)
|
|
||||||
.wrap(Wrap { trim: false });
|
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_log_style(msg: &str) -> Style {
|
|
||||||
if msg.contains("[ERROR]") {
|
|
||||||
Style::default().fg(Color::Red)
|
|
||||||
} else if msg.contains("[WARN]") {
|
|
||||||
Style::default().fg(Color::Yellow)
|
|
||||||
} else if msg.contains("[INFO]") {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
} else if msg.contains("[DEBUG]") || msg.contains("[TRACE]") {
|
|
||||||
Style::default().fg(Color::DarkGray)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_input_box(f: &mut Frame, app: &TuiApp, area: Rect) {
|
|
||||||
let input_text = format!("> {}", app.input);
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(input_text.as_str())
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(" Input (Up/Down: history | Ctrl+Y: visual | Ctrl+H: help | Ctrl+C: exit) ")
|
|
||||||
.border_style(Style::default().fg(Color::Green)),
|
|
||||||
)
|
|
||||||
.style(Style::default());
|
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
|
|
||||||
let text_before_cursor: String = app.input.chars().take(app.cursor_position).collect();
|
|
||||||
let display_width = text_before_cursor.width();
|
|
||||||
let cursor_x = area.x + 1 + 2 + display_width as u16;
|
|
||||||
let cursor_y = area.y + 1;
|
|
||||||
|
|
||||||
if !app.visual_mode && cursor_x < area.x + area.width.saturating_sub(1) {
|
|
||||||
f.set_cursor_position((cursor_x, cursor_y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_help_overlay(f: &mut Frame, app: &TuiApp) {
|
|
||||||
let area = centered_rect(72, 72, f.area());
|
|
||||||
let lines = if app.show_logs {
|
|
||||||
vec![
|
|
||||||
Line::from("Key Map"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("Ctrl+H Toggle this help"),
|
|
||||||
Line::from("Up/Down Pick start line"),
|
|
||||||
Line::from("Ctrl+Y Enter visual mode from cursor"),
|
|
||||||
Line::from("j/k Move visual selection"),
|
|
||||||
Line::from("g / G Jump to top or bottom"),
|
|
||||||
Line::from("y Copy selected lines"),
|
|
||||||
Line::from("Esc Close help, cancel visual, or close logs"),
|
|
||||||
Line::from("Up/Down Scroll logs normally"),
|
|
||||||
Line::from("PgUp/Dn Scroll faster"),
|
|
||||||
Line::from("Home/End Jump to top or bottom"),
|
|
||||||
Line::from("Ctrl+C Exit app"),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
vec![
|
|
||||||
Line::from("Key Map"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("Enter Send input"),
|
|
||||||
Line::from("Up/Down Browse sent-input history"),
|
|
||||||
Line::from("Ctrl+H Toggle this help"),
|
|
||||||
Line::from("Ctrl+Y Enter visual mode (scroll/select)"),
|
|
||||||
Line::from("j/k Move visual selection"),
|
|
||||||
Line::from("g / G Jump to top or bottom"),
|
|
||||||
Line::from("y Copy selected lines"),
|
|
||||||
Line::from("Ctrl+R Toggle raw messages"),
|
|
||||||
Line::from("Ctrl+L Toggle logs panel"),
|
|
||||||
Line::from("PgUp/Dn Scroll messages"),
|
|
||||||
Line::from("Left/Right Move input cursor"),
|
|
||||||
Line::from("Home/End Move input cursor"),
|
|
||||||
Line::from("Ctrl+Home Jump to top"),
|
|
||||||
Line::from("Ctrl+End Jump to bottom"),
|
|
||||||
Line::from("Esc Close help, cancel visual, or quit"),
|
|
||||||
Line::from("Ctrl+C Exit app"),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
let title = if app.show_logs {
|
|
||||||
" Help - Logs "
|
|
||||||
} else {
|
|
||||||
" Help - Messages "
|
|
||||||
};
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(title)
|
|
||||||
.border_style(Style::default().fg(Color::Yellow)),
|
|
||||||
)
|
|
||||||
.wrap(Wrap { trim: false });
|
|
||||||
|
|
||||||
f.render_widget(Clear, area);
|
|
||||||
f.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
|
||||||
let vertical = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage((100 - percent_y) / 2),
|
|
||||||
Constraint::Percentage(percent_y),
|
|
||||||
Constraint::Percentage((100 - percent_y) / 2),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage((100 - percent_x) / 2),
|
|
||||||
Constraint::Percentage(percent_x),
|
|
||||||
Constraint::Percentage((100 - percent_x) / 2),
|
|
||||||
])
|
|
||||||
.split(vertical[1])[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn style_for_line(app: &TuiApp, idx: usize, base: Style, cursor_color: Color) -> Style {
|
|
||||||
if let Some((start, end)) = app.visual_range() {
|
|
||||||
if Some(idx) == app.visual_cursor() {
|
|
||||||
return base.bg(cursor_color).fg(Color::Black);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start..=end).contains(&idx) {
|
|
||||||
return base.bg(Color::DarkGray).fg(Color::White);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if Some(idx) == app.pane_cursor() {
|
|
||||||
return base.bg(Color::Rgb(40, 40, 40)).fg(Color::White);
|
|
||||||
}
|
|
||||||
|
|
||||||
base
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user