initial commit
This commit is contained in:
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()] });
|
||||
Reference in New Issue
Block a user