initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# 已弃用:应用不再从环境变量读取业务配置。
|
||||||
|
# 请使用 config.toml.example:
|
||||||
|
# cp config.toml.example config.toml
|
||||||
|
# 然后填写 config.toml 中的注释项。Docker Compose 会只读挂载该文件。
|
||||||
+38
@@ -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
@@ -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"]
|
||||||
@@ -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 鉴权包。
|
||||||
Generated
+4829
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 }); }
|
||||||
@@ -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 })); }
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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("转盘 摇滚")); });
|
||||||
@@ -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}`); }
|
||||||
|
}
|
||||||
@@ -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: "进房观众" } });
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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/>);
|
||||||
@@ -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}}
|
||||||
@@ -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"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
export default defineConfig({ plugins: [react()] });
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Generated
+4266
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}));
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}));
|
||||||
@@ -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 }>;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- apps/*
|
||||||
|
- packages/*
|
||||||
+3934
File diff suppressed because it is too large
Load Diff
Vendored
+172
@@ -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"
|
||||||
Vendored
+21
@@ -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.
|
||||||
Vendored
+537
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+12
@@ -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;
|
||||||
Vendored
+97
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+276
@@ -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!("===============================");
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+17
@@ -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,
|
||||||
|
};
|
||||||
Vendored
+481
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+58
@@ -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
@@ -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]");
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+856
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+624
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+254
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+73
@@ -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) {}
|
||||||
|
}
|
||||||
Vendored
+11
@@ -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;
|
||||||
Vendored
+355
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user