change core library to libilibili
This commit is contained in:
@@ -27,12 +27,3 @@ data/
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.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
|
||||
|
||||
`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
|
||||
|
||||
|
||||
+5
-6
@@ -6,10 +6,12 @@ COPY apps/overlay ./
|
||||
RUN npm run 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
|
||||
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/migrations ./apps/server-rust/migrations
|
||||
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
|
||||
|
||||
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
|
||||
# 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=web-build /app/apps/overlay/dist /app/web
|
||||
EXPOSE 9719
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request`
|
||||
点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
||||
|
||||
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在
|
||||
`vendor/blivedm`
|
||||
固定了保留原始 JSON、可观测发送失败和安全重连所需的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃,也让宿主在 socket 恢复失败后重建客户端。
|
||||
直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
|
||||
crate。它负责 Cookie/WBI
|
||||
API、直播 WebSocket 认证、心跳以及 JSON、zlib、brotli 包解析;本项目的 provider
|
||||
adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。crate 尚未建模的少量兼容命令只在 provider 边界解析,原始包不会进入组件协议。
|
||||
|
||||
## 文档索引
|
||||
|
||||
@@ -36,6 +37,14 @@
|
||||
|
||||
## 部署
|
||||
|
||||
源码目录需要保持为相邻 checkout,Compose 会把 `libilibili` 作为独立 BuildKit 上下文传入镜像:
|
||||
|
||||
```text
|
||||
source/
|
||||
├── libilibili/
|
||||
└── lxc-streamutils/
|
||||
```
|
||||
|
||||
先复制配置并生成独立密钥:
|
||||
|
||||
```sh
|
||||
@@ -82,8 +91,8 @@ git diff --check
|
||||
```
|
||||
|
||||
`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git
|
||||
ignore 会排除依赖、构建产物、第三方 vendor、PNG/SVG 和包含真实 Secret 的 `config.toml`。不要对
|
||||
`vendor/blivedm` 做无关的批量风格改写,以便继续审查上游补丁。
|
||||
ignore 会排除依赖、构建产物、PNG/SVG 和包含真实 Secret 的 `config.toml`。`libilibili`
|
||||
是独立 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"] }
|
||||
async-trait = "0.1"
|
||||
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"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
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"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -27,7 +27,7 @@ crate 导出。
|
||||
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
|
||||
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
|
||||
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
|
||||
- Bilibili listener 必须保持 20 秒心跳;socket 内部重连失败后由 adapter 重建完整客户端。
|
||||
- `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。
|
||||
- projection 无副作用;可靠业务动作必须使用幂等 handler。
|
||||
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
|
||||
- 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
|
||||
```
|
||||
|
||||
第三方 `vendor/blivedm` 不作为本项目风格重写目标;项目只维护保留原始 JSON 所需的小补丁。
|
||||
直播协议、WBI 与 WebSocket 解包由相邻的 `libilibili`
|
||||
crate 维护;本项目只测试强类型命令到领域事件的映射。
|
||||
|
||||
更多设计说明:
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ impl Config {
|
||||
legacy_obs_access_token: file.obs.access_token,
|
||||
legacy_overlay_defaults: overlay_defaults(file.overlay),
|
||||
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
|
||||
.gifts
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
//! Bilibili `blivedm_rs` adapter and raw-command normalization.
|
||||
//! Bilibili `libilibili` adapter and canonical event normalization.
|
||||
//!
|
||||
//! The adapter authenticates with a CookieCloud-derived cookie, refreshes gift
|
||||
//! and emoticon catalogs, and converts supported Bilibili commands into bounded
|
||||
//! domain payloads. Unknown commands expose only sanitized metadata—never the
|
||||
//! original unbounded packet or authentication material.
|
||||
//! and emoticon catalogs, drives the asynchronous live WebSocket heartbeat, and
|
||||
//! converts supported commands into bounded domain payloads. Commands not yet
|
||||
//! modeled by `libilibili` are interpreted only inside this provider boundary;
|
||||
//! unknown events expose sanitized command names, never raw packets or secrets.
|
||||
|
||||
use std::{
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use blivedm::client::{models::BiliMessage, websocket::BiliLiveClient};
|
||||
use futures_channel::mpsc as futures_mpsc;
|
||||
use libilibili::{
|
||||
Client, Credentials,
|
||||
websocket::{
|
||||
ComboSendMessage, DanmuMessage, GiftMessage, InteractWordMessage, LikeClickMessage,
|
||||
LiveCommand, LiveEvent as UpstreamLiveEvent, LiveWebSocket, SuperChatMessage,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -146,12 +148,21 @@ impl BilibiliProvider {
|
||||
viewer,
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
quantity,
|
||||
event_id,
|
||||
} => LiveEventPayload::Gift(GiftEvent {
|
||||
viewer,
|
||||
gift: gift_details(&self.gift_catalog, name, gift_id, battery, quantity).await,
|
||||
gift: gift_details(
|
||||
&self.gift_catalog,
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
quantity,
|
||||
)
|
||||
.await,
|
||||
quantity: quantity.max(1),
|
||||
source_event_id: event_id,
|
||||
}),
|
||||
@@ -159,12 +170,21 @@ impl BilibiliProvider {
|
||||
viewer,
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
quantity,
|
||||
combo_id,
|
||||
} => LiveEventPayload::GiftCombo(GiftComboEvent {
|
||||
viewer,
|
||||
gift: gift_details(&self.gift_catalog, name, gift_id, battery, quantity).await,
|
||||
gift: gift_details(
|
||||
&self.gift_catalog,
|
||||
name,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
quantity,
|
||||
)
|
||||
.await,
|
||||
quantity: quantity.max(1),
|
||||
combo_id,
|
||||
}),
|
||||
@@ -227,151 +247,149 @@ impl LiveProvider for BilibiliProvider {
|
||||
self.initial_catalogs(&context.room_id).await;
|
||||
self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone());
|
||||
|
||||
let (raw_sender, mut raw_receiver) = mpsc::channel::<ProviderEvent>(256);
|
||||
let cookie = self.cookie.to_string();
|
||||
let room_id = context.room_id.clone();
|
||||
let listener_cancel = cancel.clone();
|
||||
let listener_status = status.clone();
|
||||
let listener = tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
// A full-client retry loop complements blivedm_rs' short socket
|
||||
// reconnect loop. If all in-place attempts fail, recreating the
|
||||
// client refreshes the danmaku host and authentication token.
|
||||
while !listener_cancel.is_cancelled() {
|
||||
let (upstream_sender, mut upstream_receiver) = futures_mpsc::channel(256);
|
||||
let mut client =
|
||||
match BiliLiveClient::new_auto(Some(&cookie), &room_id, upstream_sender) {
|
||||
Ok(client) => client,
|
||||
let room_id = context
|
||||
.room_id
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Bilibili room ID must be an unsigned integer".to_owned())?;
|
||||
let client = Client::with_credentials(Credentials::from_cookie_header(&*self.cookie))
|
||||
.map_err(|error| format!("cannot create libilibili client: {error}"))?;
|
||||
|
||||
'provider: loop {
|
||||
let connection = tokio::select! {
|
||||
_ = cancel.cancelled() => break 'provider,
|
||||
result = LiveWebSocket::connect(&client, room_id) => result,
|
||||
};
|
||||
let mut socket = match connection {
|
||||
Ok(socket) => socket,
|
||||
Err(error) => {
|
||||
warn!(%error, %room_id, "Bilibili listener creation failed; retrying");
|
||||
let _ = listener_status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: room_id.clone(),
|
||||
connected: false,
|
||||
cookie_cloud: true,
|
||||
detail: format!("Bilibili connection failed; retrying: {error}"),
|
||||
});
|
||||
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
|
||||
warn!(%error, room_id = %context.room_id, "Bilibili connection failed; retrying");
|
||||
send_status(
|
||||
&status,
|
||||
&context,
|
||||
false,
|
||||
format!("Bilibili connection failed; retrying: {error}"),
|
||||
);
|
||||
if wait_for_retry(&cancel).await {
|
||||
break 'provider;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(error) = client.set_read_timeout(Some(Duration::from_secs(1))) {
|
||||
client.close();
|
||||
return Err(format!("cannot configure Bilibili socket timeout: {error}"));
|
||||
|
||||
// `connect` sends the authentication packet. An immediate heartbeat
|
||||
// matches the web client and starts the server's liveness window.
|
||||
if let Err(error) = socket.heartbeat().await {
|
||||
warn!(%error, room_id = %context.room_id, "initial Bilibili heartbeat failed");
|
||||
send_status(
|
||||
&status,
|
||||
&context,
|
||||
false,
|
||||
format!("Bilibili heartbeat failed; retrying: {error}"),
|
||||
);
|
||||
if wait_for_retry(&cancel).await {
|
||||
break 'provider;
|
||||
}
|
||||
// Bilibili closes otherwise healthy danmaku sockets after
|
||||
// roughly one minute without a heartbeat. The upstream CLI
|
||||
// sends one every 20 seconds; embedded users must do the same.
|
||||
if !client.send_auth() || !client.send_heart_beat() {
|
||||
client.close();
|
||||
let _ = listener_status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: room_id.clone(),
|
||||
connected: false,
|
||||
cookie_cloud: true,
|
||||
detail: "Bilibili authentication failed; retrying".into(),
|
||||
});
|
||||
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
|
||||
continue;
|
||||
}
|
||||
let _ = listener_status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: room_id.clone(),
|
||||
connected: true,
|
||||
cookie_cloud: true,
|
||||
detail: "Bilibili socket connected; waiting for live events".into(),
|
||||
});
|
||||
info!(%room_id, "Bilibili live socket connected and heartbeat started");
|
||||
send_status(
|
||||
&status,
|
||||
&context,
|
||||
true,
|
||||
"Bilibili socket connected; waiting for live events",
|
||||
);
|
||||
info!(room_id = %context.room_id, "libilibili live socket connected and heartbeat started");
|
||||
|
||||
let mut last_heartbeat = Instant::now();
|
||||
let mut next_heartbeat = tokio::time::Instant::now() + HEARTBEAT_INTERVAL;
|
||||
let mut received_event = false;
|
||||
let disconnect_error = loop {
|
||||
if listener_cancel.is_cancelled() {
|
||||
client.close();
|
||||
return Ok(());
|
||||
let until_heartbeat =
|
||||
next_heartbeat.saturating_duration_since(tokio::time::Instant::now());
|
||||
let upstream = tokio::select! {
|
||||
_ = cancel.cancelled() => break 'provider,
|
||||
result = tokio::time::timeout(until_heartbeat, socket.next_event()) => result,
|
||||
};
|
||||
let upstream = match upstream {
|
||||
Err(_) => {
|
||||
if let Err(error) = socket.heartbeat().await {
|
||||
break format!("heartbeat failed: {error}");
|
||||
}
|
||||
if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
|
||||
if !client.send_heart_beat() {
|
||||
break "heartbeat recovery failed".to_owned();
|
||||
next_heartbeat = tokio::time::Instant::now() + HEARTBEAT_INTERVAL;
|
||||
continue;
|
||||
}
|
||||
last_heartbeat = Instant::now();
|
||||
}
|
||||
if let Err(error) = client.receive() {
|
||||
Ok(Err(error)) => break error.to_string(),
|
||||
Ok(Ok(None)) => break "upstream closed the WebSocket".to_owned(),
|
||||
Ok(Ok(Some(event))) => event,
|
||||
};
|
||||
if let Some(error) = authentication_error(&upstream) {
|
||||
break error;
|
||||
}
|
||||
while let Ok(message) = upstream_receiver.try_recv() {
|
||||
let Some(message) = normalize(message) else {
|
||||
let Some(raw) = normalize_upstream(upstream) else {
|
||||
continue;
|
||||
};
|
||||
if !received_event {
|
||||
received_event = true;
|
||||
info!(%room_id, "Bilibili listener received its first live event");
|
||||
let _ = listener_status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: room_id.clone(),
|
||||
connected: true,
|
||||
cookie_cloud: true,
|
||||
detail: "Connected and receiving live events".into(),
|
||||
});
|
||||
}
|
||||
if raw_sender.blocking_send(message).is_err() {
|
||||
client.close();
|
||||
return Ok(());
|
||||
info!(room_id = %context.room_id, "libilibili listener received its first live event");
|
||||
send_status(
|
||||
&status,
|
||||
&context,
|
||||
true,
|
||||
"Connected and receiving live events",
|
||||
);
|
||||
}
|
||||
let event = Arc::new(self.enrich(&context, raw).await);
|
||||
if events.send(event).await.is_err() {
|
||||
break 'provider;
|
||||
}
|
||||
};
|
||||
|
||||
client.close();
|
||||
warn!(error = %disconnect_error, %room_id, "Bilibili listener disconnected; rebuilding client");
|
||||
let _ = listener_status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: room_id.clone(),
|
||||
connected: false,
|
||||
cookie_cloud: true,
|
||||
detail: format!("Bilibili disconnected; retrying: {disconnect_error}"),
|
||||
});
|
||||
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
value = raw_receiver.recv() => {
|
||||
let Some(value) = value else { break };
|
||||
let event = Arc::new(self.enrich(&context, value).await);
|
||||
if events.send(event).await.is_err() { break; }
|
||||
}
|
||||
warn!(error = %disconnect_error, room_id = %context.room_id, "Bilibili listener disconnected; rebuilding libilibili socket");
|
||||
send_status(
|
||||
&status,
|
||||
&context,
|
||||
false,
|
||||
format!("Bilibili disconnected; retrying: {disconnect_error}"),
|
||||
);
|
||||
if wait_for_retry(&cancel).await {
|
||||
break 'provider;
|
||||
}
|
||||
}
|
||||
cancel.cancel();
|
||||
listener
|
||||
.await
|
||||
.map_err(|error| format!("listener task failed: {error}"))??;
|
||||
let _ = status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: context.room_id,
|
||||
connected: false,
|
||||
cookie_cloud: true,
|
||||
detail: "Listener stopped".into(),
|
||||
});
|
||||
send_status(&status, &context, false, "Listener stopped");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking listener tasks cannot await a cancellation token. Sleeping in
|
||||
/// short slices keeps shutdown/reconfiguration latency bounded.
|
||||
fn wait_for_retry(cancel: &CancellationToken, duration: Duration) {
|
||||
let deadline = Instant::now() + duration;
|
||||
while !cancel.is_cancelled() {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(remaining.min(Duration::from_millis(200)));
|
||||
fn send_status(
|
||||
status: &watch::Sender<SourceStatus>,
|
||||
context: &SourceContext,
|
||||
connected: bool,
|
||||
detail: impl Into<String>,
|
||||
) {
|
||||
let _ = status.send(SourceStatus {
|
||||
source_id: context.source_id,
|
||||
room_id: context.room_id.clone(),
|
||||
connected,
|
||||
cookie_cloud: true,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns `true` when cancellation won the retry wait.
|
||||
async fn wait_for_retry(cancel: &CancellationToken) -> bool {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => true,
|
||||
_ = tokio::time::sleep(RECONNECT_BACKOFF) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn authentication_error(event: &UpstreamLiveEvent) -> Option<String> {
|
||||
let UpstreamLiveEvent::Auth(payload) = event else {
|
||||
return None;
|
||||
};
|
||||
let code = payload.get("code").and_then(Value::as_i64).unwrap_or(-1);
|
||||
(code != 0).then(|| format!("Bilibili WebSocket authentication rejected with code {code}"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EmoticonHint {
|
||||
text: String,
|
||||
@@ -397,6 +415,7 @@ enum ProviderEvent {
|
||||
viewer: PlatformViewer,
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
coin_type: Option<String>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
event_id: String,
|
||||
@@ -405,6 +424,7 @@ enum ProviderEvent {
|
||||
viewer: PlatformViewer,
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
coin_type: Option<String>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
combo_id: String,
|
||||
@@ -432,13 +452,249 @@ enum ProviderEvent {
|
||||
},
|
||||
}
|
||||
|
||||
fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
|
||||
let raw = match message {
|
||||
BiliMessage::Raw(value) => value,
|
||||
_ => return None,
|
||||
fn normalize_upstream(event: UpstreamLiveEvent) -> Option<ProviderEvent> {
|
||||
let UpstreamLiveEvent::Command(command) = event else {
|
||||
return None;
|
||||
};
|
||||
normalize_command(*command)
|
||||
}
|
||||
|
||||
fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
|
||||
match command {
|
||||
LiveCommand::Danmu(message) => normalize_danmaku(&message),
|
||||
LiveCommand::Gift(message) => normalize_gift(&message),
|
||||
LiveCommand::ComboSend(message) => normalize_combo(&message),
|
||||
LiveCommand::LikeClick(message) => normalize_like(&message),
|
||||
LiveCommand::LikeUpdate(message) => unknown(message.cmd),
|
||||
LiveCommand::LikeNotice(message) => unknown(message.cmd),
|
||||
LiveCommand::GuardBuy(message) => Some(ProviderEvent::Guard {
|
||||
viewer: PlatformViewer {
|
||||
uid: message.uid.to_string(),
|
||||
name: message.username,
|
||||
},
|
||||
name: message.gift_name,
|
||||
quantity: bounded_i32(message.num).max(1),
|
||||
price: message.extra.get("price").and_then(value_i64).unwrap_or(0),
|
||||
}),
|
||||
LiveCommand::SuperChat(message) => normalize_superchat(&message),
|
||||
LiveCommand::InteractWord(message) => normalize_interaction(&message),
|
||||
// Invalid/unknown commands retain raw JSON inside libilibili. We only
|
||||
// use that fallback for legacy aliases or malformed versions of known
|
||||
// commands; every other raw payload is discarded at this boundary.
|
||||
LiveCommand::Unknown { command, raw }
|
||||
| LiveCommand::Invalid {
|
||||
command,
|
||||
raw,
|
||||
error: _,
|
||||
} => normalize_raw(&raw).or_else(|| {
|
||||
Some(ProviderEvent::Unknown {
|
||||
command: sanitized_command(command.as_deref()),
|
||||
})
|
||||
}),
|
||||
LiveCommand::OnlineRankCount(message) => unknown(message.cmd),
|
||||
LiveCommand::WatchedChange(message) => unknown(message.cmd),
|
||||
LiveCommand::StopLiveRoomList(message) => unknown(message.cmd),
|
||||
LiveCommand::GiftStarProcess(message) => unknown(message.cmd),
|
||||
LiveCommand::OnlineRankV3(message) => unknown(message.cmd),
|
||||
LiveCommand::InteractWordV2(message) => unknown(message.cmd),
|
||||
LiveCommand::RoomChange(message) => unknown(message.cmd),
|
||||
LiveCommand::Live(message) => unknown(message.cmd),
|
||||
LiveCommand::Preparing(message) => unknown(message.cmd),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_danmaku(message: &DanmuMessage) -> Option<ProviderEvent> {
|
||||
let text = message.text.clone()?;
|
||||
let uid = message.sender.uid?;
|
||||
Some(ProviderEvent::Danmaku {
|
||||
viewer: PlatformViewer {
|
||||
uid: uid.to_string(),
|
||||
name: message
|
||||
.sender
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
},
|
||||
emoticons: parse_danmaku_emoticons(message, &text),
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
|
||||
let uid = message.data.uid?;
|
||||
let quantity = bounded_i32(message.data.num.unwrap_or(1)).max(1);
|
||||
let unit_price = message.data.price.or_else(|| {
|
||||
message
|
||||
.data
|
||||
.total_coin
|
||||
.map(|total| total / u64::try_from(quantity).unwrap_or(1))
|
||||
});
|
||||
let event_id = message
|
||||
.message_id
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
message
|
||||
.data
|
||||
.extra
|
||||
.get("tid")
|
||||
.and_then(value_lossless_string)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
let stable_time = message
|
||||
.data
|
||||
.extra
|
||||
.get("timestamp")
|
||||
.and_then(value_lossless_string)
|
||||
.or_else(|| message.sent_at.map(|value| value.to_string()))
|
||||
.unwrap_or_else(|| "unknown-time".into());
|
||||
format!(
|
||||
"gift-{uid}-{}-{stable_time}",
|
||||
message.data.gift_id.unwrap_or_default()
|
||||
)
|
||||
});
|
||||
Some(ProviderEvent::Gift {
|
||||
viewer: PlatformViewer {
|
||||
uid: uid.to_string(),
|
||||
name: message
|
||||
.data
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
},
|
||||
name: message
|
||||
.data
|
||||
.gift_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "礼物".into()),
|
||||
gift_id: message.data.gift_id.and_then(bounded_i64),
|
||||
coin_type: message.data.coin_type.clone(),
|
||||
battery: bounded_i32(unit_price.unwrap_or_default()),
|
||||
quantity,
|
||||
event_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_combo(message: &ComboSendMessage) -> Option<ProviderEvent> {
|
||||
let uid = message.data.uid?;
|
||||
let quantity = message
|
||||
.data
|
||||
.combo_num
|
||||
.or(message.data.batch_combo_num)
|
||||
.or(message.data.num)
|
||||
.map(bounded_i32)
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let unit_price = message.data.price.or_else(|| {
|
||||
message
|
||||
.data
|
||||
.total_coin
|
||||
.map(|total| total / message.data.num.unwrap_or(1).max(1))
|
||||
});
|
||||
let gift_id = message.data.gift_id.and_then(bounded_i64);
|
||||
let combo_id = message
|
||||
.data
|
||||
.combo_id
|
||||
.clone()
|
||||
.or_else(|| message.data.batch_combo_id.clone())
|
||||
.unwrap_or_else(|| format!("combo-{uid}-{}", message.data.gift_id.unwrap_or_default()));
|
||||
Some(ProviderEvent::GiftCombo {
|
||||
viewer: PlatformViewer {
|
||||
uid: uid.to_string(),
|
||||
name: message
|
||||
.data
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
},
|
||||
name: message
|
||||
.data
|
||||
.gift_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "礼物".into()),
|
||||
gift_id,
|
||||
coin_type: message.data.coin_type.clone(),
|
||||
battery: bounded_i32(unit_price.unwrap_or_default()),
|
||||
quantity,
|
||||
combo_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_like(message: &LikeClickMessage) -> Option<ProviderEvent> {
|
||||
if message.data.is_like == Some(false) {
|
||||
return None;
|
||||
}
|
||||
let uid = message.data.uid?;
|
||||
Some(ProviderEvent::Like {
|
||||
viewer: PlatformViewer {
|
||||
uid: uid.to_string(),
|
||||
name: message
|
||||
.data
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_superchat(message: &SuperChatMessage) -> Option<ProviderEvent> {
|
||||
let uid = message.data.uid;
|
||||
let name = message
|
||||
.data
|
||||
.extra
|
||||
.get("user_info")
|
||||
.and_then(|value| value.get("uname"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| message.data.extra.get("uname").and_then(Value::as_str))
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("UID {uid}"));
|
||||
let event_id = message
|
||||
.data
|
||||
.extra
|
||||
.get("id")
|
||||
.and_then(value_lossless_string)
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
Some(ProviderEvent::SuperChat {
|
||||
viewer: PlatformViewer {
|
||||
uid: uid.to_string(),
|
||||
name,
|
||||
},
|
||||
message: message.data.message.clone(),
|
||||
price: bounded_i64(message.data.price).unwrap_or(i64::MAX),
|
||||
event_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_interaction(message: &InteractWordMessage) -> Option<ProviderEvent> {
|
||||
let viewer = PlatformViewer {
|
||||
uid: message.data.uid.to_string(),
|
||||
name: message.data.uname.clone(),
|
||||
};
|
||||
match message.data.msg_type {
|
||||
1 => Some(ProviderEvent::Enter { viewer }),
|
||||
3 => Some(ProviderEvent::Share { viewer }),
|
||||
_ => unknown(message.cmd.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown(command: String) -> Option<ProviderEvent> {
|
||||
Some(ProviderEvent::Unknown {
|
||||
command: sanitized_command(Some(&command)),
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitized_command(command: Option<&str>) -> String {
|
||||
command
|
||||
.and_then(|value| value.split(':').next())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("UNKNOWN")
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
let command = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
|
||||
let data = raw.get("data").unwrap_or(&raw);
|
||||
let data = raw.get("data").unwrap_or(raw);
|
||||
let viewer = |uid: &Value, name: &Value| {
|
||||
Some(PlatformViewer {
|
||||
uid: uid
|
||||
@@ -458,15 +714,6 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
|
||||
)
|
||||
};
|
||||
match command.as_str() {
|
||||
"DANMU_MSG" => {
|
||||
let info = raw.get("info")?.as_array()?;
|
||||
let text = info.get(1)?.as_str()?.to_owned();
|
||||
Some(ProviderEvent::Danmaku {
|
||||
viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?,
|
||||
emoticons: parse_danmaku_emoticons(info, &text),
|
||||
text,
|
||||
})
|
||||
}
|
||||
"SEND_GIFT" => Some(ProviderEvent::Gift {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
@@ -478,12 +725,23 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
|
||||
.get("giftId")
|
||||
.or_else(|| data.get("gift_id"))
|
||||
.and_then(Value::as_i64),
|
||||
battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32,
|
||||
quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32,
|
||||
coin_type: data
|
||||
.get("coin_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
battery: data
|
||||
.get("price")
|
||||
.and_then(value_i64)
|
||||
.map(bounded_signed_i32)
|
||||
.unwrap_or(0),
|
||||
quantity: data
|
||||
.get("num")
|
||||
.and_then(value_i64)
|
||||
.map(bounded_signed_i32)
|
||||
.unwrap_or(1),
|
||||
event_id: data
|
||||
.get("tid")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.and_then(value_lossless_string)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}-{}",
|
||||
@@ -492,32 +750,6 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
|
||||
)
|
||||
}),
|
||||
}),
|
||||
"COMBO_SEND" => Some(ProviderEvent::GiftCombo {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
.get("gift_name")
|
||||
.or_else(|| data.get("giftName"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("礼物")
|
||||
.to_owned(),
|
||||
gift_id: data
|
||||
.get("gift_id")
|
||||
.or_else(|| data.get("giftId"))
|
||||
.and_then(Value::as_i64),
|
||||
battery: data.get("price").and_then(Value::as_i64).unwrap_or(0) as i32,
|
||||
quantity: data
|
||||
.get("combo_num")
|
||||
.or_else(|| data.get("total_num"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1) as i32,
|
||||
combo_id: data
|
||||
.get("combo_id")
|
||||
.map(Value::to_string)
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
"INTERACT_WORD" => Some(ProviderEvent::Enter {
|
||||
viewer: data_viewer(data)?,
|
||||
}),
|
||||
"GUARD_BUY" => Some(ProviderEvent::Guard {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
@@ -526,8 +758,12 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("舰长")
|
||||
.to_owned(),
|
||||
quantity: data.get("num").and_then(Value::as_i64).unwrap_or(1) as i32,
|
||||
price: data.get("price").and_then(Value::as_i64).unwrap_or(0),
|
||||
quantity: data
|
||||
.get("num")
|
||||
.and_then(value_i64)
|
||||
.map(bounded_signed_i32)
|
||||
.unwrap_or(1),
|
||||
price: data.get("price").and_then(value_i64).unwrap_or(0),
|
||||
}),
|
||||
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat {
|
||||
viewer: data_viewer(data)?,
|
||||
@@ -536,22 +772,50 @@ fn normalize(message: BiliMessage) -> Option<ProviderEvent> {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
price: data.get("price").and_then(Value::as_i64).unwrap_or(0),
|
||||
price: data.get("price").and_then(value_i64).unwrap_or(0),
|
||||
event_id: data
|
||||
.get("id")
|
||||
.map(Value::to_string)
|
||||
.and_then(value_lossless_string)
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
}),
|
||||
"LIKE_INFO_V3_CLICK" => Some(ProviderEvent::Like {
|
||||
viewer: data_viewer(data)?,
|
||||
}),
|
||||
"SHARE" => Some(ProviderEvent::Share {
|
||||
viewer: data_viewer(data)?,
|
||||
}),
|
||||
_ => Some(ProviderEvent::Unknown { command }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_i64(value: &Value) -> Option<i64> {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(bounded_i64))
|
||||
.or_else(|| value.as_str()?.parse().ok())
|
||||
}
|
||||
|
||||
fn value_lossless_string(value: &Value) -> Option<String> {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| value.as_i64().map(|number| number.to_string()))
|
||||
.or_else(|| value.as_u64().map(|number| number.to_string()))
|
||||
}
|
||||
|
||||
fn bounded_i32(value: u64) -> i32 {
|
||||
i32::try_from(value).unwrap_or(i32::MAX)
|
||||
}
|
||||
|
||||
fn bounded_signed_i32(value: i64) -> i32 {
|
||||
i32::try_from(value).unwrap_or(if value.is_negative() {
|
||||
i32::MIN
|
||||
} else {
|
||||
i32::MAX
|
||||
})
|
||||
}
|
||||
|
||||
fn bounded_i64(value: u64) -> Option<i64> {
|
||||
i64::try_from(value).ok()
|
||||
}
|
||||
|
||||
fn json_object(value: &Value) -> Option<Value> {
|
||||
match value {
|
||||
Value::Object(_) => Some(value.clone()),
|
||||
@@ -605,16 +869,15 @@ fn emoticon_hint(value: &Value, text: &str, standalone: bool) -> Option<Emoticon
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
|
||||
let Some(header) = info.first().and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
fn parse_danmaku_emoticons(message: &DanmuMessage, text: &str) -> Vec<EmoticonHint> {
|
||||
let mut hints = Vec::new();
|
||||
if let Some(direct) = header
|
||||
.get(13)
|
||||
if let Some(direct) = message
|
||||
.metadata
|
||||
.extra
|
||||
.get(&13)
|
||||
.and_then(|value| emoticon_hint(value, text, true))
|
||||
.or_else(|| {
|
||||
header.iter().find_map(|value| {
|
||||
message.metadata.extra.values().find_map(|value| {
|
||||
let object = json_object(value)?;
|
||||
object.get("url")?;
|
||||
emoticon_hint(&object, text, true)
|
||||
@@ -623,11 +886,32 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
|
||||
{
|
||||
hints.push(direct);
|
||||
}
|
||||
let extra = header.iter().find_map(|value| {
|
||||
let object = json_object(value)?;
|
||||
json_object(object.get("extra")?)
|
||||
});
|
||||
if let Some(extra) = extra {
|
||||
|
||||
// libilibili preserves unmodeled metadata indices and extension JSON, so
|
||||
// evolving emoticon layouts remain recoverable without leaking the raw
|
||||
// DANMU_MSG array into the application domain.
|
||||
for value in message.metadata.extra.values() {
|
||||
let Some(object) = json_object(value) else {
|
||||
continue;
|
||||
};
|
||||
push_emoticon_extra(&object, text, &mut hints);
|
||||
if let Some(extra) = object.get("extra").and_then(json_object) {
|
||||
push_emoticon_extra(&extra, text, &mut hints);
|
||||
}
|
||||
}
|
||||
if let Some(extra) = message
|
||||
.extension
|
||||
.as_ref()
|
||||
.and_then(|extension| extension.extra_json.as_deref())
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.and_then(|value| json_object(&value))
|
||||
{
|
||||
push_emoticon_extra(&extra, text, &mut hints);
|
||||
}
|
||||
hints
|
||||
}
|
||||
|
||||
fn push_emoticon_extra(extra: &Value, text: &str, hints: &mut Vec<EmoticonHint>) {
|
||||
if let Some(emoticons) = extra.get("emots").and_then(json_object)
|
||||
&& let Some(emoticons) = emoticons.as_object()
|
||||
{
|
||||
@@ -656,8 +940,6 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
|
||||
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
|
||||
});
|
||||
}
|
||||
}
|
||||
hints
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -782,6 +1064,7 @@ async fn gift_details(
|
||||
catalog: &GiftCatalog,
|
||||
name: String,
|
||||
gift_id: Option<i64>,
|
||||
coin_type: Option<String>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
) -> GiftDetails {
|
||||
@@ -797,9 +1080,8 @@ async fn gift_details(
|
||||
.as_ref()
|
||||
.map(|gift| gift.name.clone())
|
||||
.unwrap_or(name),
|
||||
coin_type: metadata
|
||||
.as_ref()
|
||||
.map(|gift| gift.coin_type.clone())
|
||||
coin_type: coin_type
|
||||
.or_else(|| metadata.as_ref().map(|gift| gift.coin_type.clone()))
|
||||
.unwrap_or_else(|| "gold".into()),
|
||||
unit_price,
|
||||
total_price,
|
||||
@@ -816,10 +1098,11 @@ async fn gift_details(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libilibili::websocket::parse_command;
|
||||
|
||||
#[test]
|
||||
fn normalizes_raw_danmaku_to_provider_event() {
|
||||
let message = normalize(BiliMessage::Raw(
|
||||
fn normalizes_libilibili_danmaku_to_provider_event() {
|
||||
let message = normalize_command(parse_command(
|
||||
json!({"cmd":"DANMU_MSG:4:0:2","info":[[],"晚上好",[12345,"观众"]]}),
|
||||
))
|
||||
.unwrap();
|
||||
@@ -835,7 +1118,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unknown_events_do_not_forward_raw_payload() {
|
||||
let event = normalize(BiliMessage::Raw(json!({
|
||||
let event = normalize_command(parse_command(json!({
|
||||
"cmd":"FUTURE_SECRET_EVENT",
|
||||
"data":{"cookie":"must-not-cross-provider-boundary"}
|
||||
})))
|
||||
@@ -845,4 +1128,90 @@ mod tests {
|
||||
_ => panic!("expected unknown event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_typed_gift_identity_value_and_coin_type() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
"cmd":"SEND_GIFT",
|
||||
"data":{
|
||||
"uid":123,
|
||||
"uname":"送礼观众",
|
||||
"giftId":42,
|
||||
"giftName":"小花花",
|
||||
"num":3,
|
||||
"price":100,
|
||||
"total_coin":300,
|
||||
"coin_type":"gold",
|
||||
"tid":"gift-event-1"
|
||||
}
|
||||
})))
|
||||
.unwrap();
|
||||
match event {
|
||||
ProviderEvent::Gift {
|
||||
viewer,
|
||||
gift_id,
|
||||
coin_type,
|
||||
battery,
|
||||
quantity,
|
||||
event_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(viewer.uid, "123");
|
||||
assert_eq!(gift_id, Some(42));
|
||||
assert_eq!(coin_type.as_deref(), Some("gold"));
|
||||
assert_eq!(battery, 100);
|
||||
assert_eq!(quantity, 3);
|
||||
assert_eq!(event_id, "gift-event-1");
|
||||
}
|
||||
_ => panic!("expected gift"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_typed_combo_commands() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
"cmd":"COMBO_SEND",
|
||||
"data":{
|
||||
"uid":123,
|
||||
"uname":"送礼观众",
|
||||
"gift_id":42,
|
||||
"gift_name":"小花花",
|
||||
"combo_num":8,
|
||||
"price":100,
|
||||
"coin_type":"gold",
|
||||
"combo_id":"combo-1"
|
||||
}
|
||||
})))
|
||||
.unwrap();
|
||||
match event {
|
||||
ProviderEvent::GiftCombo {
|
||||
quantity, combo_id, ..
|
||||
} => {
|
||||
assert_eq!(quantity, 8);
|
||||
assert_eq!(combo_id, "combo-1");
|
||||
}
|
||||
_ => panic!("expected gift combo"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_typed_like_clicks_with_a_viewer() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
"cmd":"LIKE_INFO_V3_CLICK",
|
||||
"data":{
|
||||
"uid":456,
|
||||
"uname":"点赞观众",
|
||||
"is_like":true,
|
||||
"like_count":12
|
||||
}
|
||||
})))
|
||||
.unwrap();
|
||||
match event {
|
||||
ProviderEvent::Like { viewer } => {
|
||||
assert_eq!(viewer.uid, "456");
|
||||
assert_eq!(viewer.name, "点赞观众");
|
||||
}
|
||||
_ => panic!("expected like"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
additional_contexts:
|
||||
# Cargo resolves apps/server-rust/../../../libilibili to this checkout.
|
||||
libilibili: ../libilibili
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
user: '${APP_UID:-1000}:${APP_GID:-1000}'
|
||||
|
||||
+3
-3
@@ -115,7 +115,7 @@ guard = true
|
||||
like = false
|
||||
share = false
|
||||
|
||||
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。
|
||||
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。
|
||||
# 默认记录本服务生命周期信息,并保持依赖库仅输出警告。
|
||||
# libilibili 的凭据 Debug 已脱敏,但仍不要记录 Cookie 或原始认证载荷。
|
||||
[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。
|
||||
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据获取 Cookie,并把原始消息转换成
|
||||
`LiveEvent`。
|
||||
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据构造 `libilibili`
|
||||
客户端;crate 负责 WBI、WebSocket 与压缩包解析,adapter 再把强类型命令转换成 `LiveEvent`。
|
||||
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
|
||||
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
||||
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
||||
@@ -71,6 +71,7 @@ flowchart LR
|
||||
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
|
||||
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。
|
||||
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
|
||||
- `libilibili` 是源码树中的相邻 crate,由 Compose named build context 注入 Rust 构建阶段。
|
||||
|
||||
## 代码导航
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ token。以下规则是实现约束,而不是可选部署建议。
|
||||
- Key 编码成单一路径段,不能注入额外路径。
|
||||
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
|
||||
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
|
||||
- 日志不得把 `blivedm` 调到可能打印认证响应的详细级别。
|
||||
- `libilibili::Credentials` 的 `Debug` 已脱敏,但应用仍不得记录 Cookie、原始认证响应或弹幕 token。
|
||||
|
||||
## 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