initial commit

This commit is contained in:
2026-07-14 21:31:59 -07:00
commit bd79966218
61 changed files with 19379 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# 已弃用:应用不再从环境变量读取业务配置。
# 请使用 config.toml.example:
# cp config.toml.example config.toml
# 然后填写 config.toml 中的注释项。Docker Compose 会只读挂载该文件。
+38
View File
@@ -0,0 +1,38 @@
# Local secrets and deployment configuration
.env
.env.*
!.env.example
config.toml
# JavaScript / TypeScript dependencies and generated output
node_modules/
**/node_modules/
dist/
**/dist/
*.tsbuildinfo
coverage/
# Rust build output
target/
**/target/
*.rs.bk
# Runtime state and logs
data/
*.log
*.pid
# Editor and operating-system files
.DS_Store
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/**
+34
View File
@@ -0,0 +1,34 @@
FROM node:22-bookworm-slim AS web-build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/web/package.json apps/web/package.json
COPY packages/protocol/package.json packages/protocol/package.json
COPY packages/live-client/package.json packages/live-client/package.json
RUN npm ci --include-workspace-root
COPY apps/web apps/web
COPY packages/protocol packages/protocol
COPY packages/live-client packages/live-client
RUN npm --workspace @lxc/protocol run build && npm --workspace @lxc/live-client run build && npm --workspace @lxc/web 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
COPY apps/server-rust/src ./apps/server-rust/src
COPY apps/server/migrations ./apps/server/migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target-cache \
CARGO_TARGET_DIR=/app/target-cache cargo build --manifest-path apps/server-rust/Cargo.toml --release && \
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 libasound2 libssl3 && 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/web/dist /app/web
EXPOSE 9719
CMD ["/app/lxc-stream-server", "--config", "/app/config.toml"]
+20
View File
@@ -0,0 +1,20 @@
# 洛星瓷直播转盘
运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、业务数据库、歌单只读数据库和访问密钥,然后执行:
```sh
cp config.toml.example config.toml
docker compose up --build -d
```
应用配置遵循 `blivedm_rs` 的 TOML/`--config` 形式;Compose 会以只读方式将 `config.toml` 挂载到容器并传入 `--config /app/config.toml`。旧 `.env`/`.env.example` 不再被读取;迁移确认后可删除原 `.env`。配置段及字段说明直接写在 [config.toml.example](config.toml.example) 的注释中。
- 管理台:`http://127.0.0.1:9719/admin`
- 测试页:`http://127.0.0.1:9719/test`
- OBS:`http://127.0.0.1:9719/obs?token=$OBS_ACCESS_TOKEN`
应用容器使用 host 网络并监听 `9719`。它通过宿主机 `127.0.0.1:5432` 直连 PostgreSQL:转盘业务使用独立的 `wheel` 数据库,歌单读取使用现有 `lxc_songlist` 数据库。应用会自动创建转盘业务表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。
`BILI_REPLY_ENABLED=true` 可启用聊天回复;发送前会从 CookieCloud 读取最新的 Bilibili Cookie,并从其中取得 `bili_jct` CSRF 值。若 Cookie 未同步 `bili_jct`,转盘仍会正常在 OBS 展示,只会停用聊天回复。
服务端已迁移为 Rust/Axum,并使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 发布的 `blivedm` crate 建立 Bilibili 认证弹幕连接;不再使用 Node 服务端或 `@laplace.live/ws`。为确保 UID、礼物价格和上游事件 ID 不会被库的简化消息结构丢弃,项目在 `vendor/blivedm` 固定了一个仅保留原始 JSON 的小补丁。应用从 CookieCloud 获取 Bilibili Cookie(必须包含 `SESSDATA`),用于获取认证 UID、直播间网关与 WebSocket 鉴权包。
+4829
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "lxc-stream-server"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["ws", "json"] }
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"] }
futures = "0.3"
futures-channel = "0.3"
hmac = "0.12"
http = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] }
toml = "0.8"
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "serde"] }
+163
View File
@@ -0,0 +1,163 @@
use std::{env, fs, path::PathBuf, sync::{Arc, atomic::{AtomicBool, Ordering}}};
use axum::{extract::{ws::{Message, WebSocket, WebSocketUpgrade}, Query, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use blivedm::client::{models::BiliMessage, websocket::BiliLiveClient};
use futures_channel::mpsc;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::Sha256;
use tokio::{sync::{broadcast, Mutex}, task};
use tokio_postgres::{Client, NoTls};
use tower_http::services::{ServeDir, ServeFile};
use tracing::{error, info, warn};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
const WHEEL_COST: i32 = 150;
#[derive(Clone)]
struct Config { port: u16, room_id: String, database_url: String, songlist_database_url: String, cookiecloud_host: String, cookiecloud_key: String, cookiecloud_password: String, admin_password: String, session_secret: String, obs_access_token: String, reply_enabled: bool, log_filter: String }
#[derive(Deserialize)]
struct FileConfig { connection: ConnectionConfig, #[serde(default)] server: ServerConfig, database: DatabaseConfig, songlist: SonglistConfig, cookiecloud: CookieCloudConfig, admin: AdminConfig, obs: ObsConfig, #[serde(default)] reply: ReplyConfig, #[serde(default)] logging: LoggingConfig }
#[derive(Deserialize)] struct ConnectionConfig { room_id: String }
#[derive(Deserialize, Default)] struct ServerConfig { port: Option<u16> }
#[derive(Deserialize)] struct DatabaseConfig { url: String }
#[derive(Deserialize)] struct SonglistConfig { database_url: String }
#[derive(Deserialize)] struct CookieCloudConfig { host: String, key: String, password: String }
#[derive(Deserialize)] struct AdminConfig { password: String, session_secret: String }
#[derive(Deserialize)] struct ObsConfig { access_token: String }
#[derive(Deserialize, Default)] struct ReplyConfig { enabled: Option<bool> }
#[derive(Deserialize, Default)] struct LoggingConfig { filter: Option<String> }
impl Config {
fn config_path() -> Result<PathBuf, String> {
let mut args = env::args_os().skip(1);
let mut path = PathBuf::from("config.toml");
while let Some(arg) = args.next() {
if arg == "--config" { path = PathBuf::from(args.next().ok_or("--config requires a TOML path")?); }
else { return Err(format!("Unknown argument: {:?}; use --config <path>", arg)); }
}
Ok(path)
}
fn load() -> Result<Self, String> {
let path = Self::config_path()?;
let source = fs::read_to_string(&path).map_err(|e| format!("Cannot read configuration {}: {e}", path.display()))?;
let file: FileConfig = toml::from_str(&source).map_err(|e| format!("Invalid TOML in {}: {e}", path.display()))?;
Ok(Self { port: file.server.port.unwrap_or(9719), room_id: file.connection.room_id, database_url: file.database.url, songlist_database_url: file.songlist.database_url, cookiecloud_host: file.cookiecloud.host, cookiecloud_key: file.cookiecloud.key, cookiecloud_password: file.cookiecloud.password, admin_password: file.admin.password, session_secret: file.admin.session_secret, obs_access_token: file.obs.access_token, reply_enabled: file.reply.enabled.unwrap_or(false), log_filter: file.logging.filter.unwrap_or_else(|| "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into()) })
}
}
#[derive(Clone)]
struct AppState { config: Config, events: broadcast::Sender<String>, reply_enabled: Arc<AtomicBool>, source: Arc<Mutex<SourceStatus>> }
#[derive(Clone, Default)]
struct SourceStatus { connected: bool, cookie_cloud: bool, detail: String }
#[derive(Serialize, Clone)]
struct Viewer { uid: String, name: String }
#[derive(Clone)]
enum Incoming { Enter { viewer: Viewer }, Danmaku { viewer: Viewer, text: String }, Gift { viewer: Viewer, name: String, battery: i32, quantity: i32, event_id: String }, Event { kind: String, payload: Value } }
#[derive(Deserialize)] struct Login { password: String }
#[derive(Deserialize)] struct ToggleReply { enabled: bool }
#[derive(Deserialize)] struct ViewerQuery { search: Option<String> }
#[derive(Deserialize)] struct WsQuery { token: Option<String> }
#[derive(Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")]
enum TestEvent { Enter { uid: String, name: String }, Danmaku { uid: String, name: String, text: String }, Gift { uid: String, name: String, #[serde(rename = "giftName")] gift_name: String, battery: i32, quantity: i32 } }
#[tokio::main]
async fn main() {
let config = Config::load().unwrap_or_else(|error| panic!("Configuration error: {error}"));
let log_filter = tracing_subscriber::EnvFilter::new(config.log_filter.clone());
tracing_subscriber::fmt().with_env_filter(log_filter).json().init();
let (events, _) = broadcast::channel(256);
let state = AppState { reply_enabled: Arc::new(AtomicBool::new(config.reply_enabled)), source: Arc::new(Mutex::new(SourceStatus { detail: "Starting blivedm_rs listener".into(), ..Default::default() })), config, events };
migrate(&state.config.database_url).await.expect("database migration failed");
spawn_live_listener(state.clone());
let static_files = ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html"));
let app = Router::new()
.route("/health", get(health)).route("/ws", get(ws))
.route("/api/auth/login", post(login)).route("/api/auth/logout", post(logout))
.route("/api/admin/status", get(status)).route("/api/admin/viewers", get(viewers)).route("/api/admin/ledger", get(ledger))
.route("/api/admin/reconnect", post(reconnect)).route("/api/admin/reply", post(toggle_reply)).route("/api/admin/obs-url", get(obs_url))
.route("/api/test/event", post(test_event)).fallback_service(static_files).with_state(state.clone());
let addr = format!("0.0.0.0:{}", state.config.port);
let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind failed");
info!(%addr, "Rust backend listening"); axum::serve(listener, app).await.expect("server failed");
}
async fn migrate(url: &str) -> Result<(), String> { let client = connect(url).await?; client.batch_execute(include_str!("../../server/migrations/001_initial.sql")).await.map_err(|e| e.to_string()) }
async fn connect(url: &str) -> Result<Client, String> { let (client, connection) = tokio_postgres::connect(url, NoTls).await.map_err(|e| e.to_string())?; tokio::spawn(async move { if let Err(e) = connection.await { warn!(error = %e, "postgres connection ended"); } }); Ok(client) }
fn session(config: &Config) -> String { let mut mac = HmacSha256::new_from_slice(config.session_secret.as_bytes()).expect("hmac key"); mac.update(b"admin"); URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) }
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> { headers.get("cookie")?.to_str().ok()?.split(';').find_map(|part| part.trim().split_once('=').filter(|(k, _)| *k == name).map(|(_, v)| v.to_owned())) }
fn admin(state: &AppState, headers: &HeaderMap) -> bool { cookie(headers, "lxc_session").is_some_and(|v| v == session(&state.config)) }
fn unauthorized() -> Response { (StatusCode::UNAUTHORIZED, Json(json!({"error":"Administrator authentication required"}))).into_response() }
fn event(state: &AppState, kind: &str, payload: Value) { let message = json!({"version":1,"id":Uuid::new_v4(),"occurredAt":chrono_now(),"roomId":state.config.room_id,"type":kind,"payload":payload}); let _ = state.events.send(message.to_string()); }
fn chrono_now() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) }
async fn health(State(state): State<AppState>) -> Json<Value> { Json(json!({"ok":true,"roomId":state.config.room_id})) }
async fn login(State(state): State<AppState>, Json(body): Json<Login>) -> Response { if body.password != state.config.admin_password { return (StatusCode::UNAUTHORIZED, Json(json!({"error":"Invalid password"}))).into_response(); } let mut response = Json(json!({"ok":true})).into_response(); response.headers_mut().insert("set-cookie", HeaderValue::from_str(&format!("lxc_session={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200", session(&state.config))).unwrap()); response }
async fn logout() -> Response { let mut r = Json(json!({"ok":true})).into_response(); r.headers_mut().insert("set-cookie", HeaderValue::from_static("lxc_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")); r }
async fn status(State(state): State<AppState>, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } let source = state.source.lock().await.clone(); Json(json!({"roomId":state.config.room_id,"source":{"connected":source.connected,"cookieCloud":source.cookie_cloud,"detail":source.detail},"reply":{"enabled":state.reply_enabled.load(Ordering::Relaxed),"available":true,"detail":"CookieCloud-backed reply ready"},"websocketClients":state.events.receiver_count(),"cookieCloudHost":state.config.cookiecloud_host})).into_response() }
async fn viewers(State(state): State<AppState>, headers: HeaderMap, Query(query): Query<ViewerQuery>) -> Response { if !admin(&state, &headers) { return unauthorized(); } let db = match connect(&state.config.database_url).await { Ok(db) => db, Err(e) => return error_response(e) }; let search = format!("%{}%", query.search.unwrap_or_default()); match db.query("SELECT uid,display_name,points FROM viewer_accounts WHERE scope='live' AND room_id=$1 AND (uid ILIKE $2 OR display_name ILIKE $2) ORDER BY updated_at DESC LIMIT 100", &[&state.config.room_id, &search]).await { Ok(rows) => Json(rows.into_iter().map(|r| json!({"uid":r.get::<_, String>(0),"displayName":r.get::<_, String>(1),"points":r.get::<_, i32>(2)})).collect::<Vec<_>>()).into_response(), Err(e) => error_response(e.to_string()) } }
async fn ledger(State(state): State<AppState>, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } let db = match connect(&state.config.database_url).await { Ok(db) => db, Err(e) => return error_response(e) }; match db.query("SELECT l.id::text,l.uid,a.display_name,l.delta,l.reason,l.created_at::text FROM point_ledger l JOIN viewer_accounts a ON(a.scope=l.scope AND a.room_id=l.room_id AND a.uid=l.uid) WHERE l.scope='live' AND l.room_id=$1 ORDER BY l.created_at DESC LIMIT 100", &[&state.config.room_id]).await { Ok(rows) => Json(rows.into_iter().map(|r| json!({"id":r.get::<_, String>(0),"uid":r.get::<_, String>(1),"displayName":r.get::<_, String>(2),"delta":r.get::<_, i32>(3),"reason":r.get::<_, String>(4),"createdAt":r.get::<_, String>(5)})).collect::<Vec<_>>()).into_response(), Err(e) => error_response(e.to_string()) } }
async fn toggle_reply(State(state): State<AppState>, headers: HeaderMap, Json(body): Json<ToggleReply>) -> Response { if !admin(&state, &headers) { return unauthorized(); } state.reply_enabled.store(body.enabled, Ordering::Relaxed); Json(json!({"enabled":body.enabled,"available":true,"detail":"CookieCloud-backed reply ready"})).into_response() }
async fn reconnect(State(state): State<AppState>, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } spawn_live_listener(state.clone()); Json(json!({"ok":true})).into_response() }
async fn obs_url(State(state): State<AppState>, headers: HeaderMap) -> Response { if !admin(&state, &headers) { return unauthorized(); } Json(format!("/obs?token={}", state.config.obs_access_token)).into_response() }
async fn test_event(State(state): State<AppState>, headers: HeaderMap, Json(body): Json<TestEvent>) -> Response { if !admin(&state, &headers) { return unauthorized(); } let message = match body { TestEvent::Enter{uid,name} => Incoming::Enter{viewer:Viewer{uid,name}}, TestEvent::Danmaku{uid,name,text} => Incoming::Danmaku{viewer:Viewer{uid,name},text}, TestEvent::Gift{uid,name,gift_name,battery,quantity} => Incoming::Gift{viewer:Viewer{uid,name},name:gift_name,battery,quantity,event_id:format!("test-{}",Uuid::new_v4())} }; if let Err(e) = process(&state, message, "test").await { return error_response(e); } Json(json!({"ok":true})).into_response() }
async fn ws(State(state): State<AppState>, headers: HeaderMap, Query(query): Query<WsQuery>, upgrade: WebSocketUpgrade) -> Response { let allowed = admin(&state, &headers) || query.token.as_deref() == Some(&state.config.obs_access_token); if !allowed { return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } upgrade.on_upgrade(move |socket| ws_loop(socket, state.events.subscribe())) }
async fn ws_loop(mut socket: WebSocket, mut rx: broadcast::Receiver<String>) { while let Ok(text) = rx.recv().await { if socket.send(Message::Text(text.into())).await.is_err() { break; } } }
fn error_response(error: String) -> Response { error!(%error, "request failed"); (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"Internal server error"}))).into_response() }
fn spawn_live_listener(state: AppState) { task::spawn_blocking(move || { let runtime = tokio::runtime::Handle::current(); let cookie = match runtime.block_on(cookiecloud_cookie(&state.config)) { Ok(v) => v, Err(e) => { runtime.block_on(set_source(&state, false, false, e)); return; } }; runtime.block_on(set_source(&state, false, true, "Connecting with blivedm_rs".into())); let (sender, mut receiver) = mpsc::channel(256); let mut client = match BiliLiveClient::new_auto(Some(&cookie), &state.config.room_id, sender) { Ok(client) => client, Err(e) => { runtime.block_on(set_source(&state, false, true, e)); return; } }; client.send_auth(); runtime.block_on(set_source(&state, true, true, "Connected with authenticated blivedm_rs listener".into())); loop { if let Err(e) = client.receive() { runtime.block_on(set_source(&state, false, true, e)); } while let Ok(Some(raw)) = receiver.try_next() { if let Some(message) = normalize(raw) { let state = state.clone(); runtime.block_on(async move { if let Err(e) = process(&state, message, "live").await { error!(%e, "live event processing failed"); } }); } } } }); }
async fn set_source(state: &AppState, connected: bool, cookie_cloud: bool, detail: String) { *state.source.lock().await = SourceStatus { connected, cookie_cloud, detail }; }
async fn cookiecloud_cookie(config: &Config) -> Result<String, String> { let host = config.cookiecloud_host.trim_end_matches('/'); let response = reqwest::Client::new().post(format!("{host}/get/{}", config.cookiecloud_key)).form(&[("password", config.cookiecloud_password.as_str())]).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("CookieCloud HTTP {}", response.status())); } let value: Value = response.json().await.map_err(|e| e.to_string())?; let mut cookies = Vec::new(); if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) { for (domain, stored) in domains { if !domain.contains("bilibili.com") { continue; } let entries: Vec<&Value> = if let Some(array) = stored.as_array() { array.iter().collect() } else { stored.as_object().map(|values| values.values().collect()).unwrap_or_default() }; for c in entries { if let (Some(name), Some(value)) = (c.get("name").and_then(Value::as_str), c.get("value").and_then(Value::as_str)) { cookies.push(format!("{name}={value}")); } } } } if cookies.iter().any(|c| c.starts_with("SESSDATA=")) { Ok(cookies.join("; ")) } else { Err("CookieCloud has no Bilibili SESSDATA cookie".into()) } }
fn normalize(message: BiliMessage) -> Option<Incoming> {
let raw = match message { BiliMessage::Raw(v) => v, _ => return None };
let cmd = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
let data = raw.get("data").unwrap_or(&raw);
let viewer = |uid: &Value, name: &Value| Some(Viewer { uid: uid.as_i64().map(|id| id.to_string()).or_else(|| uid.as_str().map(str::to_owned))?, name: name.as_str()?.to_string() });
let data_viewer = |value: &Value| viewer(value.get("uid")?, value.get("uname").or_else(|| value.pointer("/sender_uinfo/base/name")).or_else(|| value.pointer("/user_info/uname"))?);
let event = |kind: &str, payload: Value| Some(Incoming::Event { kind: kind.into(), payload });
match cmd.as_str() {
"DANMU_MSG" => { let info = raw.get("info")?.as_array()?; Some(Incoming::Danmaku { viewer: viewer(info.get(2)?.get(0)?, info.get(2)?.get(1)?)?, text: info.get(1)?.as_str()?.to_string() }) }
"SEND_GIFT" => Some(Incoming::Gift { viewer: data_viewer(data)?, name: data.get("giftName").or_else(|| data.get("gift_name"))?.as_str()?.to_string(), 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, event_id: data.get("tid").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| format!("{}-{}", data.get("uid").unwrap_or(&Value::Null), data.get("timestamp").unwrap_or(&Value::Null))) }),
"COMBO_SEND" => { let viewer = data_viewer(data)?; event("live.gift.combo", json!({"viewer":viewer,"giftName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("礼物"),"battery":data.get("price").and_then(Value::as_i64).unwrap_or(0),"quantity":data.get("combo_num").or_else(||data.get("total_num")).and_then(Value::as_i64).unwrap_or(1),"comboId":data.get("combo_id").and_then(Value::as_str).unwrap_or("")})) }
"INTERACT_WORD" => Some(Incoming::Enter { viewer: data_viewer(data)? }),
"GUARD_BUY" => { let viewer = data_viewer(data)?; event("live.guard.buy", json!({"viewer":viewer,"guardName":data.get("gift_name").or_else(||data.get("giftName")).and_then(Value::as_str).unwrap_or("舰长"),"quantity":data.get("num").and_then(Value::as_i64).unwrap_or(1),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0)})) }
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => { let viewer = data_viewer(data)?; event("live.superchat", json!({"viewer":viewer,"message":data.get("message").and_then(Value::as_str).unwrap_or(""),"price":data.get("price").and_then(Value::as_i64).unwrap_or(0),"sourceEventId":data.get("id").map(Value::to_string).unwrap_or_else(||Uuid::new_v4().to_string())})) }
"LIKE_INFO_V3_CLICK" => { let viewer = data_viewer(data)?; event("live.like", json!({"viewer":viewer})) }
"SHARE" => { let viewer = data_viewer(data)?; event("live.share", json!({"viewer":viewer})) }
_ => event("live.unknown", json!({"cmd":cmd,"raw":raw})),
}
}
async fn process(state: &AppState, message: Incoming, scope: &str) -> Result<(), String> { if let Incoming::Event { kind, payload } = &message { event(state, kind, payload.clone()); return Ok(()); } let viewer = match &message { Incoming::Enter{viewer}|Incoming::Danmaku{viewer,..}|Incoming::Gift{viewer,..} => viewer.clone(), Incoming::Event{..} => unreachable!() }; let db = connect(&state.config.database_url).await?; db.execute("INSERT INTO viewer_accounts(scope,room_id,uid,display_name) VALUES($1,$2,$3,$4) ON CONFLICT(scope,room_id,uid) DO UPDATE SET display_name=EXCLUDED.display_name,updated_at=now()", &[&scope,&state.config.room_id,&viewer.uid,&viewer.name]).await.map_err(|e|e.to_string())?; match message { Incoming::Enter{..} => event(state,"live.enter",json!({"viewer":viewer})), Incoming::Danmaku{ text,.. } => { event(state,"live.danmaku",json!({"viewer":viewer,"text":text})); command(state, &db, scope, viewer, text).await? }, Incoming::Gift{name,battery,quantity,event_id,..} => { let amount = battery.max(0).saturating_mul(quantity.max(1)); let id = Uuid::new_v4().to_string(); let inserted = db.query_opt("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,source_event_id,metadata) VALUES($1::text::uuid,$2,$3,$4,$5,'gift',$6,$7) ON CONFLICT(source_event_id) DO NOTHING RETURNING id", &[&id,&scope,&state.config.room_id,&viewer.uid,&amount,&event_id,&json!({"giftName":name,"battery":battery,"quantity":quantity})]).await.map_err(|e|e.to_string())?; event(state,"live.gift",json!({"viewer":viewer,"giftName":name,"battery":battery,"quantity":quantity,"sourceEventId":event_id})); if inserted.is_some() { let row = db.query_one("UPDATE viewer_accounts SET points=points+$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 RETURNING points", &[&amount,&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?; let points:i32=row.get(0); event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":amount,"balance":points,"reason":"gift"})); } }, Incoming::Event{..} => unreachable!() } Ok(()) }
async fn command(state: &AppState, db: &Client, scope: &str, viewer: Viewer, text: String) -> Result<(), String> { let normalized = text.split_whitespace().collect::<Vec<_>>().join(" "); if normalized == "转盘查询" { let points = db.query_opt("SELECT points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND uid=$3", &[&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?.map(|r|r.get::<_,i32>(0)).unwrap_or(0); event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":0,"balance":points,"reason":"gift"})); reply(state, format!("{} 当前转盘点数:{}", viewer.name, points)).await; return Ok(()); } let Some(category) = normalized.strip_prefix("转盘 ").filter(|v| !v.is_empty()) else { if normalized == "转盘" { event(state,"wheel.invalid-command",json!({"viewer":viewer,"message":"用法:转盘 [类别]"})); reply(state, format!("{}:用法:转盘 [类别]", viewer.name)).await; } return Ok(()); }; let song = random_song(&state.config.songlist_database_url, category).await?; let Some((song_id,title,tags,fallback)) = song else { event(state,"wheel.invalid-command",json!({"viewer":viewer,"message":"歌单暂时没有可抽取的歌曲"})); return Ok(()); }; let row = db.query_opt("UPDATE viewer_accounts SET points=points-$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 AND points >= $1 RETURNING points", &[&WHEEL_COST,&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?; if let Some(row) = row { let balance:i32=row.get(0); let id = Uuid::new_v4().to_string(); db.execute("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,metadata) VALUES($1::text::uuid,$2,$3,$4,$5,'wheel',$6)",&[&id,&scope,&state.config.room_id,&viewer.uid,&-WHEEL_COST,&json!({"category":category,"songId":song_id})]).await.map_err(|e|e.to_string())?; event(state,"viewer.points.updated",json!({"viewer":viewer,"delta":-WHEEL_COST,"balance":balance,"reason":"wheel"})); event(state,"wheel.result",json!({"viewer":viewer,"category":category,"fallback":fallback,"song":{"id":song_id,"title":title,"tags":tags},"cost":150,"balance":balance})); reply(state, format!("{} 抽中了《{}》", viewer.name, title)).await; } else { let points = db.query_opt("SELECT points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND uid=$3", &[&scope,&state.config.room_id,&viewer.uid]).await.map_err(|e|e.to_string())?.map(|r|r.get::<_,i32>(0)).unwrap_or(0); event(state,"wheel.insufficient-balance",json!({"viewer":viewer,"balance":points,"cost":150})); reply(state, format!("{} 点数不足(需要 150,当前 {})", viewer.name, points)).await; } Ok(()) }
async fn reply(state: &AppState, text: String) { if !state.reply_enabled.load(Ordering::Relaxed) { return; } let cookie = match cookiecloud_cookie(&state.config).await { Ok(cookie) => cookie, Err(e) => { warn!(%e, "reply cookie unavailable"); return; } }; let Some(csrf) = cookie.split(';').find_map(|v| v.trim().strip_prefix("bili_jct=").map(str::to_owned)) else { warn!("reply cookie has no bili_jct"); return; }; let response = reqwest::Client::new().post("https://api.live.bilibili.com/msg/send").header("cookie", cookie).header("referer", format!("https://live.bilibili.com/{}", state.config.room_id)).form(&[("roomid", state.config.room_id.as_str()), ("msg", text.as_str()), ("csrf", csrf.as_str()), ("csrf_token", csrf.as_str())]).send().await; if let Err(e) = response { warn!(%e, "Bilibili reply failed"); } }
async fn random_song(url: &str, category: &str) -> Result<Option<(i32,String,Vec<String>,bool)>,String> { let db=connect(url).await?; let pattern=format!("%{}%",category.trim().split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()); let query="SELECT s.\"Id\",s.\"Title\",COALESCE(array_agg(t.\"Name\") FILTER (WHERE t.\"Name\" IS NOT NULL), '{}') FROM \"Songs\" s LEFT JOIN \"SongTags\" st ON st.\"SongId\"=s.\"Id\" LEFT JOIN \"Tags\" t ON t.\"Id\"=st.\"TagId\" WHERE s.\"IsHidden\"=false AND EXISTS (SELECT 1 FROM \"SongTags\" mst JOIN \"Tags\" mt ON mt.\"Id\"=mst.\"TagId\" WHERE mst.\"SongId\"=s.\"Id\" AND lower(mt.\"NormalizedName\") LIKE $1) GROUP BY s.\"Id\" ORDER BY random() LIMIT 1"; let row=db.query_opt(query,&[&pattern]).await.map_err(|e|e.to_string())?; let fallback=row.is_none(); let row=match row {Some(r)=>r,None=>match db.query_opt("SELECT s.\"Id\",s.\"Title\",COALESCE(array_agg(t.\"Name\") FILTER (WHERE t.\"Name\" IS NOT NULL), '{}') FROM \"Songs\" s LEFT JOIN \"SongTags\" st ON st.\"SongId\"=s.\"Id\" LEFT JOIN \"Tags\" t ON t.\"Id\"=st.\"TagId\" WHERE s.\"IsHidden\"=false GROUP BY s.\"Id\" ORDER BY random() LIMIT 1",&[]).await.map_err(|e|e.to_string())?{Some(r)=>r,None=>return Ok(None)}}; Ok(Some((row.get(0),row.get(1),row.get(2),fallback))) }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_raw_danmaku_with_uid() {
let message = normalize(BiliMessage::Raw(json!({"cmd":"DANMU_MSG:4:0:2:2:2:0","info":[[],"转盘 查询",[12345,"观众"]]}))).expect("danmaku should normalize");
match message {
Incoming::Danmaku { viewer, text } => { assert_eq!(viewer.uid, "12345"); assert_eq!(viewer.name, "观众"); assert_eq!(text, "转盘 查询"); }
_ => panic!("expected danmaku"),
}
}
#[test]
fn normalizes_raw_gift_for_deduplicated_accounting() {
let message = normalize(BiliMessage::Raw(json!({"cmd":"SEND_GIFT","data":{"uid":42,"uname":"送礼者","giftName":"小花花","price":100,"num":3,"tid":"gift-event-1"}}))).expect("gift should normalize");
match message {
Incoming::Gift { viewer, name, battery, quantity, event_id } => { assert_eq!(viewer.uid, "42"); assert_eq!(name, "小花花"); assert_eq!(battery, 100); assert_eq!(quantity, 3); assert_eq!(event_id, "gift-event-1"); }
_ => panic!("expected gift"),
}
}
}
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS viewer_accounts (
scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, display_name TEXT NOT NULL,
points INTEGER NOT NULL DEFAULT 0 CHECK(points >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY(scope, room_id, uid)
);
CREATE TABLE IF NOT EXISTS point_ledger (
id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, delta INTEGER NOT NULL,
reason TEXT NOT NULL, source_event_id TEXT UNIQUE, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS wheel_spins (
id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, category TEXT NOT NULL,
song_id INTEGER NOT NULL, song_title TEXT NOT NULL, fallback BOOLEAN NOT NULL, cost INTEGER NOT NULL, balance_after INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS viewer_accounts_name_idx ON viewer_accounts(scope, room_id, display_name);
CREATE INDEX IF NOT EXISTS point_ledger_recent_idx ON point_ledger(scope, room_id, created_at DESC);
CREATE TABLE IF NOT EXISTS live_session_outbox (
id BIGSERIAL PRIMARY KEY,
streamer_uid BIGINT NOT NULL,
event_ts_ms BIGINT NOT NULL,
payload BYTEA NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS live_session_outbox_due_idx ON live_session_outbox(next_retry_at_ms, id);
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@lxc/server",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"dev": "tsx watch src/index.ts",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.1.1",
"@fastify/websocket": "^11.0.0",
"@laplace.live/ws": "^8.0.1",
"@lxc/protocol": "*",
"fastify": "^5.2.1",
"pg": "^8.13.3",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/pg": "^8.11.11",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^3.1.1"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
const bool = z.enum(["true", "false"]).default("false").transform(v => v === "true");
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: z.coerce.number().int().positive().default(3000), ROOM_ID: z.string().min(1),
DATABASE_URL: z.string().url(), SONGLIST_DATABASE_URL: z.string().url(), COOKIECLOUD_HOST: z.string().url(), COOKIECLOUD_KEY: z.string().min(1), COOKIECLOUD_PASSWORD: z.string().min(1),
ADMIN_PASSWORD: z.string().min(12), SESSION_SECRET: z.string().min(32), OBS_ACCESS_TOKEN: z.string().min(16), BILI_REPLY_ENABLED: bool
});
export type Config = z.infer<typeof schema>;
export const loadConfig = (source = process.env): Config => schema.parse(source);
+44
View File
@@ -0,0 +1,44 @@
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
type Queryable = Pick<Pool, "query">;
export interface ViewerRow { uid: string; displayName: string; points: number; }
export interface LedgerRow { id: string; uid: string; displayName: string; delta: number; reason: string; createdAt: string; }
interface LiveSessionOutboxInsert { streamerUid: number; eventTsMs: number; payload: Uint8Array; }
interface LiveSessionOutboxItem extends LiveSessionOutboxInsert { id: number; retryCount: number; nextRetryAtMs: number; }
interface LiveSessionOutboxUpdate { id: number; retryCount: number; nextRetryAtMs: number; }
export interface LiveSessionOutboxStore { append(items: LiveSessionOutboxInsert[]): Promise<number>; listDue(options: { nowMs: number; limit?: number }): Promise<LiveSessionOutboxItem[]>; ack(ids: number[]): Promise<number>; reschedule(updates: LiveSessionOutboxUpdate[]): Promise<number>; countPending(): Promise<number>; }
export class WheelDatabase {
readonly pool: Pool;
constructor(url: string) { this.pool = new Pool({ connectionString: url }); }
async migrate(directory: string) { for (const file of (await readdir(directory)).filter(x => x.endsWith(".sql")).sort()) await this.pool.query(await readFile(join(directory, file), "utf8")); }
async close() { await this.pool.end(); }
async withTransaction<T>(work: (client: PoolClient) => Promise<T>) { const client = await this.pool.connect(); try { await client.query("BEGIN"); const result = await work(client); await client.query("COMMIT"); return result; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }
async ensureViewer(scope: string, roomId: string, viewer: ViewerRow, client: Queryable = this.pool) {
await client.query("INSERT INTO viewer_accounts(scope,room_id,uid,display_name) VALUES($1,$2,$3,$4) ON CONFLICT(scope,room_id,uid) DO UPDATE SET display_name=EXCLUDED.display_name,updated_at=now()", [scope, roomId, viewer.uid, viewer.displayName]);
}
async getViewer(scope: string, roomId: string, uid: string, client: Queryable = this.pool): Promise<ViewerRow | undefined> { const { rows } = await client.query("SELECT uid, display_name AS \"displayName\", points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND uid=$3", [scope, roomId, uid]); return rows[0]; }
async listViewers(scope: string, roomId: string, search = ""): Promise<ViewerRow[]> { const { rows } = await this.pool.query("SELECT uid,display_name AS \"displayName\",points FROM viewer_accounts WHERE scope=$1 AND room_id=$2 AND (uid ILIKE $3 OR display_name ILIKE $3) ORDER BY updated_at DESC LIMIT 100", [scope, roomId, `%${search}%`]); return rows; }
async listLedger(scope: string, roomId: string): Promise<LedgerRow[]> { const { rows } = await this.pool.query("SELECT l.id,l.uid,a.display_name AS \"displayName\",l.delta,l.reason,l.created_at AS \"createdAt\" FROM point_ledger l JOIN viewer_accounts a ON(a.scope=l.scope AND a.room_id=l.room_id AND a.uid=l.uid) WHERE l.scope=$1 AND l.room_id=$2 ORDER BY l.created_at DESC LIMIT 100", [scope, roomId]); return rows; }
createLiveSessionOutbox(): LiveSessionOutboxStore {
const prune = () => this.pool.query("DELETE FROM live_session_outbox WHERE event_ts_ms < $1", [Date.now() - 7 * 24 * 60 * 60 * 1000]);
return {
append: async items => { if (!items.length) return 0; await prune(); const values: unknown[] = []; const rows = items.map((item, index) => { const offset = index * 4; values.push(Math.floor(item.streamerUid), Math.floor(item.eventTsMs), Buffer.from(item.payload), Math.floor(item.eventTsMs)); return `($${offset + 1},$${offset + 2},$${offset + 3},0,$${offset + 4})`; }).join(","); const result = await this.pool.query(`INSERT INTO live_session_outbox(streamer_uid,event_ts_ms,payload,retry_count,next_retry_at_ms) VALUES ${rows}`, values); return result.rowCount ?? 0; },
listDue: async ({ nowMs, limit = 100 }) => { await prune(); const result = await this.pool.query<{ id: string; streamer_uid: string; event_ts_ms: string; payload: Buffer; retry_count: number; next_retry_at_ms: string }>("SELECT id,streamer_uid,event_ts_ms,payload,retry_count,next_retry_at_ms FROM live_session_outbox WHERE next_retry_at_ms <= $1 ORDER BY next_retry_at_ms,id LIMIT $2", [Math.floor(nowMs), Math.min(500, Math.max(1, Math.floor(limit)))]); return result.rows.map(row => ({ id: Number(row.id), streamerUid: Number(row.streamer_uid), eventTsMs: Number(row.event_ts_ms), payload: new Uint8Array(row.payload), retryCount: row.retry_count, nextRetryAtMs: Number(row.next_retry_at_ms) })); },
ack: async ids => { if (!ids.length) return 0; const result = await this.pool.query("DELETE FROM live_session_outbox WHERE id = ANY($1::bigint[])", [ids]); return result.rowCount ?? 0; },
reschedule: async updates => { if (!updates.length) return 0; const ids = updates.map(item => item.id); const retries = updates.map(item => item.retryCount); const due = updates.map(item => item.nextRetryAtMs); const result = await this.pool.query("UPDATE live_session_outbox AS o SET retry_count = u.retry_count, next_retry_at_ms = u.next_retry_at_ms FROM unnest($1::bigint[],$2::integer[],$3::bigint[]) AS u(id,retry_count,next_retry_at_ms) WHERE o.id=u.id", [ids, retries, due]); return result.rowCount ?? 0; },
countPending: async () => { await prune(); const result = await this.pool.query<{ count: string }>("SELECT COUNT(*)::text AS count FROM live_session_outbox"); return Number(result.rows[0]?.count ?? 0); }
};
}
}
export interface Song { id: number; title: string; tags: string[]; }
export class SongCatalog {
readonly pool: Pool; constructor(url: string) { this.pool = new Pool({ connectionString: url }); }
async close() { await this.pool.end(); }
async random(category: string): Promise<{ song: Song; fallback: boolean } | undefined> {
const categoryKey = category.trim().replace(/\s+/g, " ").toLowerCase();
const select = async (matching: boolean) => (await this.pool.query<Song>(`SELECT s."Id" id,s."Title" title,COALESCE(array_agg(t."Name") FILTER (WHERE t."Name" IS NOT NULL), '{}') tags FROM "Songs" s LEFT JOIN "SongTags" st ON st."SongId"=s."Id" LEFT JOIN "Tags" t ON t."Id"=st."TagId" WHERE s."IsHidden"=false ${matching ? 'AND EXISTS (SELECT 1 FROM "SongTags" mst JOIN "Tags" mt ON mt."Id"=mst."TagId" WHERE mst."SongId"=s."Id" AND lower(mt."NormalizedName") LIKE $1)' : ""} GROUP BY s."Id" ORDER BY random() LIMIT 1`, matching ? [`%${categoryKey}%`] : [])).rows[0];
const match = await select(true); if (match) return { song: match, fallback: false }; const fallback = await select(false); return fallback && { song: fallback, fallback: true };
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { LiveEvent } from "@lxc/protocol";
export class EventHub { private listeners = new Set<(event: LiveEvent) => void>();
publish(event: LiveEvent) { this.listeners.forEach(listener => listener(event)); }
subscribe(listener: (event: LiveEvent) => void) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
get subscriberCount() { return this.listeners.size; }
}
+46
View File
@@ -0,0 +1,46 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import statik from "@fastify/static";
import websocket from "@fastify/websocket";
import { z } from "zod";
import { loadConfig } from "./config.js";
import { WheelDatabase, SongCatalog } from "./db.js";
import { EventHub } from "./hub.js";
import { BilibiliLiveSource, type IncomingLiveMessage } from "./live.js";
import { BilibiliReply, DisabledReply, SwitchableReply } from "./reply.js";
import { WheelService } from "./wheel.js";
const here = dirname(fileURLToPath(import.meta.url));
const equal = (actual: string, expected: string) => { const a = Buffer.from(actual); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); };
export async function buildApp() {
const config = loadConfig(); const app = Fastify({ logger: true }); const database = new WheelDatabase(config.DATABASE_URL); const songs = new SongCatalog(config.SONGLIST_DATABASE_URL); const hub = new EventHub();
const cookieCloudReplyCookie = async () => { const host = config.COOKIECLOUD_HOST.replace(/\/+$/, ""); const response = await fetch(`${host}/get/${encodeURIComponent(config.COOKIECLOUD_KEY)}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ password: config.COOKIECLOUD_PASSWORD }) }); if (!response.ok) throw new Error(`CookieCloud download failed: HTTP ${response.status}`); const payload = await response.json() as { cookie_data?: Record<string, Record<string, { name: string; value: string; domain: string }>> }; const cookies = Object.values(payload.cookie_data ?? {}).flatMap(domain => Object.values(domain)).filter(cookie => cookie.domain.includes("bilibili.com")); if (!cookies.length) throw new Error("CookieCloud has no bilibili.com cookies"); return cookies.map(cookie => `${cookie.name}=${cookie.value}`).join("; "); };
const rawReply = config.BILI_REPLY_ENABLED ? new BilibiliReply(config.ROOM_ID, cookieCloudReplyCookie) : new DisabledReply("Reply disabled by configuration"); const reply = new SwitchableReply(rawReply, config.BILI_REPLY_ENABLED);
const service = new WheelService(database, songs, hub, reply, config.ROOM_ID); const source = new BilibiliLiveSource(config);
const sessionFor = () => createHmac("sha256", config.SESSION_SECRET).update("admin").digest("base64url"); const admin = (request: any) => equal(request.cookies?.lxc_session ?? "", sessionFor());
await app.register(cookie); await app.register(websocket);
const webRoot = join(here, "../../web/dist"); if (existsSync(webRoot)) await app.register(statik, { root: webRoot, prefix: "/" });
app.get("/health", async () => ({ ok: true, roomId: config.ROOM_ID, subscribers: hub.subscriberCount }));
app.post("/api/auth/login", async (request, replyTo) => { const body = z.object({ password: z.string() }).parse(request.body); if (!equal(body.password, config.ADMIN_PASSWORD)) return replyTo.code(401).send({ error: "Invalid password" }); replyTo.setCookie("lxc_session", sessionFor(), { httpOnly: true, secure: "auto", sameSite: "lax", path: "/", maxAge: 60 * 60 * 12 }); return { ok: true }; });
app.post("/api/auth/logout", async (_request, replyTo) => { replyTo.clearCookie("lxc_session", { path: "/" }); return { ok: true }; });
const guard = async (request: any, replyTo: any) => { if (!admin(request)) return replyTo.code(401).send({ error: "Administrator authentication required" }); };
app.get("/api/admin/status", { preHandler: guard }, async () => ({ roomId: config.ROOM_ID, source: source.status(), reply: reply.status(), websocketClients: hub.subscriberCount, cookieCloudHost: config.COOKIECLOUD_HOST }));
app.get("/api/admin/viewers", { preHandler: guard }, async request => database.listViewers("live", config.ROOM_ID, String((request.query as any).search ?? "")));
app.get("/api/admin/ledger", { preHandler: guard }, async () => database.listLedger("live", config.ROOM_ID));
app.post("/api/admin/reconnect", { preHandler: guard }, async () => { await source.stop(); await source.start(); return { ok: true, source: source.status() }; });
app.post("/api/admin/reply", { preHandler: guard }, async request => { reply.enabled = z.object({ enabled: z.boolean() }).parse(request.body).enabled; return reply.status(); });
app.get("/api/admin/obs-url", { preHandler: guard }, async request => `${request.protocol}://${request.hostname}/obs?token=${encodeURIComponent(config.OBS_ACCESS_TOKEN)}`);
app.post("/api/test/event", { preHandler: guard }, async request => { const data = z.discriminatedUnion("kind", [z.object({ kind: z.literal("enter"), uid: z.string(), name: z.string() }), z.object({ kind: z.literal("gift"), uid: z.string(), name: z.string(), giftName: z.string().default("测试礼物"), battery: z.number().int().nonnegative(), quantity: z.number().int().positive().default(1) }), z.object({ kind: z.literal("danmaku"), uid: z.string(), name: z.string(), text: z.string() })]).parse(request.body); const viewer = { uid: data.uid, name: data.name }; const message: IncomingLiveMessage = data.kind === "gift" ? { ...data, viewer, sourceEventId: `test-${crypto.randomUUID()}` } : data.kind === "enter" ? { kind: "enter", viewer } : { kind: "danmaku", viewer, text: data.text }; await service.handle(message, "test"); return { ok: true }; });
app.get("/ws", { websocket: true }, (socket, request) => { const token = String((request.query as any)?.token ?? ""); if (!(admin(request) || equal(token, config.OBS_ACCESS_TOKEN))) { socket.close(1008, "Unauthorized"); return; } const unsubscribe = hub.subscribe(event => { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(event)); }); socket.on("close", unsubscribe); });
const webPage = async (_request: unknown, replyTo: any) => { if (!existsSync(join(webRoot, "index.html"))) return replyTo.code(503).send("Web frontend has not been built"); return replyTo.sendFile("index.html"); };
app.get("/admin", webPage); app.get("/test", webPage); app.get("/obs", webPage);
app.addHook("onClose", async () => { await source.stop(); await database.close(); await songs.close(); });
await database.migrate(join(here, "../migrations")); source.onMessage(message => void service.handle(message)); await source.start(); return app;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) { const app = await buildApp(); const config = loadConfig(); await app.listen({ host: "0.0.0.0", port: config.PORT }); }
+55
View File
@@ -0,0 +1,55 @@
import { EventEmitter } from "node:events";
import { KeepLiveWS } from "@laplace.live/ws";
import { makeEvent, type Viewer } from "@lxc/protocol";
import type { Config } from "./config.js";
import type { EventHub } from "./hub.js";
export interface IncomingEnter { kind: "enter"; viewer: Viewer; }
export interface IncomingGift { kind: "gift"; viewer: Viewer; giftName: string; battery: number; quantity: number; sourceEventId: string; }
export interface IncomingDanmaku { kind: "danmaku"; viewer: Viewer; text: string; }
export type IncomingLiveMessage = IncomingEnter | IncomingGift | IncomingDanmaku;
export interface LiveSource { start(): Promise<void>; stop(): Promise<void>; onMessage(handler: (message: IncomingLiveMessage) => void): () => void; status(): { connected: boolean; cookieCloud: boolean; detail?: string }; }
/** Maps the fields used by common Bilibili CMD payloads without leaking raw data into business code. */
export function normalizeCoreMessage(raw: any): IncomingLiveMessage | undefined {
const cmd = String(raw?.cmd ?? "").split(":")[0];
if (cmd === "DANMU_MSG") { const info = raw.info ?? []; const user = info[2] ?? []; return { kind: "danmaku", viewer: { uid: String(user[0] ?? ""), name: String(user[1] ?? "匿名观众") }, text: String(info[1] ?? "") }; }
if (cmd === "SEND_GIFT") { const data = raw.data ?? raw; return { kind: "gift", viewer: { uid: String(data.uid ?? ""), name: String(data.uname ?? "匿名观众") }, giftName: String(data.giftName ?? "礼物"), battery: Number(data.price ?? 0), quantity: Number(data.num ?? 1), sourceEventId: String(data.tid ?? `${data.uid}-${data.timestamp}-${data.giftId}-${data.num}`) }; }
if (cmd === "INTERACT_WORD") { const data = raw.data ?? raw; return { kind: "enter", viewer: { uid: String(data.uid ?? ""), name: String(data.uname ?? "匿名观众") } }; }
return undefined;
}
interface CookieCloudCookie { name: string; value: string; domain: string; }
interface CookieCloudPayload { cookie_data?: Record<string, Record<string, CookieCloudCookie>>; }
interface DanmakuInfo { data?: { token?: string; host_list?: Array<{ host?: string; wss_port?: number }>; host_server_list?: Array<{ host?: string; wss_port?: number }> }; message?: string; code?: number; }
export class BilibiliLiveSource implements LiveSource {
private emitter = new EventEmitter(); private client?: KeepLiveWS; private connected = false; private detail?: string; private cookieReady = false;
constructor(private readonly config: Config) {}
onMessage(handler: (message: IncomingLiveMessage) => void) { this.emitter.on("message", handler); return () => this.emitter.off("message", handler); }
status() { return { connected: this.connected, cookieCloud: this.cookieReady, detail: this.detail }; }
async start() {
try {
await this.cookieHeader(); const connection = await this.connectionInfo(); this.client = new KeepLiveWS(Number(this.config.ROOM_ID), { key: connection.key, address: connection.address });
// `@laplace.live/ws` dispatches the complete upstream packet on `msg`.
// Concrete event names preserve Bilibili suffixes (for example
// `DANMU_MSG:4:0`), so listening to bare command names loses messages.
this.client.addEventListener("msg", (event: any) => {
const raw = event.data?.msg ?? event.data;
const message = normalizeCoreMessage(raw);
if (message?.viewer.uid) this.emitter.emit("message", message);
});
this.client.addEventListener("heartbeat", () => { this.connected = true; this.detail = "Connected; waiting for live messages"; }); this.client.addEventListener("live", () => { this.connected = true; this.detail = "Connected; waiting for live messages"; }); this.client.addEventListener("close", () => { this.connected = false; }); this.client.addEventListener("e", () => { this.connected = false; this.detail = "Bilibili WebSocket disconnected; reconnecting"; });
} catch (error) { this.detail = error instanceof Error ? error.message : String(error); this.connected = false; }
}
async stop() { this.client?.close(); this.client = undefined; this.connected = false; }
private async cookieHeader() { const host = this.config.COOKIECLOUD_HOST.replace(/\/+$/, ""); const response = await fetch(`${host}/get/${encodeURIComponent(this.config.COOKIECLOUD_KEY)}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ password: this.config.COOKIECLOUD_PASSWORD }) }); if (!response.ok) throw new Error(`CookieCloud download failed: HTTP ${response.status}`); const payload = await response.json() as CookieCloudPayload; const cookies = Object.values(payload.cookie_data ?? {}).flatMap(domain => Object.values(domain)).filter(cookie => cookie.domain.includes("bilibili.com")); if (!cookies.length) throw new Error("CookieCloud has no bilibili.com cookies"); this.cookieReady = true; }
private async connectionInfo() { const roomId = encodeURIComponent(this.config.ROOM_ID); const response = await fetch(`https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id=${roomId}&platform=pc&player=web`, { headers: { accept: "application/json, text/plain, */*", referer: `https://live.bilibili.com/${roomId}`, "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" } }); const json = await response.json() as DanmakuInfo; const key = json.data?.token; const server = json.data?.host_server_list?.[0] ?? json.data?.host_list?.[0]; if (!response.ok || !key || !server?.host) throw new Error(`Bilibili Danmu getConf failed: ${json.message ?? "missing token"} (code ${json.code ?? response.status})`); return { key, address: `wss://${server.host}:${server.wss_port ?? 443}/sub` }; }
}
export class ManualLiveSource implements LiveSource {
private emitter = new EventEmitter(); async start() {} async stop() {} status() { return { connected: true, cookieCloud: true, detail: "manual test source" }; }
onMessage(handler: (message: IncomingLiveMessage) => void) { this.emitter.on("message", handler); return () => this.emitter.off("message", handler); }
emit(message: IncomingLiveMessage) { this.emitter.emit("message", message); }
}
export function publishUnknown(hub: EventHub, roomId: string, cmd: string, raw: unknown) { hub.publish(makeEvent(roomId, "live.unknown", { cmd, raw })); }
+17
View File
@@ -0,0 +1,17 @@
export interface ReplyStatus { enabled: boolean; available: boolean; detail?: string; }
export interface ReplyPort { status(): ReplyStatus; send(text: string): Promise<void>; }
export class DisabledReply implements ReplyPort { constructor(private readonly detail: string) {} status() { return { enabled: false, available: false, detail: this.detail }; } async send() {} }
export class SwitchableReply implements ReplyPort {
enabled: boolean;
constructor(private readonly delegate: ReplyPort, enabled: boolean) { this.enabled = enabled; }
status() { const status = this.delegate.status(); return this.enabled ? status : { ...status, enabled: false, detail: "Disabled by administrator" }; }
async send(text: string) { if (this.enabled) await this.delegate.send(text); }
}
/** Bilibili sender with a queue. COOKIE must include bili_jct; it is intentionally isolated from scoring. */
export class BilibiliReply implements ReplyPort {
private next = Promise.resolve(); private lastSent = 0;
private lastError?: string;
constructor(private readonly roomId: string, private readonly cookieProvider: () => Promise<string>) {}
status() { return { enabled: true, available: !this.lastError, detail: this.lastError ?? "CookieCloud-backed reply ready" }; }
send(text: string) { this.next = this.next.then(async () => { const cookie = await this.cookieProvider(); const csrf = cookie.match(/(?:^|;\s*)bili_jct=([^;]+)/)?.[1]; if (!csrf) throw new Error("CookieCloud Bilibili Cookie is missing bili_jct"); const wait = Math.max(0, 3000 - (Date.now() - this.lastSent)); if (wait) await new Promise(r => setTimeout(r, wait)); const body = new URLSearchParams({ roomid: this.roomId, msg: text.slice(0, 100), csrf, csrf_token: csrf }); const response = await fetch("https://api.live.bilibili.com/msg/send", { method: "POST", headers: { cookie, "content-type": "application/x-www-form-urlencoded", referer: `https://live.bilibili.com/${this.roomId}` }, body }); if (!response.ok) throw new Error(`Bilibili reply failed: ${response.status}`); this.lastError = undefined; this.lastSent = Date.now(); }).catch(error => { this.lastError = error instanceof Error ? error.message : String(error); }); return this.next; }
}
+3
View File
@@ -0,0 +1,3 @@
import { describe, expect, it, vi } from "vitest";
import { WHEEL_COST } from "./wheel.js";
describe("wheel rules", () => { it("uses the agreed fixed cost", () => expect(WHEEL_COST).toBe(150)); it("normalizes spaces", () => expect("转盘 摇滚".trim().replace(/\s+/g, " ")).toBe("转盘 摇滚")); });
+45
View File
@@ -0,0 +1,45 @@
import { randomUUID } from "node:crypto";
import { makeEvent, type Viewer } from "@lxc/protocol";
import type { WheelDatabase, SongCatalog, ViewerRow } from "./db.js";
import type { EventHub } from "./hub.js";
import type { ReplyPort } from "./reply.js";
import type { IncomingLiveMessage } from "./live.js";
export const WHEEL_COST = 150;
const normal = (text: string) => text.trim().replace(/\s+/g, " ");
const toViewerRow = (viewer: Viewer): ViewerRow => ({ uid: viewer.uid, displayName: viewer.name, points: 0 });
export class WheelService {
constructor(private readonly database: WheelDatabase, private readonly songs: SongCatalog, private readonly hub: EventHub, private readonly reply: ReplyPort, private readonly roomId: string) {}
async handle(message: IncomingLiveMessage, scope = "live") {
const viewer = message.viewer; await this.database.ensureViewer(scope, this.roomId, toViewerRow(viewer));
if (message.kind === "enter") { this.hub.publish(makeEvent(this.roomId, "live.enter", { viewer })); return; }
if (message.kind === "gift") return this.gift(message, scope);
this.hub.publish(makeEvent(this.roomId, "live.danmaku", { viewer, text: message.text })); return this.command(viewer, message.text, scope);
}
private async gift(message: Extract<IncomingLiveMessage, { kind: "gift" }>, scope: string) {
const amount = Math.max(0, Math.floor(message.battery)) * Math.max(1, Math.floor(message.quantity));
const result = await this.database.withTransaction(async client => {
await this.database.ensureViewer(scope, this.roomId, toViewerRow(message.viewer), client);
const inserted = await client.query("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,source_event_id,metadata) VALUES($1,$2,$3,$4,$5,'gift',$6,$7) ON CONFLICT(source_event_id) DO NOTHING RETURNING id", [randomUUID(), scope, this.roomId, message.viewer.uid, amount, message.sourceEventId, JSON.stringify({ giftName: message.giftName, battery: message.battery, quantity: message.quantity })]);
if (!inserted.rowCount) return undefined;
const updated = await client.query<{ points: number }>("UPDATE viewer_accounts SET points=points+$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 RETURNING points", [amount, scope, this.roomId, message.viewer.uid]); return updated.rows[0]?.points;
});
this.hub.publish(makeEvent(this.roomId, "live.gift", { viewer: message.viewer, giftName: message.giftName, battery: message.battery, quantity: message.quantity, sourceEventId: message.sourceEventId }));
if (result !== undefined) this.hub.publish(makeEvent(this.roomId, "viewer.points.updated", { viewer: message.viewer, delta: amount, balance: result, reason: "gift" }));
}
private async command(viewer: Viewer, raw: string, scope: string) {
const text = normal(raw); if (text === "转盘查询") { const account = await this.database.getViewer(scope, this.roomId, viewer.uid); const balance = account?.points ?? 0; const event = makeEvent(this.roomId, "viewer.points.updated", { viewer, delta: 0, balance, reason: "gift" }); this.hub.publish(event); await this.reply.send(`${viewer.name} 当前转盘点数:${balance}`); return; }
if (text === "转盘") return this.invalid(viewer, "用法:转盘 [类别]");
const match = /^转盘\s+(.+)$/.exec(text); if (!match) return;
const category = match[1]!; const choice = await this.songs.random(category); if (!choice) return this.invalid(viewer, "歌单暂时没有可抽取的歌曲");
const result = await this.database.withTransaction(async client => {
const current = await this.database.getViewer(scope, this.roomId, viewer.uid, client); const balance = current?.points ?? 0; if (balance < WHEEL_COST) return { insufficient: true as const, balance };
const update = await client.query<{ points: number }>("UPDATE viewer_accounts SET points=points-$1,updated_at=now() WHERE scope=$2 AND room_id=$3 AND uid=$4 AND points >= $1 RETURNING points", [WHEEL_COST, scope, this.roomId, viewer.uid]); if (!update.rowCount) return { insufficient: true as const, balance: 0 };
const balanceAfter = update.rows[0]!.points; await client.query("INSERT INTO point_ledger(id,scope,room_id,uid,delta,reason,metadata) VALUES($1,$2,$3,$4,$5,'wheel',$6)", [randomUUID(), scope, this.roomId, viewer.uid, -WHEEL_COST, JSON.stringify({ category, songId: choice.song.id })]); await client.query("INSERT INTO wheel_spins(id,scope,room_id,uid,category,song_id,song_title,fallback,cost,balance_after) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", [randomUUID(), scope, this.roomId, viewer.uid, category, choice.song.id, choice.song.title, choice.fallback, WHEEL_COST, balanceAfter]); return { insufficient: false as const, balance: balanceAfter };
});
if (result.insufficient) { this.hub.publish(makeEvent(this.roomId, "wheel.insufficient-balance", { viewer, balance: result.balance, cost: WHEEL_COST })); await this.reply.send(`${viewer.name} 点数不足(需要 ${WHEEL_COST},当前 ${result.balance})`); return; }
this.hub.publish(makeEvent(this.roomId, "viewer.points.updated", { viewer, delta: -WHEEL_COST, balance: result.balance, reason: "wheel" })); this.hub.publish(makeEvent(this.roomId, "wheel.result", { viewer, category, fallback: choice.fallback, song: choice.song, cost: WHEEL_COST, balance: result.balance })); await this.reply.send(`${viewer.name} 抽中了《${choice.song.title}》`);
}
private async invalid(viewer: Viewer, message: string) { this.hub.publish(makeEvent(this.roomId, "wheel.invalid-command", { viewer, message })); await this.reply.send(`${viewer.name}:${message}`); }
}
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { WHEEL_COST } from "../dist/wheel.js";
import { normalizeCoreMessage } from "../dist/live.js";
test("wheel cost is 150", () => assert.equal(WHEEL_COST, 150));
test("Bilibili gift messages normalize to typed inputs", () => {
const event = normalizeCoreMessage({ cmd: "SEND_GIFT", data: { uid: 4, uname: "测试", giftName: "辣条", price: 150, num: 2, tid: "gift-1" } });
assert.deepEqual(event, { kind: "gift", viewer: { uid: "4", name: "测试" }, giftName: "辣条", battery: 150, quantity: 2, sourceEventId: "gift-1" });
});
test("Bilibili danmaku commands with a suffix normalize from their raw packet", () => {
const event = normalizeCoreMessage({ cmd: "DANMU_MSG:4:0", info: [[], "转盘 流行", [42, "观众"]] });
assert.deepEqual(event, { kind: "danmaku", viewer: { uid: "42", name: "观众" }, text: "转盘 流行" });
});
test("Bilibili enter packets normalize from their raw packet", () => {
const event = normalizeCoreMessage({ cmd: "INTERACT_WORD", data: { uid: 7, uname: "进房观众" } });
assert.deepEqual(event, { kind: "enter", viewer: { uid: "7", name: "进房观众" } });
});
+1
View File
@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] }
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@lxc/web",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "tsc -b && vite build", "check": "tsc -b --pretty false" },
"dependencies": { "@lxc/live-client": "*", "@lxc/protocol": "*", "react": "^19.0.0", "react-dom": "^19.0.0" },
"devDependencies": { "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.4.1", "typescript": "^5.8.3", "vite": "^6.2.2" }
}
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { LiveClient } from "@lxc/live-client";
import type { LiveEvent } from "@lxc/protocol";
import "./style.css";
const api = async <T,>(url: string, options?: RequestInit): Promise<T> => { const response = await fetch(url, { headers: { "content-type": "application/json", ...(options?.headers ?? {}) }, ...options }); if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error); return response.json(); };
const wsUrl = (token?: string) => `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
function useEvents(token?: string) { const [events, setEvents] = useState<LiveEvent[]>([]); useEffect(() => { const client = new LiveClient({ url: wsUrl(token) }); client.onAny(event => setEvents(current => [event, ...current].slice(0, 30))); client.connect(); return () => client.close(); }, [token]); return events; }
function Login({ onSuccess }: { onSuccess(): void }) { const [password, setPassword] = useState(""); const [error, setError] = useState(""); return <main className="login"><h1>洛星瓷直播转盘</h1><form onSubmit={async e => { e.preventDefault(); try { await api("/api/auth/login", { method: "POST", body: JSON.stringify({ password }) }); onSuccess(); } catch (e) { setError((e as Error).message); } }}><input aria-label="管理员密码" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="管理员密码"/><button>登录</button>{error && <p className="error">{error}</p>}</form></main> }
function Admin() { const [loggedIn, setLoggedIn] = useState(false); const [status, setStatus] = useState<any>(); const [viewers, setViewers] = useState<any[]>([]); const [ledger, setLedger] = useState<any[]>([]); const [search, setSearch] = useState(""); const events = useEvents(); const load = async () => { try { setStatus(await api("/api/admin/status")); setViewers(await api(`/api/admin/viewers?search=${encodeURIComponent(search)}`)); setLedger(await api("/api/admin/ledger")); setLoggedIn(true); } catch { setLoggedIn(false); } }; useEffect(() => { void load(); }, [search]); if (!loggedIn) return <Login onSuccess={load}/>; return <main><header><h1>管理员控制台</h1><nav><a href="/test">测试页面</a><a href="/obs" target="_blank">OBS 展示</a><button onClick={() => void api("/api/auth/logout", { method: "POST" }).then(() => setLoggedIn(false))}>退出</button></nav></header><section className="cards"><article><b>直播连接</b><p>{status?.source.connected ? "已连接" : "未连接"}</p><small>{status?.source.detail}</small><button onClick={() => void api("/api/admin/reconnect", { method: "POST" }).then(load)}>重连</button></article><article><b>CookieCloud</b><p>{status?.source.cookieCloud ? "可用" : "异常"}</p><small>{status?.cookieCloudHost}</small></article><article><b>聊天回复</b><p>{status?.reply.enabled ? "已启用" : "已关闭"}</p><small>{status?.reply.detail}</small><button onClick={() => void api("/api/admin/reply", { method: "POST", body: JSON.stringify({ enabled: !status?.reply.enabled }) }).then(load)}>切换</button></article><article><b>OBS</b><button onClick={() => void api<string>("/api/admin/obs-url").then(url => navigator.clipboard.writeText(url))}>复制浏览器源地址</button></article></section><section><h2>观众点数</h2><input value={search} onChange={e => setSearch(e.target.value)} placeholder="UID 或昵称"/><table><thead><tr><th>UID</th><th>昵称</th><th>点数</th></tr></thead><tbody>{viewers.map(v => <tr key={v.uid}><td>{v.uid}</td><td>{v.displayName}</td><td>{v.points}</td></tr>)}</tbody></table></section><section><h2>最近流水</h2><table><thead><tr><th>观众</th><th>变化</th><th>原因</th><th>时间</th></tr></thead><tbody>{ledger.map(x => <tr key={x.id}><td>{x.displayName}</td><td>{x.delta}</td><td>{x.reason}</td><td>{new Date(x.createdAt).toLocaleString()}</td></tr>)}</tbody></table></section><EventList events={events}/></main> }
function TestPage() { const [allowed, setAllowed] = useState<boolean>(); const [form, setForm] = useState({ kind: "gift", uid: "test-1", name: "测试观众", giftName: "测试礼物", battery: 150, quantity: 1, text: "转盘 流行" }); const [notice, setNotice] = useState(""); useEffect(() => { void api("/api/admin/status").then(() => setAllowed(true)).catch(() => setAllowed(false)); }, []); if (allowed === false) return <Login onSuccess={() => setAllowed(true)}/>; if (allowed === undefined) return <main>正在验证管理员会话…</main>; const submit = async (kind: string) => { try { const body: any = { kind, uid: form.uid, name: form.name }; if (kind === "gift") Object.assign(body, { giftName: form.giftName, battery: Number(form.battery), quantity: Number(form.quantity) }); if (kind === "danmaku") body.text = form.text; await api("/api/test/event", { method: "POST", body: JSON.stringify(body) }); setNotice("测试事件已发出;请在 OBS 展示页确认效果。"); } catch (e) { setNotice((e as Error).message); } }; const set = (name: string, value: any) => setForm(f => ({ ...f, [name]: value })); return <main><header><h1>弹幕触发测试</h1><nav><a href="/admin">管理台</a><a href="/obs" target="_blank">OBS 展示</a></nav></header><p>测试使用独立的测试账户和点数范围,不影响真实观众或聊天回复。</p><section className="form"><label>UID<input value={form.uid} onChange={e => set("uid", e.target.value)}/></label><label>昵称<input value={form.name} onChange={e => set("name", e.target.value)}/></label><label>礼物名称<input value={form.giftName} onChange={e => set("giftName", e.target.value)}/></label><label>电池<input type="number" value={form.battery} onChange={e => set("battery", e.target.value)}/></label><label>数量<input type="number" value={form.quantity} onChange={e => set("quantity", e.target.value)}/></label><label>弹幕<input value={form.text} onChange={e => set("text", e.target.value)}/></label><div><button onClick={() => void submit("enter")}>模拟进房</button><button onClick={() => void submit("gift")}>模拟礼物</button><button onClick={() => void submit("danmaku")}>模拟弹幕</button></div>{notice && <p>{notice}</p>}</section></main> }
function EventList({ events }: { events: LiveEvent[] }) { return <section><h2>实时事件</h2><ol className="events">{events.map(event => <li key={event.id}><b>{event.type}</b> <code>{JSON.stringify(event.payload)}</code></li>)}</ol></section> }
function Obs() { const token = new URLSearchParams(location.search).get("token") ?? undefined; const events = useEvents(token); const latest = events[0]; return <main className="obs">{latest ? <div className={`event event-${latest.type.replaceAll(".", "-")}`}><span className="type">{latest.type}</span>{latest.type === "wheel.result" ? <><h1>{latest.payload.viewer.name} 抽中了</h1><h2>《{latest.payload.song.title}》</h2><p>{latest.payload.category} · 剩余 {latest.payload.balance} 点</p></> : latest.type === "live.gift" ? <><h1>{latest.payload.viewer.name} 送出 {latest.payload.giftName}</h1><h2>+{latest.payload.battery * latest.payload.quantity} 点</h2></> : latest.type === "live.enter" ? <h1>欢迎 {latest.payload.viewer.name} 进入直播间</h1> : <h2>{JSON.stringify(latest.payload)}</h2>}</div> : <div className="event standby">等待直播事件…</div>}</main> }
const path = location.pathname; createRoot(document.getElementById("root")!).render(path === "/test" ? <TestPage/> : path === "/obs" ? <Obs/> : <Admin/>);
+1
View File
@@ -0,0 +1 @@
:root{font-family:system-ui,"Microsoft YaHei",sans-serif;color:#f5ecff;background:#160d24}*{box-sizing:border-box}body{margin:0}main{max-width:1100px;margin:auto;padding:2rem}header{display:flex;justify-content:space-between;align-items:center;gap:1rem}nav{display:flex;gap:.75rem;align-items:center}a,button{color:#fff;background:#7b36d7;border:0;border-radius:.5rem;padding:.55rem .8rem;text-decoration:none;cursor:pointer}button:hover{background:#9956eb}input{display:block;width:100%;margin:.3rem 0 1rem;padding:.6rem;border:1px solid #765497;border-radius:.4rem;background:#29163d;color:#fff}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1rem;margin:1.5rem 0}.cards article,section,.form{background:#241334;border-radius:.8rem;padding:1rem}.cards button{margin-top:.5rem}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:.55rem;border-bottom:1px solid #4a2b67}.events{max-height:300px;overflow:auto;padding-left:1.5rem}.events li{margin:.5rem 0}.events code{word-break:break-all}.form{max-width:520px}.form label{display:block}.form div{display:flex;gap:.5rem;flex-wrap:wrap}.login{max-width:380px;margin-top:12vh}.error{color:#ff9da8}.obs{max-width:none;min-height:100vh;display:grid;place-items:center;background:transparent}.event{padding:2.5rem 4rem;text-align:center;border:3px solid #e6c7ff;border-radius:1rem;background:#180d26dd;box-shadow:0 0 40px #a94eff}.event h1{font-size:3rem;margin:.3rem}.event h2{font-size:4rem;margin:.3rem;color:#f9d86e}.type{opacity:.65}@media(max-width:650px){main{padding:1rem}header{align-items:flex-start;flex-direction:column}.event{padding:1rem}.event h1{font-size:1.8rem}.event h2{font-size:2.5rem}}
+1
View File
@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "Bundler", "allowImportingTsExtensions": false, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true }, "include": ["src"] }
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });
+8
View File
@@ -0,0 +1,8 @@
services:
app:
build: .
restart: unless-stopped
network_mode: host
volumes:
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
- ./config.toml:/app/config.toml:ro
+49
View File
@@ -0,0 +1,49 @@
# 洛星瓷直播转盘服务配置。
#
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项;该文件包含
# CookieCloud、数据库和访问令牌等敏感信息,请不要提交或公开分享。
# Docker Compose 会将它以只读方式挂载到容器的 /app/config.toml。
# 与 blivedm_rs 一致的连接配置段。实际 Bilibili Cookie 不写在这里,
# 服务会通过下方 [cookiecloud] 在运行时获取最新 Cookie。
[connection]
room_id = "000000"
# cookies = "SESSDATA=..." # 不需要;由 CookieCloud 托管。
# HTTP、WebSocket 及静态前端监听端口。host 网络模式下为宿主机端口。
[server]
port = 9719
# 转盘系统自己的读写数据库。此账号必须可创建和修改业务表。
[database]
url = "postgresql://wheel:replace-with-wheel-password@127.0.0.1:5432/wheel?sslmode=disable"
# 洛星瓷歌单数据库,只用于读取歌曲和标签,建议使用只读账号。
[songlist]
database_url = "postgresql://lxc_songlist:replace-with-songlist-password@127.0.0.1:5432/lxc_songlist?sslmode=disable"
# 外部部署的 CookieCloud 实例。key 是同步 UUID;password 是同步密码。
[cookiecloud]
host = "http://127.0.0.1:8088"
key = "replace-with-cookiecloud-uuid"
password = "replace-with-cookiecloud-password"
# 管理台登录密码和用于签发 HTTP-only 会话 Cookie 的随机密钥。
# session_secret 建议至少 32 个随机字符。
[admin]
password = "replace-with-a-long-admin-password"
session_secret = "replace-with-at-least-32-random-characters"
# OBS 页面只读订阅令牌;浏览器源 URL 为 /obs?token=<access_token>。
[obs]
access_token = "replace-with-a-long-random-obs-token"
# 启用后,服务会用 CookieCloud 中的 bili_jct 和 Cookie 向直播间发送回复。
# 回复失败不会影响记账、抽歌或 OBS 推送。
[reply]
enabled = false
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。
[logging]
filter = "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn"
+4266
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "lxc-streamutils",
"private": true,
"packageManager": "pnpm@10.12.1",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "npm --workspace @lxc/protocol run build && npm --workspace @lxc/live-client run build && npm --workspace @lxc/server run build && npm --workspace @lxc/web run build",
"check": "npm --workspace @lxc/protocol run check && npm --workspace @lxc/live-client run check && npm --workspace @lxc/server run check && npm --workspace @lxc/web run check",
"test": "npm run build && npm --workspace @lxc/protocol run test && npm --workspace @lxc/live-client run test && npm --workspace @lxc/server run test",
"dev": "npm --workspace @lxc/server run dev"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@lxc/live-client",
"version": "0.1.0",
"type": "module",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"scripts": { "build": "tsc -p tsconfig.json", "check": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.mjs" },
"dependencies": { "@lxc/protocol": "*" },
"devDependencies": { "typescript": "^5.8.3", "vitest": "^3.1.1" }
}
+6
View File
@@ -0,0 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { LiveClient } from "./index.js";
describe("LiveClient", () => it("dispatches validated messages", () => {
const socket: any = { close: vi.fn() }; const client = new LiveClient({ url: "ws://test", websocketFactory: () => socket }); const fn = vi.fn(); client.on("live.enter", fn); client.connect();
socket.onmessage({ data: JSON.stringify({ version: 1, id: "a0000000-0000-4000-8000-000000000001", occurredAt: new Date().toISOString(), roomId: "1", type: "live.enter", payload: { viewer: { uid: "1", name: "a" } } }) }); expect(fn).toHaveBeenCalledOnce();
}));
+22
View File
@@ -0,0 +1,22 @@
import { liveEventSchema, type EventType, type LiveEvent } from "@lxc/protocol";
type Handler<T extends EventType = EventType> = (event: Extract<LiveEvent, { type: T }>) => void;
export interface LiveClientOptions { url: string; protocols?: string | string[]; minReconnectMs?: number; maxReconnectMs?: number; websocketFactory?: (url: string, protocols?: string | string[]) => WebSocket; }
export class LiveClient {
private ws?: WebSocket; private closed = false; private retry = 0;
private readonly typed = new Map<EventType, Set<Handler>>(); private readonly any = new Set<Handler>();
constructor(private readonly options: LiveClientOptions) {}
connect() { this.closed = false; this.open(); }
close() { this.closed = true; this.ws?.close(); }
on<T extends EventType>(type: T, handler: Handler<T>) { const set = this.typed.get(type) ?? new Set(); const untyped = handler as unknown as Handler; set.add(untyped); this.typed.set(type, set); return () => set.delete(untyped); }
onAny(handler: Handler) { this.any.add(handler); return () => this.any.delete(handler); }
private open() {
const Factory = this.options.websocketFactory ?? ((url: string, protocols?: string | string[]) => new WebSocket(url, protocols));
this.ws = Factory(this.options.url, this.options.protocols);
this.ws.onopen = () => { this.retry = 0; };
this.ws.onmessage = ({ data }) => { try { const event = liveEventSchema.parse(JSON.parse(String(data))); this.typed.get(event.type)?.forEach(fn => fn(event)); this.any.forEach(fn => fn(event)); } catch { /* reject malformed server data */ } };
this.ws.onclose = () => this.scheduleReconnect(); this.ws.onerror = () => this.ws?.close();
}
private scheduleReconnect() { if (this.closed) return; const min = this.options.minReconnectMs ?? 500; const max = this.options.maxReconnectMs ?? 10_000; const delay = Math.min(max, min * 2 ** this.retry++); setTimeout(() => this.open(), delay); }
}
@@ -0,0 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { LiveClient } from "../dist/index.js";
test("client dispatches a validated server event", () => {
const socket = {}; const client = new LiveClient({ url: "ws://invalid", websocketFactory: () => socket }); let received;
client.on("live.enter", event => { received = event; }); client.connect();
socket.onmessage({ data: JSON.stringify({ version: 1, id: "a0000000-0000-4000-8000-000000000001", occurredAt: new Date().toISOString(), roomId: "1", type: "live.enter", payload: { viewer: { uid: "1", name: "A" } } }) });
assert.equal(received.payload.viewer.name, "A");
});
+1
View File
@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@lxc/protocol",
"version": "0.1.0",
"type": "module",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"scripts": { "build": "tsc -p tsconfig.json", "check": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.mjs" },
"dependencies": { "zod": "^3.24.2" },
"devDependencies": { "typescript": "^5.8.3", "vitest": "^3.1.1" }
}
+6
View File
@@ -0,0 +1,6 @@
import { describe, expect, it } from "vitest";
import { liveEventSchema, makeEvent } from "./index.js";
describe("protocol", () => it("parses a typed event", () => {
const event = makeEvent("1", "live.enter", { viewer: { uid: "42", name: "观众" } });
expect(liveEventSchema.parse(event).type).toBe("live.enter");
}));
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod";
export const PROTOCOL_VERSION = 1 as const;
export const viewerSchema = z.object({ uid: z.string().min(1), name: z.string().min(1).max(128), avatar: z.string().url().optional() });
export type Viewer = z.infer<typeof viewerSchema>;
const liveEvents = {
"live.danmaku": z.object({ viewer: viewerSchema, text: z.string().max(500) }),
"live.gift": z.object({ viewer: viewerSchema, giftName: z.string(), battery: z.number().int().nonnegative(), quantity: z.number().int().positive(), sourceEventId: z.string() }),
"live.gift.combo": z.object({ viewer: viewerSchema, giftName: z.string(), battery: z.number().int().nonnegative(), quantity: z.number().int().positive(), comboId: z.string() }),
"live.enter": z.object({ viewer: viewerSchema }),
"live.guard.buy": z.object({ viewer: viewerSchema, guardName: z.string(), quantity: z.number().int().positive(), price: z.number().int().nonnegative() }),
"live.superchat": z.object({ viewer: viewerSchema, message: z.string(), price: z.number().int().nonnegative(), sourceEventId: z.string() }),
"live.like": z.object({ viewer: viewerSchema }),
"live.share": z.object({ viewer: viewerSchema }),
"live.unknown": z.object({ cmd: z.string(), raw: z.unknown() }),
"system.status": z.object({ connected: z.boolean(), cookieCloud: z.boolean(), replyEnabled: z.boolean(), detail: z.string().optional() }),
"system.error": z.object({ code: z.string(), message: z.string() }),
"viewer.points.updated": z.object({ viewer: viewerSchema, delta: z.number().int(), balance: z.number().int(), reason: z.enum(["gift", "wheel"]) }),
"wheel.result": z.object({ viewer: viewerSchema, category: z.string(), fallback: z.boolean(), song: z.object({ id: z.number().int(), title: z.string(), tags: z.array(z.string()) }), cost: z.literal(150), balance: z.number().int() }),
"wheel.insufficient-balance": z.object({ viewer: viewerSchema, balance: z.number().int(), cost: z.literal(150) }),
"wheel.invalid-command": z.object({ viewer: viewerSchema, message: z.string() })
} as const;
export type EventType = keyof typeof liveEvents;
export type EventPayload<T extends EventType> = z.infer<(typeof liveEvents)[T]>;
export type LiveEvent = { [T in EventType]: { version: 1; id: string; occurredAt: string; roomId: string; type: T; payload: EventPayload<T> } }[EventType];
const variants = Object.entries(liveEvents).map(([type, payload]) => z.object({ version: z.literal(1), id: z.string().uuid(), occurredAt: z.string().datetime(), roomId: z.string(), type: z.literal(type), payload }));
export const liveEventSchema = z.discriminatedUnion("type", variants as unknown as [z.ZodDiscriminatedUnionOption<"type">, ...z.ZodDiscriminatedUnionOption<"type">[]]) as unknown as z.ZodType<LiveEvent>;
export function makeEvent<T extends EventType>(roomId: string, type: T, payload: EventPayload<T>): Extract<LiveEvent, { type: T }> {
return { version: PROTOCOL_VERSION, id: crypto.randomUUID(), occurredAt: new Date().toISOString(), roomId, type, payload } as Extract<LiveEvent, { type: T }>;
}
+7
View File
@@ -0,0 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { liveEventSchema, makeEvent } from "../dist/index.js";
test("protocol parses typed event", () => {
const event = makeEvent("1", "live.enter", { viewer: { uid: "42", name: "观众" } });
assert.equal(liveEventSchema.parse(event).type, "live.enter");
});
+1
View File
@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }
+3
View File
@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*
Generated Vendored
+3934
View File
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
# 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"
+21
View File
@@ -0,0 +1,21 @@
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.
+537
View File
@@ -0,0 +1,537 @@
// 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
View File
@@ -0,0 +1,416 @@
// 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");
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// 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;
+97
View File
@@ -0,0 +1,97 @@
// 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
View File
@@ -0,0 +1,192 @@
// 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");
}
}
+507
View File
@@ -0,0 +1,507 @@
// 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::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,
})
}
pub fn send_auth(&mut self) {
if let Err(e) = self.send_auth_internal() {
log::error!("failed to send auth packet: {}", e);
}
}
pub fn send_heart_beat(&mut self) {
if let Err(e) = self.send_heart_beat_internal() {
log::error!("failed to send heartbeat: {}", e);
}
}
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(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(())
}
}
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);
}
}
+276
View File
@@ -0,0 +1,276 @@
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!("===============================");
}
}
+17
View File
@@ -0,0 +1,17 @@
// 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,
};
+481
View File
@@ -0,0 +1,481 @@
// 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
View File
@@ -0,0 +1,478 @@
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);
}
}
+58
View File
@@ -0,0 +1,58 @@
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
View File
@@ -0,0 +1,125 @@
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]");
}
}
+856
View File
@@ -0,0 +1,856 @@
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"));
}
}
+624
View File
@@ -0,0 +1,624 @@
// 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;
}
}
}
+254
View File
@@ -0,0 +1,254 @@
// 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)
}
+73
View File
@@ -0,0 +1,73 @@
// 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) {}
}
+11
View File
@@ -0,0 +1,11 @@
// 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;
+355
View File
@@ -0,0 +1,355 @@
// 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
}