remove old frontend
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
# 已弃用:应用不再从环境变量读取业务配置。
|
||||
# 请使用 config.toml.example:
|
||||
# cp config.toml.example config.toml
|
||||
# 然后填写 config.toml 中的注释项。Docker Compose 会只读挂载该文件。
|
||||
+1
-14
@@ -1,22 +1,10 @@
|
||||
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
|
||||
COPY apps/server-rust/migrations ./apps/server-rust/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 && \
|
||||
@@ -29,6 +17,5 @@ WORKDIR /app
|
||||
# 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"]
|
||||
|
||||
@@ -7,14 +7,12 @@ 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`
|
||||
应用配置遵循 `blivedm_rs` 的 TOML/`--config` 形式;Compose 会以只读方式将 `config.toml` 挂载到容器并传入 `--config /app/config.toml`。配置段及字段说明直接写在 [config.toml.example](config.toml.example) 的注释中。
|
||||
|
||||
应用容器使用 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 展示,只会停用聊天回复。
|
||||
将 `[reply].enabled` 设为 `true` 可启用聊天回复;发送前会从 CookieCloud 读取最新的 Bilibili Cookie,并从其中取得 `bili_jct` CSRF 值。若 Cookie 未同步 `bili_jct`,转盘逻辑仍会正常运行,只会停用聊天回复。
|
||||
|
||||
服务端已迁移为 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 鉴权包。
|
||||
|
||||
当前版本只提供 Rust 后端 API 与 WebSocket,不再托管任何前端静态文件或 SPA 路由。未匹配的 HTTP 路径将返回 `404`;新的前端可独立部署并连接 `/ws` 及 `/api/*`。
|
||||
|
||||
Generated
-32
@@ -1307,12 +1307,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -1847,7 +1841,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"toml",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
@@ -1905,16 +1898,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -3677,19 +3660,10 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -3841,12 +3815,6 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"] }
|
||||
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
|
||||
@@ -77,19 +76,18 @@ async fn main() {
|
||||
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());
|
||||
.route("/api/admin/reconnect", post(reconnect)).route("/api/admin/reply", post(toggle_reply))
|
||||
.route("/api/test/event", post(test_event)).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 migrate(url: &str) -> Result<(), String> { let client = connect(url).await?; client.batch_execute(include_str!("../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())) }
|
||||
@@ -106,7 +104,6 @@ async fn viewers(State(state): State<AppState>, headers: HeaderMap, Query(query)
|
||||
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; } } }
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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);
|
||||
@@ -1,44 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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 }); }
|
||||
@@ -1,55 +0,0 @@
|
||||
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 })); }
|
||||
@@ -1,17 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
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("转盘 摇滚")); });
|
||||
@@ -1,45 +0,0 @@
|
||||
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}`); }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { WHEEL_COST } from "../dist/wheel.js";
|
||||
import { normalizeCoreMessage } from "../dist/live.js";
|
||||
test("wheel cost is 150", () => assert.equal(WHEEL_COST, 150));
|
||||
test("Bilibili gift messages normalize to typed inputs", () => {
|
||||
const event = normalizeCoreMessage({ cmd: "SEND_GIFT", data: { uid: 4, uname: "测试", giftName: "辣条", price: 150, num: 2, tid: "gift-1" } });
|
||||
assert.deepEqual(event, { kind: "gift", viewer: { uid: "4", name: "测试" }, giftName: "辣条", battery: 150, quantity: 2, sourceEventId: "gift-1" });
|
||||
});
|
||||
test("Bilibili danmaku commands with a suffix normalize from their raw packet", () => {
|
||||
const event = normalizeCoreMessage({ cmd: "DANMU_MSG:4:0", info: [[], "转盘 流行", [42, "观众"]] });
|
||||
assert.deepEqual(event, { kind: "danmaku", viewer: { uid: "42", name: "观众" }, text: "转盘 流行" });
|
||||
});
|
||||
test("Bilibili enter packets normalize from their raw packet", () => {
|
||||
const event = normalizeCoreMessage({ cmd: "INTERACT_WORD", data: { uid: 7, uname: "进房观众" } });
|
||||
assert.deepEqual(event, { kind: "enter", viewer: { uid: "7", name: "进房观众" } });
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] }
|
||||
@@ -1 +0,0 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { LiveClient } from "@lxc/live-client";
|
||||
import type { LiveEvent } from "@lxc/protocol";
|
||||
import "./style.css";
|
||||
|
||||
const api = async <T,>(url: string, options?: RequestInit): Promise<T> => { const response = await fetch(url, { headers: { "content-type": "application/json", ...(options?.headers ?? {}) }, ...options }); if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error); return response.json(); };
|
||||
const wsUrl = (token?: string) => `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
function useEvents(token?: string) { const [events, setEvents] = useState<LiveEvent[]>([]); useEffect(() => { const client = new LiveClient({ url: wsUrl(token) }); client.onAny(event => setEvents(current => [event, ...current].slice(0, 30))); client.connect(); return () => client.close(); }, [token]); return events; }
|
||||
function Login({ onSuccess }: { onSuccess(): void }) { const [password, setPassword] = useState(""); const [error, setError] = useState(""); return <main className="login"><h1>洛星瓷直播转盘</h1><form onSubmit={async e => { e.preventDefault(); try { await api("/api/auth/login", { method: "POST", body: JSON.stringify({ password }) }); onSuccess(); } catch (e) { setError((e as Error).message); } }}><input aria-label="管理员密码" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="管理员密码"/><button>登录</button>{error && <p className="error">{error}</p>}</form></main> }
|
||||
function Admin() { const [loggedIn, setLoggedIn] = useState(false); const [status, setStatus] = useState<any>(); const [viewers, setViewers] = useState<any[]>([]); const [ledger, setLedger] = useState<any[]>([]); const [search, setSearch] = useState(""); const events = useEvents(); const load = async () => { try { setStatus(await api("/api/admin/status")); setViewers(await api(`/api/admin/viewers?search=${encodeURIComponent(search)}`)); setLedger(await api("/api/admin/ledger")); setLoggedIn(true); } catch { setLoggedIn(false); } }; useEffect(() => { void load(); }, [search]); if (!loggedIn) return <Login onSuccess={load}/>; return <main><header><h1>管理员控制台</h1><nav><a href="/test">测试页面</a><a href="/obs" target="_blank">OBS 展示</a><button onClick={() => void api("/api/auth/logout", { method: "POST" }).then(() => setLoggedIn(false))}>退出</button></nav></header><section className="cards"><article><b>直播连接</b><p>{status?.source.connected ? "已连接" : "未连接"}</p><small>{status?.source.detail}</small><button onClick={() => void api("/api/admin/reconnect", { method: "POST" }).then(load)}>重连</button></article><article><b>CookieCloud</b><p>{status?.source.cookieCloud ? "可用" : "异常"}</p><small>{status?.cookieCloudHost}</small></article><article><b>聊天回复</b><p>{status?.reply.enabled ? "已启用" : "已关闭"}</p><small>{status?.reply.detail}</small><button onClick={() => void api("/api/admin/reply", { method: "POST", body: JSON.stringify({ enabled: !status?.reply.enabled }) }).then(load)}>切换</button></article><article><b>OBS</b><button onClick={() => void api<string>("/api/admin/obs-url").then(url => navigator.clipboard.writeText(url))}>复制浏览器源地址</button></article></section><section><h2>观众点数</h2><input value={search} onChange={e => setSearch(e.target.value)} placeholder="UID 或昵称"/><table><thead><tr><th>UID</th><th>昵称</th><th>点数</th></tr></thead><tbody>{viewers.map(v => <tr key={v.uid}><td>{v.uid}</td><td>{v.displayName}</td><td>{v.points}</td></tr>)}</tbody></table></section><section><h2>最近流水</h2><table><thead><tr><th>观众</th><th>变化</th><th>原因</th><th>时间</th></tr></thead><tbody>{ledger.map(x => <tr key={x.id}><td>{x.displayName}</td><td>{x.delta}</td><td>{x.reason}</td><td>{new Date(x.createdAt).toLocaleString()}</td></tr>)}</tbody></table></section><EventList events={events}/></main> }
|
||||
function TestPage() { const [allowed, setAllowed] = useState<boolean>(); const [form, setForm] = useState({ kind: "gift", uid: "test-1", name: "测试观众", giftName: "测试礼物", battery: 150, quantity: 1, text: "转盘 流行" }); const [notice, setNotice] = useState(""); useEffect(() => { void api("/api/admin/status").then(() => setAllowed(true)).catch(() => setAllowed(false)); }, []); if (allowed === false) return <Login onSuccess={() => setAllowed(true)}/>; if (allowed === undefined) return <main>正在验证管理员会话…</main>; const submit = async (kind: string) => { try { const body: any = { kind, uid: form.uid, name: form.name }; if (kind === "gift") Object.assign(body, { giftName: form.giftName, battery: Number(form.battery), quantity: Number(form.quantity) }); if (kind === "danmaku") body.text = form.text; await api("/api/test/event", { method: "POST", body: JSON.stringify(body) }); setNotice("测试事件已发出;请在 OBS 展示页确认效果。"); } catch (e) { setNotice((e as Error).message); } }; const set = (name: string, value: any) => setForm(f => ({ ...f, [name]: value })); return <main><header><h1>弹幕触发测试</h1><nav><a href="/admin">管理台</a><a href="/obs" target="_blank">OBS 展示</a></nav></header><p>测试使用独立的测试账户和点数范围,不影响真实观众或聊天回复。</p><section className="form"><label>UID<input value={form.uid} onChange={e => set("uid", e.target.value)}/></label><label>昵称<input value={form.name} onChange={e => set("name", e.target.value)}/></label><label>礼物名称<input value={form.giftName} onChange={e => set("giftName", e.target.value)}/></label><label>电池<input type="number" value={form.battery} onChange={e => set("battery", e.target.value)}/></label><label>数量<input type="number" value={form.quantity} onChange={e => set("quantity", e.target.value)}/></label><label>弹幕<input value={form.text} onChange={e => set("text", e.target.value)}/></label><div><button onClick={() => void submit("enter")}>模拟进房</button><button onClick={() => void submit("gift")}>模拟礼物</button><button onClick={() => void submit("danmaku")}>模拟弹幕</button></div>{notice && <p>{notice}</p>}</section></main> }
|
||||
function EventList({ events }: { events: LiveEvent[] }) { return <section><h2>实时事件</h2><ol className="events">{events.map(event => <li key={event.id}><b>{event.type}</b> <code>{JSON.stringify(event.payload)}</code></li>)}</ol></section> }
|
||||
function Obs() { const token = new URLSearchParams(location.search).get("token") ?? undefined; const events = useEvents(token); const latest = events[0]; return <main className="obs">{latest ? <div className={`event event-${latest.type.replaceAll(".", "-")}`}><span className="type">{latest.type}</span>{latest.type === "wheel.result" ? <><h1>{latest.payload.viewer.name} 抽中了</h1><h2>《{latest.payload.song.title}》</h2><p>{latest.payload.category} · 剩余 {latest.payload.balance} 点</p></> : latest.type === "live.gift" ? <><h1>{latest.payload.viewer.name} 送出 {latest.payload.giftName}</h1><h2>+{latest.payload.battery * latest.payload.quantity} 点</h2></> : latest.type === "live.enter" ? <h1>欢迎 {latest.payload.viewer.name} 进入直播间</h1> : <h2>{JSON.stringify(latest.payload)}</h2>}</div> : <div className="event standby">等待直播事件…</div>}</main> }
|
||||
const path = location.pathname; createRoot(document.getElementById("root")!).render(path === "/test" ? <TestPage/> : path === "/obs" ? <Obs/> : <Admin/>);
|
||||
@@ -1 +0,0 @@
|
||||
:root{font-family:system-ui,"Microsoft YaHei",sans-serif;color:#f5ecff;background:#160d24}*{box-sizing:border-box}body{margin:0}main{max-width:1100px;margin:auto;padding:2rem}header{display:flex;justify-content:space-between;align-items:center;gap:1rem}nav{display:flex;gap:.75rem;align-items:center}a,button{color:#fff;background:#7b36d7;border:0;border-radius:.5rem;padding:.55rem .8rem;text-decoration:none;cursor:pointer}button:hover{background:#9956eb}input{display:block;width:100%;margin:.3rem 0 1rem;padding:.6rem;border:1px solid #765497;border-radius:.4rem;background:#29163d;color:#fff}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1rem;margin:1.5rem 0}.cards article,section,.form{background:#241334;border-radius:.8rem;padding:1rem}.cards button{margin-top:.5rem}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:.55rem;border-bottom:1px solid #4a2b67}.events{max-height:300px;overflow:auto;padding-left:1.5rem}.events li{margin:.5rem 0}.events code{word-break:break-all}.form{max-width:520px}.form label{display:block}.form div{display:flex;gap:.5rem;flex-wrap:wrap}.login{max-width:380px;margin-top:12vh}.error{color:#ff9da8}.obs{max-width:none;min-height:100vh;display:grid;place-items:center;background:transparent}.event{padding:2.5rem 4rem;text-align:center;border:3px solid #e6c7ff;border-radius:1rem;background:#180d26dd;box-shadow:0 0 40px #a94eff}.event h1{font-size:3rem;margin:.3rem}.event h2{font-size:4rem;margin:.3rem;color:#f9d86e}.type{opacity:.65}@media(max-width:650px){main{padding:1rem}header{align-items:flex-start;flex-direction:column}.event{padding:1rem}.event h1{font-size:1.8rem}.event h2{font-size:2.5rem}}
|
||||
@@ -1 +0,0 @@
|
||||
{ "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"] }
|
||||
@@ -1,3 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({ plugins: [react()] });
|
||||
Generated
-4266
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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();
|
||||
}));
|
||||
@@ -1,22 +0,0 @@
|
||||
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); }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { LiveClient } from "../dist/index.js";
|
||||
test("client dispatches a validated server event", () => {
|
||||
const socket = {}; const client = new LiveClient({ url: "ws://invalid", websocketFactory: () => socket }); let received;
|
||||
client.on("live.enter", event => { received = event; }); client.connect();
|
||||
socket.onmessage({ data: JSON.stringify({ version: 1, id: "a0000000-0000-4000-8000-000000000001", occurredAt: new Date().toISOString(), roomId: "1", type: "live.enter", payload: { viewer: { uid: "1", name: "A" } } }) });
|
||||
assert.equal(received.payload.viewer.name, "A");
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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");
|
||||
}));
|
||||
@@ -1,34 +0,0 @@
|
||||
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 }>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { liveEventSchema, makeEvent } from "../dist/index.js";
|
||||
test("protocol parses typed event", () => {
|
||||
const event = makeEvent("1", "live.enter", { viewer: { uid: "42", name: "观众" } });
|
||||
assert.equal(liveEventSchema.parse(event).type, "live.enter");
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
Reference in New Issue
Block a user