initial commit

This commit is contained in:
2026-07-14 21:31:59 -07:00
commit bd79966218
61 changed files with 19379 additions and 0 deletions
+537
View File
@@ -0,0 +1,537 @@
// src/client/auth.rs
//! Authentication helpers for Bilibili live danmaku WebSocket client
use md5;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde::Deserialize;
use std::time::{SystemTime, UNIX_EPOCH};
// Add browser cookie support
#[cfg(feature = "browser_cookies")]
use crate::browser_cookies;
/// Get Bilibili cookies from browser (preferred, newest), then fallback to provided cookie string
pub fn get_cookies_or_browser(provided_cookie: Option<&str>) -> Option<String> {
#[cfg(feature = "browser_cookies")]
{
// First try browser cookies as they are the newest
log::info!("Searching for Bilibili cookies in browser (newest)...");
if let Some(browser_cookie) = browser_cookies::find_bilibili_cookies_as_string() {
log::info!("Found Bilibili cookies in browser (using newest)");
return Some(browser_cookie);
}
log::info!("No Bilibili cookies found in browser, checking provided cookie...");
}
#[cfg(not(feature = "browser_cookies"))]
{
log::debug!("Browser cookie feature not enabled. Skip to find cookies in browser...");
}
if let Some(cookie) = provided_cookie {
if !cookie.is_empty() && cookie != "dummy_sessdata" && cookie.len() > 20 {
log::info!("Using provided cookie as fallback");
return Some(cookie.to_string());
}
}
log::warn!("No valid Bilibili cookies found in browser or provided input");
None
}
pub fn init_uid(headers: HeaderMap) -> (StatusCode, String) {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let response = client.get(UID_INIT_URL).headers(request_headers).send();
log::debug!("init uid response: {:?}", response);
let stat: StatusCode;
let body: String;
match response {
Ok(resp) => {
stat = resp.status();
body = resp.text().unwrap();
log::info!("init uid response: {:?}", body);
}
Err(_) => {
panic!("init uid failed");
}
}
(stat, body)
}
/// Initializes the buvid by sending a request and extracting the 'buvid3' cookie.
///
/// Note: This function is not used for document creation.
///
/// # Panics
///
/// Panics if the request fails.
pub fn init_buvid(headers: HeaderMap) -> (StatusCode, String) {
// Not used for document creation.
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let response = client.get(BUVID_INIT_URL).headers(request_headers).send();
let stat: StatusCode;
let mut buvid: String = "".to_string();
match response {
Ok(resp) => {
stat = resp.status();
let cookies = resp.cookies();
for i in cookies {
log::debug!("init buvid response cookie : {:?}", i);
if "buvid3".eq(i.name()) {
buvid = i.value().to_string();
log::info!("init buvid response: {:?}", buvid);
}
}
}
Err(_) => {
panic!("init buvid failed");
}
}
(stat, buvid)
}
/// Initializes the room by sending a request with the given room ID.
///
/// Note: This function should NOT be used for document creation.
///
/// # Panics
///
/// Panics if the request fails.
pub fn init_room(headers: HeaderMap, temp_room_id: &str) -> (StatusCode, String) {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let url = format!("{}?room_id={}", ROOM_INIT_URL, temp_room_id);
let response = client.get(url).headers(request_headers).send();
let stat: StatusCode;
let body: String;
match response {
Ok(resp) => {
stat = resp.status();
body = resp.text().unwrap();
log::info!("init room response: {:?}", body);
}
Err(_) => {
panic!("init buvid failed");
}
}
(stat, body)
}
pub fn init_host_server(headers: HeaderMap, room_id: u64) -> (StatusCode, String) {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers.clone();
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
// Get WBI keys for signing
let wbi_keys = match get_wbi_keys(request_headers.clone()) {
Ok(keys) => keys,
Err(e) => {
log::error!("Failed to get WBI keys: {:?}", e);
panic!("Failed to get WBI keys");
}
};
// Prepare parameters for signing
let params = vec![
("id", room_id.to_string()),
("type", "0".to_string()),
("web_location", "444.8".to_string()),
];
// Generate signed query string
let signed_query = encode_wbi(params, wbi_keys);
// Construct final URL
let url = format!("{}?{}", DANMAKU_SERVER_CONF_URL, signed_query);
// debug log the total request
let response = client.get(url).headers(request_headers).send();
log::debug!("init host server response: {:?}", response);
let stat: StatusCode;
let body: String;
match response {
Ok(resp) => {
stat = resp.status();
body = resp.text().unwrap();
log::info!("init host server response body: {:?}", body);
}
Err(_) => {
panic!("init host server failed");
}
}
(stat, body)
}
// WBI signing constants and functions
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,
28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25,
54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
];
#[derive(Deserialize)]
struct WbiImg {
img_url: String,
sub_url: String,
}
#[derive(Deserialize)]
struct Data {
wbi_img: WbiImg,
}
#[derive(Deserialize)]
struct ResWbi {
data: Data,
}
// 对 imgKey 和 subKey 进行字符顺序打乱编码
fn get_mixin_key(orig: &[u8]) -> String {
MIXIN_KEY_ENC_TAB
.iter()
.take(32)
.map(|&i| orig[i] as char)
.collect::<String>()
}
fn get_url_encoded(s: &str) -> String {
s.chars()
.filter_map(|c| match c.is_ascii_alphanumeric() || "-_.~".contains(c) {
true => Some(c.to_string()),
false => {
// 过滤 value 中的 "!'()*" 字符
if "!'()*".contains(c) {
return None;
}
let encoded = c
.encode_utf8(&mut [0; 4])
.bytes()
.fold("".to_string(), |acc, b| acc + &format!("%{:02X}", b));
Some(encoded)
}
})
.collect::<String>()
}
// 为请求参数进行 wbi 签名
fn encode_wbi(params: Vec<(&str, String)>, (img_key, sub_key): (String, String)) -> String {
let cur_time = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(t) => t.as_secs(),
Err(_) => panic!("SystemTime before UNIX EPOCH!"),
};
_encode_wbi(params, (img_key, sub_key), cur_time)
}
fn _encode_wbi(
mut params: Vec<(&str, String)>,
(img_key, sub_key): (String, String),
timestamp: u64,
) -> String {
let mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
// 添加当前时间戳
params.push(("wts", timestamp.to_string()));
// 重新排序
params.sort_by(|a, b| a.0.cmp(b.0));
// 拼接参数
let query = params
.iter()
.map(|(k, v)| format!("{}={}", get_url_encoded(k), get_url_encoded(v)))
.collect::<Vec<_>>()
.join("&");
// 计算签名
let web_sign = format!("{:x}", md5::compute(query.clone() + &mixin_key));
// 返回最终的 query
query + &format!("&w_rid={}", web_sign)
}
fn get_wbi_keys(headers: HeaderMap) -> Result<(String, String), reqwest::Error> {
let client = reqwest::blocking::Client::builder()
.https_only(true)
.build()
.unwrap();
let mut request_headers = headers;
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
let response = client
.get("https://api.bilibili.com/x/web-interface/nav")
.headers(request_headers)
.send()?;
let res_wbi: ResWbi = response.json()?;
Ok((
take_filename(res_wbi.data.wbi_img.img_url).unwrap(),
take_filename(res_wbi.data.wbi_img.sub_url).unwrap(),
))
}
fn take_filename(url: String) -> Option<String> {
url.rsplit_once('/')
.and_then(|(_, s)| s.rsplit_once('.'))
.map(|(s, _)| s.to_string())
}
pub const UID_INIT_URL: &str = "https://api.bilibili.com/x/web-interface/nav";
pub const BUVID_INIT_URL: &str = "https://data.bilibili.com/v/";
pub const ROOM_INIT_URL: &str =
"https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom";
pub const DANMAKU_SERVER_CONF_URL: &str =
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo";
pub const USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uid_url_constant() {
assert!(UID_INIT_URL.contains("bilibili.com"));
}
#[test]
fn test_take_filename() {
assert_eq!(
take_filename(
"https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string()
),
Some("7cd084941338484aae1ad9425b84077c".to_string())
);
assert_eq!(
take_filename(
"https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string()
),
Some("4932caff0ff746eab6f01bf08b70ac45".to_string())
);
// Test edge case with no extension
assert_eq!(
take_filename("https://example.com/path/file".to_string()),
None
);
}
#[test]
fn test_encode_wbi_with_known_values() {
let params = vec![
("foo", String::from("114")),
("bar", String::from("514")),
("zab", String::from("1919810")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1702204169,
);
assert_eq!(
result,
"bar=514&foo=114&wts=1702204169&zab=1919810&w_rid=8f6f2b5b3d485fe1886cec6a0be8c5d4"
);
}
#[test]
fn test_encode_wbi_bilibili_danmu_params() {
// Test with the actual Bilibili danmu parameters from the example
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
// Using the timestamp from the example URL (1748308267)
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748308267,
);
// The result should contain the correct parameters and w_rid
assert!(result.contains("id=24779526"));
assert!(result.contains("type=0"));
assert!(result.contains("web_location=444.8"));
assert!(result.contains("wts=1748308267"));
assert!(result.contains("w_rid="));
// Check the parameter order (should be alphabetical)
let expected_order = "id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=";
assert!(result.starts_with(expected_order));
}
#[test]
fn test_wbi_signature_consistency() {
// Test that the same parameters always generate the same signature
let params1 = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let params2 = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let keys = (
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
);
let timestamp = 1748308267;
let result1 = _encode_wbi(params1, keys.clone(), timestamp);
let result2 = _encode_wbi(params2, keys, timestamp);
assert_eq!(result1, result2);
}
#[test]
fn test_wbi_parameter_sorting() {
// Test that parameters are properly sorted alphabetically
let params = vec![
("z_param", String::from("last")),
("a_param", String::from("first")),
("m_param", String::from("middle")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748308267,
);
// Check that parameters appear in alphabetical order
let parts: Vec<&str> = result.split('&').collect();
assert!(parts[0].starts_with("a_param="));
assert!(parts[1].starts_with("m_param="));
assert!(parts[2].starts_with("wts="));
assert!(parts[3].starts_with("z_param="));
assert!(parts[4].starts_with("w_rid="));
}
#[test]
fn test_correct_bilibili_url_signature() {
// Test the exact URL from the working example:
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=884cf361b8ad4e239b4a9dbbb7134679"
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=d1e619744b4977f88ed67524a1f567cc&wts=1751072897"
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1751072897,
);
// Expected complete query string from working URL
let expected = "id=24779526&type=0&web_location=444.8&wts=1751072897&w_rid=d1e619744b4977f88ed67524a1f567cc";
assert_eq!(result, expected);
// Extract and verify the w_rid specifically
let w_rid = result.split("w_rid=").nth(1).unwrap();
assert_eq!(w_rid, "d1e619744b4977f88ed67524a1f567cc");
}
#[test]
fn test_second_bilibili_url_signature() {
// Test the second URL example:
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=fa20533eb27334ba6f2ec7263721319a&wts=1748311635"
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748311635,
);
// Expected complete query string from working URL
let expected = "id=24779526&type=0&web_location=444.8&wts=1748311635&w_rid=fa20533eb27334ba6f2ec7263721319a";
assert_eq!(result, expected);
// Extract and verify the w_rid specifically
let w_rid = result.split("w_rid=").nth(1).unwrap();
assert_eq!(w_rid, "fa20533eb27334ba6f2ec7263721319a");
}
#[test]
fn test_third_bilibili_url_signature() {
// Test the third URL example from README:
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507"
let params = vec![
("id", String::from("24779526")),
("type", String::from("0")),
("web_location", String::from("444.8")),
];
let result = _encode_wbi(
params,
(
"7cd084941338484aae1ad9425b84077c".to_string(),
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
),
1748312554,
);
// Expected complete query string from working URL
let expected = "id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507";
assert_eq!(result, expected);
// Extract and verify the w_rid specifically
let w_rid = result.split("w_rid=").nth(1).unwrap();
assert_eq!(w_rid, "30f250e8abd9effea1bcb88aab416507");
}
}
+416
View File
@@ -0,0 +1,416 @@
// src/client/browser_cookies.rs
//! Browser cookie reading functionality for automatic SESSDATA detection
use chrono::{DateTime, TimeZone, Utc};
use directories::UserDirs;
use log::{debug, info, warn};
use sqlite::Connection;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub expires: Option<DateTime<Utc>>,
pub secure: bool,
pub http_only: bool,
}
#[derive(Debug)]
pub enum Browser {
Chrome,
Firefox,
Edge,
Chromium,
Opera,
}
impl Browser {
pub fn get_cookie_db_path(&self) -> Option<PathBuf> {
let user_dirs = UserDirs::new()?;
let home_dir = user_dirs.home_dir();
match self {
Browser::Chrome => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/google-chrome/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(home_dir.join("Library/Application Support/Google/Chrome/Default/Cookies"))
}
#[cfg(target_os = "windows")]
{
Some(
home_dir
.join("AppData/Local/Google/Chrome/User Data/Default/Network/Cookies"),
)
}
}
Browser::Firefox => {
#[cfg(target_os = "linux")]
{
let firefox_dir = vec![
home_dir.join(".mozilla/firefox"),
home_dir.join("snap/firefox/common/.mozilla/firefox"),
home_dir.join(".var/app/org.mozilla.firefox/.mozilla/firefox"),
home_dir.join("snap/firefox/current/.mozilla/firefox"),
]
.into_iter()
.find(|p| p.exists())?;
Self::find_firefox_profile_cookies(&firefox_dir)
}
#[cfg(target_os = "macos")]
{
let firefox_dir = home_dir.join("Library/Application Support/Firefox/Profiles");
Self::find_firefox_profile_cookies(&firefox_dir)
}
#[cfg(target_os = "windows")]
{
let firefox_dir = home_dir.join("AppData/Roaming/Mozilla/Firefox/Profiles");
Self::find_firefox_profile_cookies(&firefox_dir)
}
}
Browser::Edge => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/microsoft-edge/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(
home_dir.join("Library/Application Support/Microsoft Edge/Default/Cookies"),
)
}
#[cfg(target_os = "windows")]
{
Some(
home_dir
.join("AppData/Local/Microsoft/Edge/User Data/Default/Network/Cookies"),
)
}
}
Browser::Chromium => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/chromium/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(home_dir.join("Library/Application Support/Chromium/Default/Cookies"))
}
#[cfg(target_os = "windows")]
{
Some(home_dir.join("AppData/Local/Chromium/User Data/Default/Network/Cookies"))
}
}
Browser::Opera => {
#[cfg(target_os = "linux")]
{
Some(home_dir.join(".config/opera/Default/Cookies"))
}
#[cfg(target_os = "macos")]
{
Some(home_dir.join(
"Library/Application Support/com.operasoftware.Opera/Default/Cookies",
))
}
#[cfg(target_os = "windows")]
{
Some(
home_dir
.join("AppData/Roaming/Opera Software/Opera Stable/Network/Cookies"),
)
}
}
}
}
fn find_firefox_profile_cookies(firefox_dir: &Path) -> Option<PathBuf> {
if !firefox_dir.exists() {
return None;
}
// Look for the default profile directory
let entries = fs::read_dir(firefox_dir).ok()?;
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name()?.to_str()?;
if dir_name.contains(".default") || dir_name.contains(".default-release") {
let cookies_path = path.join("cookies.sqlite");
if cookies_path.exists() {
return Some(cookies_path);
}
}
}
}
}
None
}
pub fn get_all_supported() -> Vec<Browser> {
vec![
Browser::Chrome,
Browser::Firefox,
Browser::Edge,
Browser::Chromium,
Browser::Opera,
]
}
}
/// Read cookies from a browser's cookie database
pub fn read_cookies_from_browser(
browser: &Browser,
domain_filter: Option<&str>,
) -> Result<Vec<Cookie>, String> {
let db_path = browser
.get_cookie_db_path()
.ok_or_else(|| "Could not determine cookie database path".to_string())?;
if !db_path.exists() {
return Err(format!("Cookie database not found at: {:?}", db_path));
}
debug!("Reading cookies from: {:?}", db_path);
// Create a temporary copy of the database since browsers might have it locked
let temp_path = std::env::temp_dir().join(format!("temp_cookies_{}.db", std::process::id()));
if let Err(e) = fs::copy(&db_path, &temp_path) {
return Err(format!("Failed to copy cookie database: {}", e));
}
let result = match browser {
Browser::Firefox => read_firefox_cookies(&temp_path, domain_filter),
_ => read_chromium_cookies(&temp_path, domain_filter),
};
// Clean up temporary file
let _ = fs::remove_file(&temp_path);
result
}
fn read_chromium_cookies(
db_path: &Path,
domain_filter: Option<&str>,
) -> Result<Vec<Cookie>, String> {
let connection =
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
let mut query =
"SELECT name, value, host_key, path, expires_utc, is_secure, is_httponly FROM cookies"
.to_string();
if let Some(domain) = domain_filter {
query.push_str(&format!(" WHERE host_key LIKE '%{}'", domain));
}
let mut cookies = Vec::new();
connection
.iterate(query, |pairs| {
let mut cookie_data = HashMap::new();
for &(column, value) in pairs.iter() {
cookie_data.insert(column, value.unwrap_or(""));
}
let expires = if let Some(expires_str) = cookie_data.get("expires_utc") {
if let Ok(expires_microseconds) = expires_str.parse::<i64>() {
// Chrome stores time as microseconds since Windows epoch (1601-01-01)
// Convert to Unix timestamp (seconds since 1970-01-01)
let windows_epoch_offset = 11644473600_i64; // seconds between 1601 and 1970
let unix_timestamp = (expires_microseconds / 1_000_000) - windows_epoch_offset;
Utc.timestamp_opt(unix_timestamp, 0).single()
} else {
None
}
} else {
None
};
let cookie = Cookie {
name: cookie_data.get("name").unwrap_or(&"").to_string(),
value: cookie_data.get("value").unwrap_or(&"").to_string(),
domain: cookie_data.get("host_key").unwrap_or(&"").to_string(),
path: cookie_data.get("path").unwrap_or(&"").to_string(),
expires,
secure: cookie_data.get("is_secure").unwrap_or(&"0") == &"1",
http_only: cookie_data.get("is_httponly").unwrap_or(&"0") == &"1",
};
cookies.push(cookie);
true
})
.map_err(|e| format!("Failed to query cookies: {}", e))?;
Ok(cookies)
}
fn read_firefox_cookies(
db_path: &Path,
domain_filter: Option<&str>,
) -> Result<Vec<Cookie>, String> {
let connection =
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
let mut query =
"SELECT name, value, host, path, expiry, isSecure, isHttpOnly FROM moz_cookies".to_string();
if let Some(domain) = domain_filter {
query.push_str(&format!(" WHERE host LIKE '%{}'", domain));
}
let mut cookies = Vec::new();
connection
.iterate(query, |pairs| {
let mut cookie_data = HashMap::new();
for &(column, value) in pairs.iter() {
cookie_data.insert(column, value.unwrap_or(""));
}
let expires = if let Some(expires_str) = cookie_data.get("expiry") {
if let Ok(expires_timestamp) = expires_str.parse::<i64>() {
Utc.timestamp_opt(expires_timestamp, 0).single()
} else {
None
}
} else {
None
};
let cookie = Cookie {
name: cookie_data.get("name").unwrap_or(&"").to_string(),
value: cookie_data.get("value").unwrap_or(&"").to_string(),
domain: cookie_data.get("host").unwrap_or(&"").to_string(),
path: cookie_data.get("path").unwrap_or(&"").to_string(),
expires,
secure: cookie_data.get("isSecure").unwrap_or(&"0") == &"1",
http_only: cookie_data.get("isHttpOnly").unwrap_or(&"0") == &"1",
};
cookies.push(cookie);
true
})
.map_err(|e| format!("Failed to query cookies: {}", e))?;
Ok(cookies)
}
/// Find SESSDATA cookie from all supported browsers
pub fn find_bilibili_cookies_as_string() -> Option<String> {
let browsers = Browser::get_all_supported();
let mut all_cookies = vec![];
for browser in browsers {
info!("Checking browser: {:?}", browser);
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
all_cookies.extend(cookies);
}
}
let mut valid_cookies = all_cookies
.into_iter()
.filter(|cookie| {
if let Some(expires) = cookie.expires {
if Utc::now() > expires {
warn!(
"Found expired {} cookie, expires: {:?}",
cookie.name, expires
);
return false;
}
}
true
})
.collect::<Vec<_>>();
// Deduplicate cookies, keeping the one with the latest expiry
valid_cookies.sort_by(|a, b| {
if a.name != b.name {
a.name.cmp(&b.name)
} else {
b.expires.cmp(&a.expires) // None is smaller
}
});
valid_cookies.dedup_by(|a, b| a.name == b.name);
if valid_cookies.is_empty() {
warn!("No valid bilibili cookies found in any browser");
return None;
}
info!("Found {} valid bilibili cookies", valid_cookies.len());
let cookie_string = valid_cookies
.iter()
.map(|c| format!("{}={}", c.name, c.value))
.collect::<Vec<String>>()
.join("; ");
if cookie_string.contains("SESSDATA") {
Some(cookie_string)
} else {
warn!("No SESSDATA cookie found among the valid cookies");
None
}
}
/// Get all bilibili cookies from browsers for debugging
pub fn get_all_bilibili_cookies() -> HashMap<String, String> {
let mut all_cookies = HashMap::new();
let browsers = Browser::get_all_supported();
for browser in browsers {
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
for cookie in cookies {
// Only include non-expired cookies
if let Some(expires) = cookie.expires {
if Utc::now() > expires {
continue;
}
}
// Use the most recent cookie if duplicates exist
let key = format!("{}_{}", cookie.name, cookie.domain);
all_cookies.insert(key, cookie.value);
}
}
}
all_cookies
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_browser_path_detection() {
for browser in Browser::get_all_supported() {
let path = browser.get_cookie_db_path();
println!("{:?} cookie path: {:?}", browser, path);
}
}
#[test]
fn test_find_sessdata() {
// This test will only work if you have bilibili cookies in your browser
if let Some(sessdata) = find_bilibili_cookies_as_string() {
println!("Found SESSDATA: {}", &sessdata[..20.min(sessdata.len())]);
assert!(!sessdata.is_empty());
} else {
println!("No SESSDATA found - this is normal if you're not logged into bilibili");
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// src/client/lib.rs
//! Library entry for the client package
pub mod auth;
#[cfg(feature = "browser_cookies")]
pub mod browser_cookies;
pub mod models;
pub mod scheduler;
pub mod websocket;
// Re-export commonly used functions
pub use auth::get_cookies_or_browser;
+97
View File
@@ -0,0 +1,97 @@
// src/client/models.rs
//! Data models for Bilibili live danmaku WebSocket client
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug)]
pub struct DanmuServer {
pub host: String,
pub port: i32,
pub wss_port: i32,
pub ws_port: i32,
}
impl Default for DanmuServer {
fn default() -> Self {
Self {
host: String::from("broadcastlv.chat.bilibili.com"),
port: 2243,
wss_port: 443,
ws_port: 2244,
}
}
}
#[derive(Copy, Clone, Debug)]
pub struct MsgHead {
pub pack_len: u32,
pub raw_header_size: u16,
pub ver: u16,
pub operation: u32,
pub seq_id: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthMessage {
pub uid: u64,
pub roomid: u64,
pub protover: i32,
pub platform: String,
pub type_: i32,
pub key: String,
}
impl AuthMessage {
pub fn from(map: &HashMap<String, String>) -> AuthMessage {
AuthMessage {
uid: map.get("uid").unwrap().parse::<u64>().unwrap(),
roomid: map.get("room_id").unwrap().parse::<u64>().unwrap(),
protover: 3,
platform: "web".to_string(),
type_: 2,
key: map.get("token").unwrap().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum BiliMessage {
Danmu {
user: String,
text: String,
},
Gift {
user: String,
gift: String,
num: String,
},
/// Online rank count message (ONLINE_RANK_COUNT)
OnlineRankCount {
/// Number of high-energy users in the live room
count: u64,
/// Number of online users in the live room
online_count: u64,
},
// Add more variants as needed
Raw(serde_json::Value),
#[deprecated(note = "Use Raw variant instead")]
Unsupported,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_message_from_map() {
let mut map = std::collections::HashMap::new();
map.insert("uid".to_string(), "12345".to_string());
map.insert("room_id".to_string(), "67890".to_string());
map.insert("token".to_string(), "test_token".to_string());
let auth = AuthMessage::from(&map);
assert_eq!(auth.uid, 12345);
assert_eq!(auth.roomid, 67890);
assert_eq!(auth.key, "test_token");
}
}
+192
View File
@@ -0,0 +1,192 @@
// In Cargo.toml, ensure you have: client = { path = "../client" }
use models::BiliMessage;
use std::sync::Arc;
use crate::models;
/// Context information passed to event handlers
#[derive(Debug, Clone)]
pub struct EventContext {
/// Bilibili cookies for authentication
pub cookies: Option<String>,
/// Room ID where the event occurred
pub room_id: u64,
}
impl EventContext {
/// Create a new EventContext with automatic cookie detection
pub fn new_with_auto_cookies(room_id: u64) -> Self {
let cookies = crate::auth::get_cookies_or_browser(None);
Self { cookies, room_id }
}
/// Create a new EventContext with provided cookies
pub fn new(cookies: Option<String>, room_id: u64) -> Self {
Self { cookies, room_id }
}
}
/// Trait for event handlers (plugins) that process BiliMessage.
pub trait EventHandler: Send + Sync {
fn handle(&self, msg: &BiliMessage, context: &EventContext);
}
/// Scheduling mode: Parallel or Sequential.
pub enum ScheduleMode {
Parallel,
Sequential,
}
/// Scheduler struct: manages event handlers and dispatches messages.
pub struct Scheduler {
/// Each stage is a Vec of handlers to run in parallel; stages run sequentially.
stages: Vec<Vec<Arc<dyn EventHandler>>>,
/// Context information for event handlers
context: EventContext,
}
impl Scheduler {
pub fn new(context: EventContext) -> Self {
Scheduler {
stages: Vec::new(),
context,
}
}
/// Add a new stage (group of handlers to run in parallel)
pub fn add_stage(&mut self, handlers: Vec<Arc<dyn EventHandler>>) {
self.stages.push(handlers);
}
/// Add a single handler as a new sequential stage
pub fn add_sequential_handler(&mut self, handler: Arc<dyn EventHandler>) {
self.stages.push(vec![handler]);
}
/// Trigger all stages with the given BiliMessage.
pub fn trigger(&self, msg: BiliMessage) {
for stage in &self.stages {
let mut handles = vec![];
for handler in stage {
let msg = msg.clone();
let context = self.context.clone();
let handler = Arc::clone(handler);
handles.push(std::thread::spawn(move || {
handler.handle(&msg, &context);
}));
}
// Wait for all handlers in this stage to finish before next stage
for handle in handles {
let _ = handle.join();
}
}
}
}
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use crate::models::BiliMessage;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, mpsc};
struct AssertHandler {
called: Arc<AtomicBool>,
last_msg: Arc<Mutex<Option<BiliMessage>>>,
}
impl super::EventHandler for AssertHandler {
fn handle(&self, msg: &BiliMessage, _context: &super::EventContext) {
self.called.store(true, Ordering::SeqCst);
let mut lock = self.last_msg.lock().unwrap();
*lock = Some(msg.clone());
}
}
#[test]
fn test_scheduler_with_mpsc_channel() {
let (tx, rx) = mpsc::channel();
let called = Arc::new(AtomicBool::new(false));
let last_msg = Arc::new(Mutex::new(None));
let handler = AssertHandler {
called: Arc::clone(&called),
last_msg: Arc::clone(&last_msg),
};
let context = super::EventContext {
cookies: Some("test_cookies".to_string()),
room_id: 12345,
};
let mut scheduler = super::Scheduler::new(context);
scheduler.add_sequential_handler(Arc::new(handler));
// Send a test message
let test_msg = BiliMessage::Danmu {
user: "user1".to_string(),
text: "hello".to_string(),
};
tx.send(test_msg.clone()).unwrap();
// Simulate receiving and triggering
if let Ok(msg) = rx.recv() {
scheduler.trigger(msg);
}
// Assert handler was called and message matches
assert!(called.load(Ordering::SeqCst), "Handler was not called");
let lock = last_msg.lock().unwrap();
assert!(lock.is_some(), "No message stored in handler");
assert_eq!(lock.as_ref().unwrap(), &test_msg, "Message does not match");
}
#[test]
fn test_scheduler_add_stage_and_sequential_handler() {
use crate::models::BiliMessage;
struct CounterHandler {
counter: Arc<AtomicUsize>,
}
impl super::EventHandler for CounterHandler {
fn handle(&self, _msg: &BiliMessage, _context: &super::EventContext) {
self.counter.fetch_add(1, Ordering::SeqCst);
}
}
let counter1 = Arc::new(AtomicUsize::new(0));
let counter2 = Arc::new(AtomicUsize::new(0));
let counter3 = Arc::new(AtomicUsize::new(0));
let handler1 = Arc::new(CounterHandler {
counter: Arc::clone(&counter1),
});
let handler2 = Arc::new(CounterHandler {
counter: Arc::clone(&counter2),
});
let handler3 = Arc::new(CounterHandler {
counter: Arc::clone(&counter3),
});
let context = super::EventContext {
cookies: Some("test_cookies".to_string()),
room_id: 12345,
};
let mut scheduler = super::Scheduler::new(context);
// Add a parallel stage (handler1 and handler2)
scheduler.add_stage(vec![handler1, handler2]);
// Add a sequential stage (handler3)
scheduler.add_sequential_handler(handler3);
let test_msg = BiliMessage::Danmu {
user: "user2".to_string(),
text: "test".to_string(),
};
scheduler.trigger(test_msg);
// Both handler1 and handler2 should be called once (parallel stage)
assert_eq!(counter1.load(Ordering::SeqCst), 1, "Handler1 not called");
assert_eq!(counter2.load(Ordering::SeqCst), 1, "Handler2 not called");
// handler3 should be called once (sequential stage)
assert_eq!(counter3.load(Ordering::SeqCst), 1, "Handler3 not called");
}
}
+507
View File
@@ -0,0 +1,507 @@
// src/client/websocket.rs
//! WebSocket client for Bilibili live danmaku messages (refactored from bili_live_dm)
use native_tls::TlsStream;
use serde_json::Value;
use std::net::TcpStream;
use std::panic;
use tungstenite::{client, Message, WebSocket};
use url::Url;
use futures_channel::mpsc::Sender;
use http::Response;
use std::collections::HashMap;
use std::thread;
use std::time::Duration;
use crate::auth::*;
use crate::models::{AuthMessage, BiliMessage, DanmuServer, MsgHead};
pub struct BiliLiveClient {
ws: WebSocket<TlsStream<TcpStream>>,
cookies: String,
room_id: String,
auth_msg: String,
ss: Sender<BiliMessage>,
}
impl BiliLiveClient {
pub fn new(cookies: &str, room_id: &str, r: Sender<BiliMessage>) -> Self {
let (ws, auth_msg) = Self::connect_with_auth(cookies, room_id)
.unwrap_or_else(|e| panic!("Failed to create websocket client: {}", e));
BiliLiveClient {
ws,
cookies: cookies.to_string(),
room_id: room_id.to_string(),
auth_msg,
ss: r,
}
}
/// Create a new client with automatic browser cookie detection
/// If cookies is None or empty, it will try to find cookies from browser
pub fn new_auto(
cookies: Option<&str>,
room_id: &str,
r: Sender<BiliMessage>,
) -> Result<Self, String> {
let resolved_cookies = get_cookies_or_browser(cookies)
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
let (ws, auth_msg) = Self::connect_with_auth(&resolved_cookies, room_id)?;
Ok(BiliLiveClient {
ws,
cookies: resolved_cookies,
room_id: room_id.to_string(),
auth_msg,
ss: r,
})
}
pub fn send_auth(&mut self) {
if let Err(e) = self.send_auth_internal() {
log::error!("failed to send auth packet: {}", e);
}
}
pub fn send_heart_beat(&mut self) {
if let Err(e) = self.send_heart_beat_internal() {
log::error!("failed to send heartbeat: {}", e);
}
}
pub fn parse_ws_message(&mut self, resv: Vec<u8>) {
let mut offset = 0;
let header = &resv[0..16];
let mut head_1 = get_msg_header(header);
if head_1.operation == 5 || head_1.operation == 8 {
loop {
let body: &[u8] = &resv[offset + 16..offset + (head_1.pack_len as usize)];
self.parse_business_message(head_1, body);
offset += head_1.pack_len as usize;
if offset >= resv.len() {
break;
}
let temp_head = &resv[offset..(offset + 16)];
head_1 = get_msg_header(temp_head);
}
} else if head_1.operation == 3 {
let mut body: [u8; 4] = [0, 0, 0, 0];
body[0] = resv[16];
body[1] = resv[17];
body[2] = resv[18];
body[3] = resv[19];
let popularity = i32::from_be_bytes(body);
log::info!("popularity:{}", popularity);
} else {
log::error!(
"unknown message operation={:?}, header={:?}}}",
head_1.operation,
head_1
)
}
}
pub fn parse_business_message(&mut self, h: MsgHead, b: &[u8]) {
if h.operation == 5 {
if h.ver == 3 {
let res: Vec<u8> = decompress(b).unwrap();
self.parse_ws_message(res);
} else if h.ver == 0 {
let s = String::from_utf8(b.to_vec()).unwrap();
let res_json: Value = serde_json::from_str(s.as_str()).unwrap();
if let Some(msg) = handle(res_json) {
let _ = self.ss.try_send(msg);
}
} else {
log::error!("Unknown compression format");
}
} else if h.operation == 8 {
self.send_heart_beat();
} else {
log::error!("Unknown message format {}", h.operation);
}
}
pub fn receive(&mut self) -> Result<(), String> {
if self.ws.can_read() {
let msg = self.ws.read();
match msg {
Ok(m) => {
let res = m.into_data();
if res.len() >= 16 {
self.parse_ws_message(res);
}
Ok(())
}
Err(e) => {
let msg = format!("read msg error: {}", e);
log::warn!("{}", msg);
self.reconnect().map_err(|reconnect_err| {
format!("{}; reconnect failed: {}", msg, reconnect_err)
})
}
}
} else {
Ok(())
}
}
fn connect_with_auth(
cookies: &str,
room_id: &str,
) -> Result<(WebSocket<TlsStream<TcpStream>>, String), String> {
panic::catch_unwind(|| {
let (v, auth) = init_server(cookies, room_id);
let (ws, _res) = connect_result(v["host_list"].clone())?;
let auth_msg = serde_json::to_string(&auth)
.map_err(|e| format!("serialize auth payload failed: {}", e))?;
Ok((ws, auth_msg))
})
.map_err(|_| format!("websocket setup panicked for room {}", room_id))?
}
fn send_auth_internal(&mut self) -> Result<(), String> {
match self.ws.send(Message::Binary(make_packet(
self.auth_msg.as_str(),
Operation::AUTH,
))) {
Ok(()) => Ok(()),
Err(e) => {
let msg = format!("send auth error: {}", e);
log::warn!("{}", msg);
self.reconnect()?;
self.ws
.send(Message::Binary(make_packet(
self.auth_msg.as_str(),
Operation::AUTH,
)))
.map_err(|retry_err| format!("{}; resend auth failed: {}", msg, retry_err))
}
}
}
fn send_heart_beat_internal(&mut self) -> Result<(), String> {
match self
.ws
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
{
Ok(()) => Ok(()),
Err(e) => {
let msg = format!("send heartbeat error: {}", e);
log::warn!("{}", msg);
self.reconnect()?;
self.ws
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
.map_err(|retry_err| format!("{}; resend heartbeat failed: {}", msg, retry_err))
}
}
}
fn reconnect(&mut self) -> Result<(), String> {
let backoff = [1_u64, 2, 5];
let mut last_err = None;
for (idx, delay_secs) in backoff.iter().enumerate() {
if idx > 0 {
thread::sleep(Duration::from_secs(*delay_secs));
}
match Self::connect_with_auth(&self.cookies, &self.room_id) {
Ok((ws, auth_msg)) => {
self.ws = ws;
self.auth_msg = auth_msg;
let auth_resend = self.ws.send(Message::Binary(make_packet(
self.auth_msg.as_str(),
Operation::AUTH,
)));
let heartbeat_resend = self
.ws
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)));
match (auth_resend, heartbeat_resend) {
(Ok(()), Ok(())) => {
log::info!(
"websocket reconnected on attempt {} for room {}",
idx + 1,
self.room_id
);
return Ok(());
}
(auth_result, heartbeat_result) => {
let auth_err = auth_result.err().map(|e| e.to_string());
let heartbeat_err = heartbeat_result.err().map(|e| e.to_string());
let reconnect_err = match (auth_err, heartbeat_err) {
(Some(auth_err), Some(heartbeat_err)) => format!(
"reconnected socket but auth resend failed: {}; heartbeat resend failed: {}",
auth_err, heartbeat_err
),
(Some(auth_err), None) => {
format!("reconnected socket but auth resend failed: {}", auth_err)
}
(None, Some(heartbeat_err)) => format!(
"reconnected socket but heartbeat resend failed: {}",
heartbeat_err
),
(None, None) => unreachable!(),
};
log::warn!(
"websocket reconnect attempt {} did not fully recover for room {}: {}",
idx + 1,
self.room_id,
reconnect_err
);
last_err = Some(reconnect_err);
}
}
}
Err(e) => {
log::warn!(
"websocket reconnect attempt {} failed for room {}: {}",
idx + 1,
self.room_id,
e
);
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| "unknown reconnect failure".to_string()))
}
}
pub fn gen_damu_list(list: &Value) -> Vec<DanmuServer> {
let server_list = list.as_array().unwrap();
let mut res: Vec<DanmuServer> = Vec::new();
if server_list.len() == 0 {
let d = DanmuServer::default();
res.push(d);
}
for s in server_list {
res.push(DanmuServer {
host: s["host"].as_str().unwrap().to_string(),
port: s["port"].as_u64().unwrap() as i32,
wss_port: s["wss_port"].as_u64().unwrap() as i32,
ws_port: s["ws_port"].as_u64().unwrap() as i32,
});
}
res
}
fn find_server(vd: Vec<DanmuServer>) -> (String, String, String) {
let (host, wss_port) = (vd.get(0).unwrap().host.clone(), vd.get(0).unwrap().wss_port);
(
host.clone(),
format!("{}:{}", host.clone(), wss_port),
format!("wss://{}:{}/sub", host, wss_port),
)
}
pub fn init_server(cookies: &str, room_id: &str) -> (Value, AuthMessage) {
let mut auth_map = HashMap::new();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::COOKIE,
reqwest::header::HeaderValue::from_str(cookies).unwrap(),
);
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(crate::auth::USER_AGENT),
);
log::debug!("headers: {:?}", headers);
// Extract SESSDATA from cookies for authentication
let sessdata = cookies
.split(';')
.find_map(|kv| {
let mut parts = kv.trim().splitn(2, '=');
let key = parts.next()?.trim();
let value = parts.next()?.trim();
if key == "SESSDATA" {
Some(value.to_string())
} else {
None
}
})
.unwrap_or_else(|| "".to_string());
if !sessdata.is_empty() {
let (_, body1) = init_uid(headers.clone());
let body1_v: Value = serde_json::from_str(body1.as_str()).unwrap();
// Check if the authentication was successful
if let Some(mid) = body1_v["data"]["mid"].as_i64() {
auth_map.insert("uid".to_string(), mid.to_string());
log::info!("Successfully authenticated with uid: {}", mid);
} else {
log::warn!("Authentication failed - SESSDATA may be invalid or expired");
log::debug!("Auth response: {}", body1);
auth_map.insert("uid".to_string(), "0".to_string());
}
} else {
auth_map.insert("uid".to_string(), "0".to_string());
}
// here the live room id is easily obtained, so we not get it by url.
auth_map.insert("room_id".to_string(), room_id.to_string());
let room_id_num = room_id.parse::<u64>().expect("room_id must be a valid u64");
let (_, body4) = init_host_server(headers.clone(), room_id_num);
let body4_res: Value = serde_json::from_str(body4.as_str()).unwrap();
let server_info = &body4_res["data"];
let token = &body4_res["data"]["token"].as_str().unwrap();
auth_map.insert("token".to_string(), token.to_string());
let auth_msg = AuthMessage::from(&auth_map);
(server_info.clone(), auth_msg)
}
pub fn connect(v: Value) -> (WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>) {
connect_result(v).expect("Can't connect")
}
pub fn connect_result(
v: Value,
) -> Result<(WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>), String> {
let danmu_server = gen_damu_list(&v);
let (host, url, ws_url) = find_server(danmu_server);
let connector: native_tls::TlsConnector =
native_tls::TlsConnector::new().map_err(|e| format!("tls init failed: {}", e))?;
let stream: TcpStream = TcpStream::connect(url.as_str())
.map_err(|e| format!("tcp connect to {} failed: {}", url, e))?;
let stream: native_tls::TlsStream<TcpStream> = connector
.connect(host.as_str(), stream)
.map_err(|e| format!("tls connect to {} failed: {}", host, e))?;
let parsed_url =
Url::parse(ws_url.as_str()).map_err(|e| format!("invalid websocket url: {}", e))?;
client(parsed_url, stream).map_err(|e| format!("websocket handshake failed: {}", e))
}
pub enum Operation {
AUTH,
HEARTBEAT,
}
pub fn make_packet(body: &str, ops: Operation) -> Vec<u8> {
let json: Value = serde_json::from_str(body).unwrap();
let temp = json.to_string();
let body_content: &[u8] = temp.as_bytes();
let pack_len: [u8; 4] = ((16 + body.len()) as u32).to_be_bytes();
let raw_header_size: [u8; 2] = (16 as u16).to_be_bytes();
let ver: [u8; 2] = (1 as u16).to_be_bytes();
let operation: [u8; 4] = match ops {
Operation::AUTH => (7 as u32).to_be_bytes(),
Operation::HEARTBEAT => (2 as u32).to_be_bytes(),
};
let seq_id: [u8; 4] = (1 as u32).to_be_bytes();
let mut res = pack_len.to_vec();
res.append(&mut raw_header_size.to_vec());
res.append(&mut ver.to_vec());
res.append(&mut operation.to_vec());
res.append(&mut seq_id.to_vec());
res.append(&mut body_content.to_vec());
res
}
pub fn get_msg_header(v_s: &[u8]) -> MsgHead {
let mut pack_len: [u8; 4] = [0; 4];
let mut raw_header_size: [u8; 2] = [0; 2];
let mut ver: [u8; 2] = [0; 2];
let mut operation: [u8; 4] = [0; 4];
let mut seq_id: [u8; 4] = [0; 4];
for (i, v) in v_s.iter().enumerate() {
if i < 4 {
pack_len[i] = *v;
continue;
}
if i < 6 {
raw_header_size[i - 4] = *v;
continue;
}
if i < 8 {
ver[i - 6] = *v;
continue;
}
if i < 12 {
operation[i - 8] = *v;
continue;
}
if i < 16 {
seq_id[i - 12] = *v;
continue;
}
}
MsgHead {
pack_len: u32::from_be_bytes(pack_len),
raw_header_size: u16::from_be_bytes(raw_header_size),
ver: u16::from_be_bytes(ver),
operation: u32::from_be_bytes(operation),
seq_id: u32::from_be_bytes(seq_id),
}
}
pub fn decompress(body: &[u8]) -> std::io::Result<Vec<u8>> {
use brotlic::DecompressorReader;
use std::io::Read;
let mut decompressed_reader: DecompressorReader<&[u8]> = DecompressorReader::new(body);
let mut decoded_input = Vec::new();
let _ = decompressed_reader.read_to_end(&mut decoded_input)?;
Ok(decoded_input)
}
/// here we detail [info format is online](https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/live/message_stream.md)
/// .
pub fn handle(json: Value) -> Option<BiliMessage> {
let category = json["cmd"].as_str().unwrap_or("");
match category {
// Preserve all fields for callers that need a stable UID, gift price
// and upstream event identifier. The previous typed variants discard
// those values, which makes reliable accounting impossible.
"DANMU_MSG" | "SEND_GIFT" => Some(BiliMessage::Raw(json)),
"ONLINE_RANK_COUNT" => Some(BiliMessage::OnlineRankCount {
count: json["data"]["count"].as_u64().unwrap_or(0),
online_count: json["data"]["online_count"].as_u64().unwrap_or(0),
}),
// Add more cases for other types as needed
_ => Some(BiliMessage::Raw(json)),
}
}
/// Enhanced init_server that can automatically detect cookies from browser
pub fn init_server_auto(
provided_cookies: Option<&str>,
room_id: &str,
) -> Result<(Value, AuthMessage), String> {
// Try to get cookies from provided value or browser cookies
let cookies = get_cookies_or_browser(provided_cookies)
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
log::info!(
"Using cookies for authentication: {}...",
&cookies[..10.min(cookies.len())]
);
let result = init_server(&cookies, room_id);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use futures_channel::mpsc::channel;
#[test]
fn test_bili_live_client_connect() {
// Always enable debug log output for test
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Debug)
.try_init();
// Get cookies from environment variable for real test
let cookies =
std::env::var("Cookie").unwrap_or_else(|_| "SESSDATA=dummy_sessdata".to_string());
let room_id = "24779526";
let (tx, _rx) = channel(10);
let _client = BiliLiveClient::new(&cookies, room_id, tx);
}
}
+276
View File
@@ -0,0 +1,276 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub connection: Option<ConnectionConfig>,
#[serde(default)]
pub tts: Option<TtsConfig>,
#[serde(default)]
pub auto_reply: Option<AutoReplyConfig>,
#[serde(default)]
pub debug: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConnectionConfig {
pub cookies: Option<String>,
pub room_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TtsConfig {
pub server: Option<String>,
pub voice: Option<String>,
pub backend: Option<String>,
pub quality: Option<String>,
pub format: Option<String>,
pub sample_rate: Option<u32>,
pub volume: Option<f32>,
pub command: Option<String>,
pub args: Option<String>,
/// Alibaba DashScope TTS configuration
pub ali_api_key: Option<String>,
pub ali_model: Option<String>,
pub ali_voice: Option<String>,
pub ali_language_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerConfig {
pub keywords: Vec<String>,
pub response: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoReplyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_cooldown")]
pub cooldown_seconds: u64,
#[serde(default)]
pub triggers: Vec<TriggerConfig>,
}
impl Default for AutoReplyConfig {
fn default() -> Self {
Self {
enabled: false,
cooldown_seconds: default_cooldown(),
triggers: vec![],
}
}
}
fn default_cooldown() -> u64 {
5
}
impl AutoReplyConfig {
/// Convert to blivedm::plugins::auto_reply::AutoReplyConfig
pub fn to_plugin_config(&self) -> blivedm::plugins::auto_reply::AutoReplyConfig {
blivedm::plugins::auto_reply::AutoReplyConfig {
enabled: self.enabled,
cooldown_seconds: self.cooldown_seconds,
triggers: self
.triggers
.iter()
.map(|t| t.to_plugin_trigger())
.collect(),
}
}
}
impl TriggerConfig {
/// Convert to blivedm::plugins::auto_reply::TriggerConfig
pub fn to_plugin_trigger(&self) -> blivedm::plugins::auto_reply::TriggerConfig {
blivedm::plugins::auto_reply::TriggerConfig {
keywords: self.keywords.clone(),
response: self.response.clone(),
}
}
}
impl Config {
/// Load configuration from file with fallback locations
pub fn load_from_file(config_path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
let config_file = if let Some(path) = config_path {
// Use provided path
path.to_path_buf()
} else {
// Try current directory first
let current_dir_config = PathBuf::from("config.toml");
if current_dir_config.exists() {
current_dir_config
} else {
// Try XDG config directory
Self::get_default_config_path()?
}
};
if !config_file.exists() {
log::debug!("Config file {:?} not found", config_file);
// Create config file if it doesn't exist and we're using default locations
if config_path.is_none() {
match Self::create_example_config(&config_file) {
Ok(()) => {
println!("Created configuration file: {:?}", config_file);
println!("You can customize it as needed.");
}
Err(e) => {
log::warn!("Failed to create config file: {}", e);
return Ok(Config::default());
}
}
} else {
return Ok(Config::default());
}
}
log::info!("Loading configuration from {:?}", config_file);
let content = fs::read_to_string(&config_file)
.map_err(|e| format!("Failed to read config file {:?}: {}", config_file, e))?;
let config: Config = toml::from_str(&content)
.map_err(|e| format!("Failed to parse config file {:?}: {}", config_file, e))?;
Ok(config)
}
/// Get the default configuration file path (~/.config/blivedm_rs/config.toml)
fn get_default_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
let config_dir = dirs::config_dir()
.ok_or("Unable to determine config directory")?
.join("blivedm_rs");
// Create config directory if it doesn't exist
if !config_dir.exists() {
fs::create_dir_all(&config_dir).map_err(|e| {
format!("Failed to create config directory {:?}: {}", config_dir, e)
})?;
}
Ok(config_dir.join("config.toml"))
}
/// Create an example configuration file
pub fn create_example_config(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let example_config = Config {
connection: None,
tts: Some(TtsConfig {
server: Some("http://localhost:8000".to_string()),
voice: None,
backend: None,
quality: None,
format: None,
sample_rate: None,
volume: None,
command: None,
args: None,
ali_api_key: None,
ali_model: None,
ali_voice: None,
ali_language_type: None,
}),
auto_reply: Some(AutoReplyConfig {
enabled: false,
cooldown_seconds: 5,
triggers: vec![
TriggerConfig {
keywords: vec!["你好".to_string(), "hello".to_string()],
response: "欢迎来到直播间!".to_string(),
},
TriggerConfig {
keywords: vec!["谢谢".to_string(), "thanks".to_string()],
response: "不客气~".to_string(),
},
],
}),
debug: None,
};
let toml_string = toml::to_string_pretty(&example_config)
.map_err(|e| format!("Failed to serialize example config: {}", e))?;
fs::write(path, toml_string)
.map_err(|e| format!("Failed to write example config to {:?}: {}", path, e))?;
Ok(())
}
/// Print the effective configuration (for debugging)
#[allow(clippy::too_many_arguments)]
pub fn print_effective_config(
cookies: &Option<String>,
room_id: &str,
tts_server: &Option<String>,
tts_voice: &Option<String>,
tts_backend: &Option<String>,
tts_quality: &Option<String>,
tts_format: &Option<String>,
tts_sample_rate: &Option<u32>,
tts_volume: &Option<f32>,
tts_command: &Option<String>,
tts_args: &Option<String>,
ali_api_key: &Option<String>,
ali_model: &Option<String>,
ali_voice: &Option<String>,
ali_language_type: &Option<String>,
auto_reply: &Option<AutoReplyConfig>,
debug: bool,
) {
println!("=== Effective Configuration ===");
println!("Connection:");
println!(" room_id: {}", room_id);
if let Some(cookies_val) = cookies {
println!(
" cookies: {}...",
&cookies_val.chars().take(20).collect::<String>()
);
} else {
println!(" cookies: None (will auto-detect)");
}
println!("TTS (REST API):");
println!(" server: {:?}", tts_server);
println!(" voice: {:?}", tts_voice);
println!(" backend: {:?}", tts_backend);
println!(" quality: {:?}", tts_quality);
println!(" format: {:?}", tts_format);
println!(" sample_rate: {:?}", tts_sample_rate);
println!(" volume: {:?}", tts_volume);
println!(" command: {:?}", tts_command);
println!(" args: {:?}", tts_args);
println!("TTS (Alibaba DashScope):");
if let Some(key) = ali_api_key {
println!(
" api_key: {}...",
&key.chars().take(10).collect::<String>()
);
} else {
println!(" api_key: None");
}
println!(" model: {:?}", ali_model);
println!(" voice: {:?}", ali_voice);
println!(" language_type: {:?}", ali_language_type);
println!("Auto Reply:");
if let Some(auto_reply_config) = auto_reply {
println!(" enabled: {}", auto_reply_config.enabled);
println!(" cooldown_seconds: {}", auto_reply_config.cooldown_seconds);
println!(
" triggers: {} configured",
auto_reply_config.triggers.len()
);
} else {
println!(" enabled: false (not configured)");
}
println!("Debug: {}", debug);
println!("===============================");
}
}
+17
View File
@@ -0,0 +1,17 @@
// src/lib.rs
//! Bilibili live room danmaku WebSocket client library with TTS and plugin support
pub mod client;
pub mod plugins;
pub mod tui;
// Re-export commonly used items from client
pub use client::{auth, get_cookies_or_browser, models, scheduler, websocket};
#[cfg(feature = "browser_cookies")]
pub use client::browser_cookies;
// Re-export plugin modules and helpers
pub use plugins::{
auto_reply, auto_reply_handler, terminal_display, terminal_display_handler, tts, tts_handler,
tts_handler_command, tts_handler_default,
};
+481
View File
@@ -0,0 +1,481 @@
// src/main.rs
// Standalone binary to test integration of the terminal display plugin with the BiliLiveClient
mod config;
use blivedm::client::get_cookies_or_browser;
use blivedm::client::scheduler::{EventContext, Scheduler};
use blivedm::client::websocket::BiliLiveClient;
use blivedm::plugins::terminal_display::TerminalDisplayHandler;
use blivedm::plugins::tts::TtsHandler;
use blivedm::tui::{TuiApp, TuiLogger, run_tui};
use clap::{CommandFactory, Parser};
use clap_complete::{Shell, generate};
use config::Config;
use futures::channel::mpsc;
use futures::stream::StreamExt;
use std::collections::VecDeque;
use std::env;
use std::path::PathBuf;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tokio::runtime::Runtime;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to configuration file
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
/// Print effective configuration and exit
#[arg(long)]
print_config: bool,
/// Cookies for Bilibili authentication (optional - will auto-detect from browser if not provided)
#[arg(long, value_name = "COOKIES")]
cookies: Option<String>,
/// Room ID to connect to
#[arg(long, value_name = "ROOM_ID")]
room_id: Option<String>,
/// TTS REST API server URL
#[arg(long, value_name = "URL")]
tts_server: Option<String>,
/// TTS voice ID (e.g., "zh-CN-XiaoxiaoNeural")
#[arg(long, value_name = "VOICE")]
tts_voice: Option<String>,
/// TTS backend ("edge", "xtts", "piper")
#[arg(long, value_name = "BACKEND")]
tts_backend: Option<String>,
/// TTS audio quality ("low", "medium", "high")
#[arg(long, value_name = "QUALITY")]
tts_quality: Option<String>,
/// TTS audio format (e.g., "wav")
#[arg(long, value_name = "FORMAT")]
tts_format: Option<String>,
/// TTS sample rate (e.g., 22050, 44100)
#[arg(long, value_name = "RATE")]
tts_sample_rate: Option<u32>,
/// TTS audio volume (0.0 to 1.0)
#[arg(long, value_name = "VOLUME")]
tts_volume: Option<f32>,
/// Local TTS command (e.g., "say", "espeak-ng")
#[arg(long, value_name = "COMMAND")]
tts_command: Option<String>,
/// Comma-separated arguments for TTS command
#[arg(long, value_name = "ARGS", allow_hyphen_values = true)]
tts_args: Option<String>,
/// Alibaba DashScope API key for ali-tts (can also use DASHSCOPE_API_KEY env)
#[arg(long, value_name = "KEY")]
ali_api_key: Option<String>,
/// Alibaba TTS model (e.g., "qwen3-tts-flash")
#[arg(long, value_name = "MODEL")]
ali_model: Option<String>,
/// Alibaba TTS voice (e.g., "Cherry", "Chelsie")
#[arg(long, value_name = "VOICE")]
ali_voice: Option<String>,
/// Alibaba TTS language type (e.g., "Chinese", "English")
#[arg(long, value_name = "LANG")]
ali_language_type: Option<String>,
/// Enable debug logging
#[arg(long)]
debug: bool,
/// Enable auto reply plugin
#[arg(long)]
auto_reply: bool,
/// Generate shell completion script (bash, zsh, fish, powershell, elvish)
#[arg(long, value_name = "SHELL")]
generate_completion: Option<Shell>,
}
fn main() {
let args = Args::parse();
// Handle shell completion generation early (before any other processing)
if let Some(shell) = args.generate_completion {
let mut cmd = Args::command();
generate(shell, &mut cmd, "blivedm", &mut std::io::stdout());
return;
}
// Load configuration from file first
let config = match Config::load_from_file(args.config.as_deref()) {
Ok(config) => config,
Err(e) => {
eprintln!("Error loading configuration: {}", e);
std::process::exit(1);
}
};
// Initialize logging with precedence: CLI args > env vars > config file
let debug_enabled =
args.debug || env::var("DEBUG").unwrap_or_default() == "1" || config.debug.unwrap_or(false);
// Load cookies and room_id with precedence: CLI args > env vars > config file > defaults
let cookies = args
.cookies
.or_else(|| {
env::var("Cookie")
.ok()
.filter(|s| !s.is_empty() && s != "SESSDATA=dummy_sessdata")
})
.or_else(|| config.connection.as_ref().and_then(|c| c.cookies.clone()));
// If no manual cookies provided, try browser auto-detection
let cookies = if cookies.is_none() {
if debug_enabled {
log::info!("No manual cookies provided, attempting browser auto-detection...");
}
get_cookies_or_browser(None)
} else {
if debug_enabled {
log::info!("Using manually provided cookies");
}
cookies
};
let room_id = args
.room_id
.or_else(|| env::var("ROOM_ID").ok())
.or_else(|| config.connection.as_ref().and_then(|c| c.room_id.clone()))
.unwrap_or_else(|| "24779526".to_string());
// Configure TTS with precedence: CLI args > config file
let tts_server = args
.tts_server
.or_else(|| config.tts.as_ref().and_then(|t| t.server.clone()));
let tts_voice = args
.tts_voice
.or_else(|| config.tts.as_ref().and_then(|t| t.voice.clone()));
let tts_backend = args
.tts_backend
.or_else(|| config.tts.as_ref().and_then(|t| t.backend.clone()));
let tts_quality = args
.tts_quality
.or_else(|| config.tts.as_ref().and_then(|t| t.quality.clone()));
let tts_format = args
.tts_format
.or_else(|| config.tts.as_ref().and_then(|t| t.format.clone()));
let tts_sample_rate = args
.tts_sample_rate
.or_else(|| config.tts.as_ref().and_then(|t| t.sample_rate));
let tts_volume = args
.tts_volume
.or_else(|| config.tts.as_ref().and_then(|t| t.volume));
let tts_command = args
.tts_command
.or_else(|| config.tts.as_ref().and_then(|t| t.command.clone()));
let tts_args = args
.tts_args
.or_else(|| config.tts.as_ref().and_then(|t| t.args.clone()));
// Configure Alibaba TTS with precedence: CLI args > env vars > config file
let ali_api_key = args
.ali_api_key
.or_else(|| env::var("DASHSCOPE_API_KEY").ok())
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_api_key.clone()));
let ali_model = args
.ali_model
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_model.clone()));
let ali_voice = args
.ali_voice
.or_else(|| config.tts.as_ref().and_then(|t| t.ali_voice.clone()));
let ali_language_type = args.ali_language_type.or_else(|| {
config
.tts
.as_ref()
.and_then(|t| t.ali_language_type.clone())
});
// Configure auto reply with precedence: CLI args > config file
let auto_reply_config = if let Some(config_auto_reply) = &config.auto_reply {
// Use config file settings, but allow CLI flag to override enabled
let mut plugin_config = config_auto_reply.to_plugin_config();
if args.auto_reply {
plugin_config.enabled = true;
}
plugin_config
} else {
// No config file section, use defaults with CLI flag
let mut default_config = blivedm::plugins::auto_reply::AutoReplyConfig::default();
default_config.enabled = args.auto_reply;
default_config
};
// If user wants to see config, print and exit
if args.print_config {
// Create a temporary config struct for display that reflects the effective settings
let effective_auto_reply = if auto_reply_config.enabled {
Some(config::AutoReplyConfig {
enabled: auto_reply_config.enabled,
cooldown_seconds: auto_reply_config.cooldown_seconds,
triggers: auto_reply_config
.triggers
.iter()
.map(|t| config::TriggerConfig {
keywords: t.keywords.clone(),
response: t.response.clone(),
})
.collect(),
})
} else {
None
};
Config::print_effective_config(
&cookies,
&room_id,
&tts_server,
&tts_voice,
&tts_backend,
&tts_quality,
&tts_format,
&tts_sample_rate,
&tts_volume,
&tts_command,
&tts_args,
&ali_api_key,
&ali_model,
&ali_voice,
&ali_language_type,
&effective_auto_reply,
debug_enabled,
);
std::process::exit(0);
}
// Initialize TuiLogger to capture logs into a shared buffer for the TUI logs panel.
// When debug is enabled, capture Debug level; otherwise capture Info level.
let log_level = if debug_enabled {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
let log_buffer = TuiLogger::init(log_level);
// Create client with automatic browser cookie detection
let (tx, mut rx) = mpsc::channel(64);
let mut client = match BiliLiveClient::new_auto(cookies.as_deref(), &room_id, tx) {
Ok(client) => {
log::info!("Successfully created client with automatic cookie detection");
client
}
Err(e) => {
eprintln!("Failed to create client: {}", e);
eprintln!(
"Please ensure you are logged into bilibili.com in your browser, or provide cookies manually."
);
std::process::exit(1);
}
};
client.send_auth();
client.send_heart_beat();
let shared_client: Arc<Mutex<BiliLiveClient>> = Arc::new(Mutex::new(client));
let heart_beats: Arc<Mutex<BiliLiveClient>> = Arc::clone(&shared_client);
thread::spawn(move || {
loop {
match heart_beats.lock() {
Ok(mut heart_beats_c) => {
heart_beats_c.send_heart_beat();
}
Err(e) => {
eprintln!("Error acquiring lock on stream: {}", e);
break;
}
}
thread::sleep(Duration::new(20, 0));
}
});
let rec_msg: Arc<Mutex<BiliLiveClient>> = Arc::clone(&shared_client);
thread::spawn(move || {
loop {
match rec_msg.lock() {
Ok(mut rec_c) => {
if let Err(e) = rec_c.receive() {
log::error!("{}", e);
}
}
Err(e) => {
eprintln!("Error acquiring lock on stream: {}", e);
break;
}
}
thread::sleep(Duration::from_millis(10)); // instead of 10 microseconds
}
});
// Set up the scheduler with context and add the terminal display handler
if debug_enabled {
match &cookies {
Some(cookie_str) => {
log::debug!(
"Cookies found and passed to context: {}...",
&cookie_str.chars().take(50).collect::<String>()
);
}
None => {
log::warn!(
"No cookies found for EventContext - auto-reply will not be able to send messages"
);
}
}
}
// Create shared message buffer for TUI
let message_buffer: Arc<Mutex<VecDeque<String>>> = Arc::new(Mutex::new(VecDeque::new()));
// Create shared online count for TUI title display
let online_count: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
let context = EventContext::new(cookies.clone(), room_id.parse::<u64>().unwrap_or(0));
let mut scheduler = Scheduler::new(context);
let terminal_handler = Arc::new(TerminalDisplayHandler::with_online_count(
Arc::clone(&message_buffer),
Arc::clone(&online_count),
));
scheduler.add_sequential_handler(terminal_handler);
if let Some(server_url) = tts_server {
// REST API TTS configuration
let tts_handler = Arc::new(TtsHandler::new_rest_api_with_volume(
server_url,
tts_voice,
tts_backend,
tts_quality,
tts_format,
tts_sample_rate,
tts_volume,
));
scheduler.add_sequential_handler(tts_handler);
println!("TTS configured with REST API server");
} else if let Some(api_key) = ali_api_key {
// Alibaba DashScope TTS configuration
let model = ali_model.unwrap_or_else(|| "qwen3-tts-flash".to_string());
let voice = ali_voice.unwrap_or_else(|| "Cherry".to_string());
let tts_handler = Arc::new(TtsHandler::new_ali_tts(
api_key,
model.clone(),
voice.clone(),
ali_language_type,
tts_volume,
));
scheduler.add_sequential_handler(tts_handler);
println!(
"TTS configured with Alibaba DashScope (model: {}, voice: {})",
model, voice
);
} else if let Some(tts_cmd) = tts_command {
// Command-line TTS configuration
let cmd_args = tts_args
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default();
let tts_handler = Arc::new(TtsHandler::new_command(tts_cmd, cmd_args));
scheduler.add_sequential_handler(tts_handler);
println!("TTS configured with local command");
} else {
println!(
"No TTS configuration provided. Use --ali-api-key, --tts-server, or --tts-command to enable TTS."
);
}
// Add auto reply plugin if enabled
if auto_reply_config.enabled {
let auto_reply_handler = blivedm::plugins::auto_reply_handler(auto_reply_config);
scheduler.add_sequential_handler(auto_reply_handler);
println!("Auto reply plugin enabled");
} else {
println!(
"Auto reply plugin disabled. Use --auto-reply or configure in config file to enable."
);
}
// Add initial system message to buffer
TuiApp::add_message(&message_buffer, format!("[System] Bilibili Danmu Client"));
TuiApp::add_message(
&message_buffer,
format!("[System] Connected to room: {}", room_id),
);
if let Some(cookies_val) = &cookies {
TuiApp::add_message(
&message_buffer,
format!(
"[System] Using provided cookies: {}...",
&cookies_val.chars().take(30).collect::<String>()
),
);
} else {
TuiApp::add_message(
&message_buffer,
"[System] Using auto-detected cookies from browser".to_string(),
);
}
// create a thread to process the rx channel messages using tokio runtime and pass to scheduler
let rt = Arc::new(Runtime::new().unwrap());
let rt_clone = Arc::clone(&rt);
rt.spawn(async move {
while let Some(msg) = rx.next().await {
scheduler.trigger(msg);
}
});
// Create TUI app
let mut tui_app = TuiApp::with_online_count(
Arc::clone(&message_buffer),
room_id.clone(),
Arc::clone(&online_count),
);
tui_app.set_log_buffer(log_buffer);
let context_for_chat = EventContext::new(cookies.clone(), room_id.parse::<u64>().unwrap_or(0));
let message_buffer_for_feedback = Arc::clone(&message_buffer);
// Run TUI with message sending callback
let tui_result = run_tui(tui_app, move |message| {
let context_clone = context_for_chat.clone();
let rt_for_send = Arc::clone(&rt_clone);
let buffer_clone = Arc::clone(&message_buffer_for_feedback);
rt_for_send.spawn(async move {
if let Err(e) = blivedm::plugins::send_danmaku_message(&message, &context_clone).await {
TuiApp::add_message(
&buffer_clone,
format!("[System] Error sending message: {}", e),
);
}
});
});
if let Err(e) = tui_result {
eprintln!("TUI error: {}", e);
}
// close the client
match shared_client.lock() {
Ok(mut _client) => {}
Err(e) => {
eprintln!("Error acquiring lock on stream: {}", e);
}
}
// wait for the threads to finish
thread::sleep(Duration::new(1, 0));
}
+478
View File
@@ -0,0 +1,478 @@
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
use log::{debug, error, info, warn};
use reqwest::header::{HeaderMap, HeaderValue};
use serde::Serialize;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::runtime::Runtime;
/// Configuration for keyword-response triggers
#[derive(Debug, Clone)]
pub struct TriggerConfig {
/// Keywords that trigger this response
pub keywords: Vec<String>,
/// Response message to send
pub response: String,
}
/// Configuration for the auto reply plugin
#[derive(Debug, Clone)]
pub struct AutoReplyConfig {
/// Whether the plugin is enabled
pub enabled: bool,
/// Minimum cooldown between replies in seconds
pub cooldown_seconds: u64,
/// List of trigger configurations
pub triggers: Vec<TriggerConfig>,
}
impl Default for AutoReplyConfig {
fn default() -> Self {
Self {
enabled: false,
cooldown_seconds: 5,
triggers: vec![
TriggerConfig {
keywords: vec!["你好".to_string(), "hello".to_string()],
response: "欢迎来到直播间!".to_string(),
},
TriggerConfig {
keywords: vec!["谢谢".to_string(), "thanks".to_string()],
response: "不客气~".to_string(),
},
],
}
}
}
/// Parameters for sending a danmaku message to Bilibili API
#[derive(Serialize, Debug)]
struct SendDanmakuRequest {
csrf: String,
roomid: u64,
msg: String,
rnd: u64,
fontsize: u32,
color: u32,
mode: u32,
bubble: u32,
room_type: u32,
jumpfrom: u32,
reply_mid: u32,
reply_attr: u32,
reply_uname: String,
replay_dmid: String,
statistics: String,
csrf_token: String,
}
/// Extract CSRF token from cookies string
pub fn extract_csrf_token(cookies: &str) -> Option<String> {
for cookie in cookies.split(';') {
let cookie = cookie.trim();
if cookie.starts_with("bili_jct=") {
return Some(cookie[9..].to_string());
}
}
None
}
/// Send a danmaku message to the Bilibili live room
///
/// # Arguments
/// * `message` - The text message to send
/// * `context` - Event context containing cookies and room_id
///
/// # Returns
/// Returns Ok(()) on success, or an error if the request fails
pub async fn send_danmaku_message(
message: &str,
context: &EventContext,
) -> Result<(), Box<dyn std::error::Error>> {
let cookies = match &context.cookies {
Some(cookies) => cookies,
None => {
return Err("No cookies available for sending danmaku".into());
}
};
let csrf_token = match extract_csrf_token(cookies) {
Some(token) => token,
None => {
return Err("Could not extract CSRF token from cookies".into());
}
};
// Current timestamp
let rnd = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let request = SendDanmakuRequest {
csrf: csrf_token.clone(),
roomid: context.room_id,
msg: message.to_string(),
rnd,
fontsize: 25,
color: 16777215, // White color
mode: 1, // Scroll mode
bubble: 0,
room_type: 0,
jumpfrom: 0,
reply_mid: 0,
reply_attr: 0,
reply_uname: String::new(),
replay_dmid: String::new(),
statistics: r#"{"appId":100,"platform":5}"#.to_string(),
csrf_token,
};
// Set up headers
let mut headers = HeaderMap::new();
headers.insert("Cookie", HeaderValue::from_str(cookies)?);
headers.insert(
"User-Agent",
HeaderValue::from_static(
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
),
);
headers.insert(
"Referer",
HeaderValue::from_str(&format!("https://live.bilibili.com/{}", context.room_id))?,
);
debug!("Sending danmaku: {}", message);
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = http_client
.post("https://api.live.bilibili.com/msg/send")
.headers(headers)
.form(&request)
.send()
.await?;
if response.status().is_success() {
info!("Successfully sent danmaku: {}", message);
Ok(())
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
warn!("Failed to send danmaku, status: {}", status);
debug!("Response body: {}", body);
Err(format!("Failed to send danmaku: {} - {}", status, body).into())
}
}
/// Auto reply handler that monitors danmaku for keywords and sends responses
pub struct AutoReplyHandler {
config: AutoReplyConfig,
last_reply: Arc<Mutex<Option<Instant>>>,
http_client: reqwest::Client,
runtime: Arc<Runtime>,
}
impl AutoReplyHandler {
/// Create a new auto reply handler with the given configuration
pub fn new(config: AutoReplyConfig) -> Self {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client");
let runtime = Arc::new(Runtime::new().expect("Failed to create tokio runtime"));
Self {
config,
last_reply: Arc::new(Mutex::new(None)),
http_client,
runtime,
}
}
/// Check if any keyword matches the message text
fn find_matching_trigger(&self, text: &str) -> Option<&TriggerConfig> {
let text_lower = text.to_lowercase();
for trigger in &self.config.triggers {
for keyword in &trigger.keywords {
if text_lower.contains(&keyword.to_lowercase()) {
return Some(trigger);
}
}
}
None
}
/// Get the response from the trigger
fn select_response(&self, trigger: &TriggerConfig) -> Option<String> {
if trigger.response.is_empty() {
return None;
}
Some(trigger.response.clone())
}
/// Check if enough time has passed since the last reply
fn check_cooldown(&self) -> bool {
let last_reply = self.last_reply.lock().unwrap();
match *last_reply {
Some(last_time) => {
let elapsed = last_time.elapsed();
elapsed >= Duration::from_secs(self.config.cooldown_seconds)
}
None => true,
}
}
/// Update the last reply timestamp
fn update_last_reply(&self) {
let mut last_reply = self.last_reply.lock().unwrap();
*last_reply = Some(Instant::now());
}
/// Extract CSRF token from cookies
fn extract_csrf_token(&self, cookies: &str) -> Option<String> {
extract_csrf_token(cookies)
}
/// Send a danmaku message to the Bilibili API
async fn send_danmaku(
&self,
message: &str,
context: &EventContext,
) -> Result<(), reqwest::Error> {
let cookies = match &context.cookies {
Some(cookies) => cookies,
None => {
warn!("No cookies available for sending danmaku");
return Ok(());
}
};
let csrf_token = match self.extract_csrf_token(cookies) {
Some(token) => token,
None => {
error!("Could not extract CSRF token from cookies");
return Ok(());
}
};
// Current timestamp
let rnd = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let request = SendDanmakuRequest {
csrf: csrf_token.clone(),
roomid: context.room_id,
msg: message.to_string(),
rnd,
fontsize: 25,
color: 16777215, // White color
mode: 1, // Scroll mode
bubble: 0,
room_type: 0,
jumpfrom: 0,
reply_mid: 0,
reply_attr: 0,
reply_uname: String::new(),
replay_dmid: String::new(),
statistics: r#"{"appId":100,"platform":5}"#.to_string(),
csrf_token,
};
// Set up headers
let mut headers = HeaderMap::new();
headers.insert("Cookie", HeaderValue::from_str(cookies).unwrap());
headers.insert(
"User-Agent",
HeaderValue::from_static(
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
),
);
headers.insert(
"Referer",
HeaderValue::from_str(&format!("https://live.bilibili.com/{}", context.room_id))
.unwrap(),
);
debug!("Sending danmaku: {}", message);
let response = self
.http_client
.post("https://api.live.bilibili.com/msg/send")
.headers(headers)
.form(&request)
.send()
.await?;
if response.status().is_success() {
info!("Successfully sent danmaku: {}", message);
} else {
warn!("Failed to send danmaku, status: {}", response.status());
let body = response.text().await.unwrap_or_default();
debug!("Response body: {}", body);
}
Ok(())
}
}
impl EventHandler for AutoReplyHandler {
fn handle(&self, msg: &BiliMessage, context: &EventContext) {
if !self.config.enabled {
return;
}
// Only process danmaku messages
if let BiliMessage::Danmu { user: _, text } = msg {
// Check for keyword match
if let Some(trigger) = self.find_matching_trigger(text) {
// Check cooldown
if !self.check_cooldown() {
debug!("Auto reply on cooldown, skipping");
return;
}
// Select response
if let Some(response) = self.select_response(trigger) {
debug!(
"Auto reply triggered by '{}', responding with '{}'",
text, response
);
// Update cooldown
self.update_last_reply();
// Send the reply asynchronously
let runtime = Arc::clone(&self.runtime);
let _http_client = self.http_client.clone();
let response_msg = response.clone();
let context_clone = context.clone();
let handler = self.clone();
runtime.spawn(async move {
if let Err(e) = handler.send_danmaku(&response_msg, &context_clone).await {
error!("Failed to send auto reply: {}", e);
}
});
}
}
}
}
}
// Implement Clone for AutoReplyHandler
impl Clone for AutoReplyHandler {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
last_reply: Arc::clone(&self.last_reply),
http_client: self.http_client.clone(),
runtime: Arc::clone(&self.runtime),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
#[test]
fn test_keyword_matching() {
let config = AutoReplyConfig::default();
let handler = AutoReplyHandler::new(config);
// Test keyword matching
assert!(handler.find_matching_trigger("你好世界").is_some());
assert!(handler.find_matching_trigger("Hello world").is_some());
assert!(handler.find_matching_trigger("谢谢大家").is_some());
assert!(handler.find_matching_trigger("Thanks everyone").is_some());
assert!(handler.find_matching_trigger("random text").is_none());
}
#[test]
fn test_response_selection() {
let config = AutoReplyConfig::default();
let handler = AutoReplyHandler::new(config);
let trigger = &handler.config.triggers[0];
let response = handler.select_response(trigger);
assert!(response.is_some());
assert_eq!(response.unwrap(), trigger.response);
}
#[test]
fn test_cooldown() {
let config = AutoReplyConfig {
enabled: true,
cooldown_seconds: 1,
triggers: vec![],
};
let handler = AutoReplyHandler::new(config);
// Initial check should pass
assert!(handler.check_cooldown());
// Update timestamp
handler.update_last_reply();
// Should be on cooldown now
assert!(!handler.check_cooldown());
// Wait for cooldown
std::thread::sleep(Duration::from_secs(2));
// Should be off cooldown now
assert!(handler.check_cooldown());
}
#[test]
fn test_csrf_extraction() {
let config = AutoReplyConfig::default();
let handler = AutoReplyHandler::new(config);
let cookies = "SESSDATA=abc123; bili_jct=csrf_token_here; other=value";
let csrf = handler.extract_csrf_token(cookies);
assert_eq!(csrf, Some("csrf_token_here".to_string()));
let cookies_no_csrf = "SESSDATA=abc123; other=value";
let csrf = handler.extract_csrf_token(cookies_no_csrf);
assert_eq!(csrf, None);
}
#[test]
fn test_event_handler() {
let config = AutoReplyConfig {
enabled: true,
cooldown_seconds: 0, // No cooldown for testing
triggers: vec![TriggerConfig {
keywords: vec!["test".to_string()],
response: "test response".to_string(),
}],
};
let handler = AutoReplyHandler::new(config);
let context = EventContext {
cookies: Some("bili_jct=test_csrf; SESSDATA=test".to_string()),
room_id: 12345,
};
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "this is a test message".to_string(),
};
// This should trigger the auto reply (but won't actually send due to test environment)
handler.handle(&msg, &context);
}
}
+58
View File
@@ -0,0 +1,58 @@
pub mod auto_reply;
pub mod terminal_display;
pub mod tts;
use crate::client::scheduler::EventHandler;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
// Re-export danmaku sending utility for easy access
pub use auto_reply::send_danmaku_message;
/// Helper to create the handler as Arc<dyn EventHandler>
pub fn terminal_display_handler(
message_buffer: Arc<Mutex<VecDeque<String>>>,
) -> Arc<dyn EventHandler> {
Arc::new(terminal_display::TerminalDisplayHandler::new(
message_buffer,
))
}
/// Helper to create the TTS handler as Arc<dyn EventHandler>
/// Uses default Chinese voice settings with REST API
pub fn tts_handler_default(server_url: String) -> Arc<dyn EventHandler> {
Arc::new(tts::TtsHandler::new_rest_api_default(server_url))
}
/// Helper to create the TTS handler with REST API and custom configuration as Arc<dyn EventHandler>
pub fn tts_handler(
server_url: String,
voice: Option<String>,
backend: Option<String>,
quality: Option<String>,
format: Option<String>,
sample_rate: Option<u32>,
) -> Arc<dyn EventHandler> {
Arc::new(tts::TtsHandler::new_rest_api(
server_url,
voice,
backend,
quality,
format,
sample_rate,
))
}
/// Helper to create the command-based TTS handler as Arc<dyn EventHandler>
/// For local TTS commands like `say` on macOS or `espeak-ng` on Linux
pub fn tts_handler_command(tts_command: String, tts_args: Vec<String>) -> Arc<dyn EventHandler> {
Arc::new(tts::TtsHandler::new_command(tts_command, tts_args))
}
/// Helper to create the auto reply handler as Arc<dyn EventHandler>
pub fn auto_reply_handler(config: auto_reply::AutoReplyConfig) -> Arc<dyn EventHandler> {
Arc::new(auto_reply::AutoReplyHandler::new(config))
}
#[cfg(test)]
mod tests {}
+125
View File
@@ -0,0 +1,125 @@
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
use std::collections::VecDeque;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex};
/// A plugin that adds BiliMessages to a shared message buffer for TUI display.
pub struct TerminalDisplayHandler {
/// Shared message buffer for TUI
message_buffer: Arc<Mutex<VecDeque<String>>>,
/// Shared online count for TUI title display
online_count: Arc<AtomicU64>,
}
impl TerminalDisplayHandler {
/// Create a new TerminalDisplayHandler with a shared message buffer
pub fn new(message_buffer: Arc<Mutex<VecDeque<String>>>) -> Self {
Self {
message_buffer,
online_count: Arc::new(AtomicU64::new(0)),
}
}
/// Create a new TerminalDisplayHandler with shared message buffer and online count
pub fn with_online_count(
message_buffer: Arc<Mutex<VecDeque<String>>>,
online_count: Arc<AtomicU64>,
) -> Self {
Self {
message_buffer,
online_count,
}
}
}
impl EventHandler for TerminalDisplayHandler {
fn handle(&self, msg: &BiliMessage, _context: &EventContext) {
let formatted_msg = match msg {
BiliMessage::Danmu { user, text } => {
format!("[Danmu] {}: {}", user, text)
}
BiliMessage::Gift { user, gift , num} => {
format!("[Gift] {} sent a gift: {} X {}", user, gift, num)
}
BiliMessage::OnlineRankCount { online_count, .. } => {
// Update the shared online count for TUI title display
crate::tui::app::TuiApp::set_online_count(&self.online_count, *online_count);
// Don't add to message buffer - just update the title counter
return;
}
BiliMessage::Raw(json) => {
format!("[Raw] {}", json["cmd"].as_str().unwrap_or("Unknown"))
}
#[allow(deprecated)]
BiliMessage::Unsupported => "[Unsupported message type]".to_string(),
};
// Add message to buffer using the TuiApp helper method
crate::tui::app::TuiApp::add_message(&self.message_buffer, formatted_msg);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::models::BiliMessage;
use crate::client::scheduler::EventHandler;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
#[test]
fn test_terminal_display_handler_adds_danmu() {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "hello world".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
let messages = buffer.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "[Danmu] test_user: hello world");
}
#[test]
fn test_terminal_display_handler_adds_gift() {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
let msg = BiliMessage::Gift {
user: "gift_user".to_string(),
gift: "rocket".to_string(),
num: "count".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
let messages = buffer.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "[Gift] gift_user sent a gift: rocket");
}
#[test]
fn test_terminal_display_handler_adds_unsupported() {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let handler = TerminalDisplayHandler::new(Arc::clone(&buffer));
let msg = BiliMessage::Unsupported;
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
let messages = buffer.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "[Unsupported message type]");
}
}
+856
View File
@@ -0,0 +1,856 @@
use crate::client::models::BiliMessage;
use crate::client::scheduler::{EventContext, EventHandler};
use base64::{Engine as _, engine::general_purpose};
use log::{debug, error, info, warn};
use rodio::{Decoder, OutputStream, Sink};
use serde::{Deserialize, Serialize};
use std::io::Cursor;
use std::process::Command;
use std::sync::mpsc::{self, Sender};
use std::thread;
use std::thread::JoinHandle;
#[derive(Serialize, Debug)]
struct TtsRequest {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
backend: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
quality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sample_rate: Option<u32>,
}
#[derive(Deserialize, Debug)]
struct TtsResponse {
audio_data: String,
metadata: TtsMetadata,
#[allow(dead_code)]
cached: bool,
}
#[derive(Deserialize, Debug)]
struct TtsMetadata {
#[allow(dead_code)]
backend: String,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration: Option<f64>,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
sample_rate: Option<u32>,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[allow(dead_code)]
#[serde(skip_serializing_if = "Option::is_none")]
size_bytes: Option<u64>,
}
/// Alibaba DashScope TTS request structure
#[derive(Serialize, Debug)]
struct AliTtsRequest {
model: String,
input: AliTtsInput,
}
#[derive(Serialize, Debug)]
struct AliTtsInput {
text: String,
voice: String,
#[serde(skip_serializing_if = "Option::is_none")]
language_type: Option<String>,
}
/// Alibaba DashScope TTS SSE response structure
#[derive(Deserialize, Debug)]
struct AliTtsResponse {
output: Option<AliTtsOutput>,
#[allow(dead_code)]
request_id: Option<String>,
}
#[derive(Deserialize, Debug)]
struct AliTtsOutput {
#[serde(default)]
audio: Option<AliTtsAudio>,
/// Finish reason: "null" for intermediate, "stop" for final
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize, Debug)]
struct AliTtsAudio {
/// Base64 encoded audio data chunk (may be empty)
#[serde(default)]
data: Option<String>,
/// Audio URL (only in the final response when finish_reason is "stop")
#[serde(default)]
url: Option<String>,
#[allow(dead_code)]
#[serde(default)]
id: Option<String>,
#[allow(dead_code)]
#[serde(default)]
expires_at: Option<u64>,
}
/// TTS backend configuration
#[derive(Debug, Clone)]
pub enum TtsMode {
/// Use REST API for TTS with advanced neural voices
RestApi {
/// The base URL of the TTS server (e.g., "http://localhost:8000")
server_url: String,
/// Voice ID to use for TTS (e.g., "zh-CN-XiaoxiaoNeural")
voice: Option<String>,
/// TTS backend to use (e.g., "edge", "xtts", "piper")
backend: Option<String>,
/// Audio quality ("low", "medium", "high")
quality: Option<String>,
/// Audio format (e.g., "wav")
format: Option<String>,
/// Sample rate for audio
sample_rate: Option<u32>,
/// Audio volume (0.0 to 1.0, default is 1.0)
volume: Option<f32>,
},
/// Use Alibaba DashScope TTS API (qwen3-tts)
AliTts {
/// DashScope API key (from DASHSCOPE_API_KEY env or config)
api_key: String,
/// Model to use (e.g., "qwen3-tts-flash")
model: String,
/// Voice ID to use (e.g., "Cherry", "Chelsie", etc.)
voice: String,
/// Language type (e.g., "Chinese", "English")
language_type: Option<String>,
/// Audio volume (0.0 to 1.0, default is 1.0)
volume: Option<f32>,
},
/// Use local command-line TTS programs
Command {
/// The TTS command to use (e.g., "say" on macOS, "espeak-ng" on Linux)
tts_command: String,
/// Optional extra arguments for the TTS command (e.g., ["-v", "SinJi"])
tts_args: Vec<String>,
},
}
/// A plugin that sends Danmaku text to a TTS service and plays the audio sequentially.
///
/// This handler supports two modes:
/// 1. REST API mode: Sends text to a TTS REST API server, receives base64-encoded audio data,
/// decodes it and plays through the system's audio output
/// 2. Command mode: Uses local command-line TTS programs (like `say` on macOS or `espeak-ng` on Linux)
///
/// Messages are processed sequentially to avoid overlapping audio.
pub struct TtsHandler {
/// TTS configuration (either REST API or command-based)
#[allow(dead_code)]
mode: TtsMode,
/// Channel sender for queuing TTS messages
sender: Sender<String>,
/// Background thread handle for TTS processing
_worker_handle: JoinHandle<()>,
}
impl TtsHandler {
/// Create a new TTS handler with the specified mode
pub fn new(mode: TtsMode) -> Self {
let (sender, receiver) = mpsc::channel::<String>();
// Clone the mode for the worker thread
let mode_clone = mode.clone();
// Spawn worker thread to process TTS queue sequentially
let worker_handle = thread::spawn(move || match &mode_clone {
TtsMode::RestApi { .. } => {
Self::run_rest_api_worker(receiver, mode_clone);
}
TtsMode::AliTts { .. } => {
Self::run_ali_tts_worker(receiver, mode_clone);
}
TtsMode::Command { .. } => {
Self::run_command_worker(receiver, mode_clone);
}
});
TtsHandler {
mode,
sender,
_worker_handle: worker_handle,
}
}
/// Create a new TTS handler with REST API using default Chinese voice settings
pub fn new_rest_api_default(server_url: String) -> Self {
Self::new_rest_api_default_with_volume(server_url, 1.0)
}
/// Create a new TTS handler with REST API using default Chinese voice settings and custom volume
pub fn new_rest_api_default_with_volume(server_url: String, volume: f32) -> Self {
let mode = TtsMode::RestApi {
server_url,
voice: Some("zh-CN-XiaoxiaoNeural".to_string()),
backend: Some("edge".to_string()),
quality: Some("medium".to_string()),
format: Some("wav".to_string()),
sample_rate: Some(22050),
volume: Some(volume),
};
Self::new(mode)
}
/// Create a new TTS handler with REST API and custom configuration
pub fn new_rest_api(
server_url: String,
voice: Option<String>,
backend: Option<String>,
quality: Option<String>,
format: Option<String>,
sample_rate: Option<u32>,
) -> Self {
Self::new_rest_api_with_volume(
server_url,
voice,
backend,
quality,
format,
sample_rate,
None,
)
}
/// Create a new TTS handler with REST API and custom configuration including volume
pub fn new_rest_api_with_volume(
server_url: String,
voice: Option<String>,
backend: Option<String>,
quality: Option<String>,
format: Option<String>,
sample_rate: Option<u32>,
volume: Option<f32>,
) -> Self {
let mode = TtsMode::RestApi {
server_url,
voice,
backend,
quality,
format,
sample_rate,
volume,
};
Self::new(mode)
}
/// Create a new TTS handler with command-line TTS
pub fn new_command(tts_command: String, tts_args: Vec<String>) -> Self {
let mode = TtsMode::Command {
tts_command,
tts_args,
};
Self::new(mode)
}
/// Create a new TTS handler with Alibaba DashScope TTS using default settings
pub fn new_ali_tts_default(api_key: String) -> Self {
Self::new_ali_tts(
api_key,
"qwen3-tts-flash".to_string(),
"Cherry".to_string(),
Some("Chinese".to_string()),
None,
)
}
/// Create a new TTS handler with Alibaba DashScope TTS and custom configuration
pub fn new_ali_tts(
api_key: String,
model: String,
voice: String,
language_type: Option<String>,
volume: Option<f32>,
) -> Self {
let mode = TtsMode::AliTts {
api_key,
model,
voice,
language_type,
volume,
};
Self::new(mode)
}
/// Worker thread for REST API TTS processing
fn run_rest_api_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
if let TtsMode::RestApi {
server_url,
voice,
backend,
quality,
format,
sample_rate,
volume,
} = mode
{
// Create a tokio runtime for HTTP requests
let rt = tokio::runtime::Runtime::new().unwrap();
let client = reqwest::Client::new();
// Initialize audio output stream (this will be reused for all audio playback)
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
while let Ok(message) = receiver.recv() {
let request = TtsRequest {
text: message,
voice: voice.clone(),
backend: backend.clone(),
quality: quality.clone(),
format: format.clone(),
sample_rate,
};
// Make HTTP request to TTS service
rt.block_on(async {
match client
.post(&format!("{}/tts", server_url))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
match response.json::<TtsResponse>().await {
Ok(tts_response) => {
info!("TTS generated successfully");
// Decode base64 audio data and play it
match general_purpose::STANDARD
.decode(&tts_response.audio_data)
{
Ok(audio_bytes) => {
// Create a cursor from the audio bytes
let cursor = Cursor::new(audio_bytes);
// Create a decoder for the audio format
match Decoder::new(cursor) {
Ok(source) => {
// Create a new sink for this audio
let sink =
Sink::try_new(&stream_handle).unwrap();
// Set volume if specified (default to 1.0 if not set)
let audio_volume = volume.unwrap_or(1.0);
sink.set_volume(audio_volume);
// Append the audio source to the sink
sink.append(source);
// Wait for the audio to finish playing
sink.sleep_until_end();
debug!("Audio playback completed");
}
Err(e) => error!(
"Failed to decode audio format: {}",
e
),
}
}
Err(e) => {
error!("Failed to decode base64 audio data: {}", e)
}
}
}
Err(e) => error!("Failed to parse TTS response: {}", e),
}
} else {
warn!("TTS request failed with status: {}", response.status());
}
}
Err(e) => error!("Failed to send TTS request: {}", e),
}
});
}
}
}
/// Worker thread for Alibaba DashScope TTS processing with SSE streaming
fn run_ali_tts_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
use futures::StreamExt;
if let TtsMode::AliTts {
api_key,
model,
voice,
language_type,
volume,
} = mode
{
// Create a tokio runtime for HTTP requests
let rt = tokio::runtime::Runtime::new().unwrap();
let client = reqwest::Client::new();
// Initialize audio output stream (this will be reused for all audio playback)
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
while let Ok(message) = receiver.recv() {
let request = AliTtsRequest {
model: model.clone(),
input: AliTtsInput {
text: message,
voice: voice.clone(),
language_type: language_type.clone(),
},
};
// Make HTTP request to DashScope TTS service with SSE
rt.block_on(async {
match client
.post("https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation")
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.header("X-DashScope-SSE", "enable")
.json(&request)
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
// Collect all audio chunks from SSE stream
let mut audio_chunks: Vec<Vec<u8>> = Vec::new();
let mut audio_url: Option<String> = None;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk_result) = stream.next().await {
match chunk_result {
Ok(chunk) => {
// Append chunk to buffer
if let Ok(text) = std::str::from_utf8(&chunk) {
buffer.push_str(text);
// Process complete SSE events in buffer
while let Some(event_end) = buffer.find("\n\n") {
let event = buffer[..event_end].to_string();
buffer = buffer[event_end + 2..].to_string();
// Parse SSE event - look for data: lines
for line in event.lines() {
if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
if data.is_empty() || data == "[DONE]" {
continue;
}
match serde_json::from_str::<AliTtsResponse>(data) {
Ok(ali_response) => {
if let Some(output) = ali_response.output {
if let Some(audio) = output.audio {
// Check for base64 audio data (non-empty)
if let Some(ref audio_data) = audio.data {
if !audio_data.is_empty() {
match general_purpose::STANDARD.decode(audio_data) {
Ok(decoded) => {
if !decoded.is_empty() {
audio_chunks.push(decoded);
}
}
Err(e) => {
debug!("Failed to decode audio chunk: {}", e);
}
}
}
}
// Check for audio URL (final response)
if let Some(url) = audio.url {
debug!("Audio URL received: {}", url);
audio_url = Some(url);
}
}
// Check if this is the final response
if let Some(ref reason) = output.finish_reason {
if reason == "stop" {
debug!("Received final response with finish_reason: stop");
}
}
}
}
Err(e) => {
debug!("Failed to parse SSE data: {} - data: {}", e, data);
}
}
}
}
}
}
}
Err(e) => {
error!("Error reading SSE stream: {}", e);
break;
}
}
}
// Try to play audio - prefer URL download over streamed chunks
// Streamed MP3 chunks cannot be simply concatenated due to headers/frames
let audio_data = if let Some(url) = audio_url {
// Download complete audio from URL (preferred method)
info!("AliTTS: downloading audio from URL");
match client.get(&url).send().await {
Ok(audio_response) => {
if audio_response.status().is_success() {
match audio_response.bytes().await {
Ok(bytes) => {
info!("AliTTS: downloaded {} bytes", bytes.len());
Some(bytes.to_vec())
}
Err(e) => {
error!("Failed to read audio bytes: {}", e);
None
}
}
} else {
error!("Failed to download audio: {}", audio_response.status());
None
}
}
Err(e) => {
error!("Failed to fetch audio URL: {}", e);
None
}
}
} else if !audio_chunks.is_empty() {
// Fallback: try to use collected base64 chunks
// Note: This may not work correctly for MP3 format due to concatenation issues
warn!("AliTTS: No URL provided, attempting to use streamed chunks (may have decoding issues)");
let combined: Vec<u8> = audio_chunks.into_iter().flatten().collect();
info!("AliTTS: using {} bytes from streamed chunks", combined.len());
Some(combined)
} else {
warn!("No audio data or URL received from AliTTS");
None
};
// Play the audio
if let Some(audio_bytes) = audio_data {
let cursor = Cursor::new(audio_bytes);
match Decoder::new(cursor) {
Ok(source) => {
let sink = Sink::try_new(&stream_handle).unwrap();
let audio_volume = volume.unwrap_or(1.0);
sink.set_volume(audio_volume);
sink.append(source);
sink.sleep_until_end();
debug!("Audio playback completed");
}
Err(e) => error!("Failed to decode audio format: {}", e),
}
}
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
warn!("AliTTS request failed with status: {} - {}", status, body);
}
}
Err(e) => error!("Failed to send AliTTS request: {}", e),
}
});
}
}
}
/// Worker thread for command-line TTS processing
fn run_command_worker(receiver: std::sync::mpsc::Receiver<String>, mode: TtsMode) {
if let TtsMode::Command {
tts_command,
tts_args,
} = mode
{
while let Ok(message) = receiver.recv() {
let mut command = Command::new(&tts_command);
for arg in &tts_args {
command.arg(arg);
}
// Execute TTS command and wait for it to complete
match command.arg(&message).status() {
Ok(status) => {
if status.success() {
debug!("TTS command completed successfully");
} else {
warn!("TTS command failed with status: {}", status);
}
}
Err(e) => error!("Failed to execute TTS command: {}", e),
}
}
}
}
/// Legacy method - kept for backward compatibility
#[deprecated(note = "Use new_rest_api_default instead")]
pub fn new_default(server_url: String) -> Self {
Self::new_rest_api_default(server_url)
}
}
impl EventHandler for TtsHandler {
fn handle(&self, msg: &BiliMessage, _context: &EventContext) {
if let BiliMessage::Danmu { user, text } = msg {
let message = format!("{}说:{}", user, text);
// Send message to the queue for sequential processing
let _ = self.sender.send(message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::models::BiliMessage;
use crate::client::scheduler::EventHandler;
#[test]
fn test_tts_handler_danmu() {
// Test with a mock server URL (won't actually make requests in this test)
let handler = TtsHandler::new_rest_api_default("http://localhost:8000".to_string());
let text = "您好,欢迎来到直播间。".to_string();
let msg = BiliMessage::Danmu {
user: "测试用户".to_string(),
text: text.clone(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_tts_handler_custom_config() {
let handler = TtsHandler::new_rest_api(
"http://localhost:8000".to_string(),
Some("zh-CN-XiaoxiaoNeural".to_string()),
Some("edge".to_string()),
Some("high".to_string()),
Some("wav".to_string()),
Some(44100),
);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "hello world".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_tts_handler_sequential_processing() {
use std::time::Duration;
// Use default configuration for testing
let handler = TtsHandler::new_rest_api_default("http://localhost:8000".to_string());
// Send multiple messages quickly
let messages = vec![
("User1", "First message"),
("User2", "Second message"),
("User3", "Third message"),
];
for (user, text) in messages {
let msg = BiliMessage::Danmu {
user: user.to_string(),
text: text.to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
// Give the worker thread some time to process the queue
std::thread::sleep(Duration::from_millis(100));
// The test passes if no panic occurs - the sequential processing
// is ensured by the worker thread design
}
#[test]
fn test_tts_handler_command_mode() {
// Test command-based TTS (cross-platform using echo)
let handler = TtsHandler::new_command("echo".to_string(), vec![]);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "test message".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
// Give the worker thread some time to process the message
std::thread::sleep(std::time::Duration::from_millis(50));
}
#[cfg(target_os = "macos")]
#[test]
fn test_tts_handler_macos_voice() {
let handler = TtsHandler::new_command(
"say".to_string(),
vec!["-v".to_string(), "Mei-Jia".to_string()],
);
let msg = BiliMessage::Danmu {
user: "用户".to_string(),
text: "你好".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[cfg(target_os = "linux")]
#[test]
fn test_tts_handler_linux_voice() {
let handler = TtsHandler::new_command(
"espeak-ng".to_string(),
vec!["-v".to_string(), "cmn".to_string()],
);
let msg = BiliMessage::Danmu {
user: "用户".to_string(),
text: "你好".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_tts_request_serialization() {
let request = TtsRequest {
text: "Hello world".to_string(),
voice: Some("zh-CN-XiaoxiaoNeural".to_string()),
backend: Some("edge".to_string()),
quality: Some("medium".to_string()),
format: Some("wav".to_string()),
sample_rate: Some(22050),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("Hello world"));
assert!(json.contains("zh-CN-XiaoxiaoNeural"));
assert!(json.contains("edge"));
}
#[test]
fn test_tts_handler_with_volume() {
// Test with custom volume setting
let handler =
TtsHandler::new_rest_api_default_with_volume("http://localhost:8000".to_string(), 0.5);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "volume test".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
// Test with custom configuration including volume
let handler_custom = TtsHandler::new_rest_api_with_volume(
"http://localhost:8000".to_string(),
Some("zh-CN-XiaoxiaoNeural".to_string()),
Some("edge".to_string()),
Some("high".to_string()),
Some("wav".to_string()),
Some(44100),
Some(0.8),
);
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler_custom.handle(&msg, &context);
}
#[test]
fn test_ali_tts_handler_default() {
// Test with a mock API key (won't actually make requests in this test)
let handler = TtsHandler::new_ali_tts_default("test_api_key".to_string());
let msg = BiliMessage::Danmu {
user: "测试用户".to_string(),
text: "你好".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_ali_tts_handler_custom_config() {
let handler = TtsHandler::new_ali_tts(
"test_api_key".to_string(),
"qwen3-tts-flash".to_string(),
"Chelsie".to_string(),
Some("English".to_string()),
Some(0.8),
);
let msg = BiliMessage::Danmu {
user: "test_user".to_string(),
text: "hello world".to_string(),
};
let context = EventContext {
cookies: None,
room_id: 12345,
};
handler.handle(&msg, &context);
}
#[test]
fn test_ali_tts_request_serialization() {
let request = AliTtsRequest {
model: "qwen3-tts-flash".to_string(),
input: AliTtsInput {
text: "你好世界".to_string(),
voice: "Cherry".to_string(),
language_type: Some("Chinese".to_string()),
},
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("qwen3-tts-flash"));
assert!(json.contains("你好世界"));
assert!(json.contains("Cherry"));
assert!(json.contains("Chinese"));
}
}
+624
View File
@@ -0,0 +1,624 @@
// src/tui/app.rs
//! TUI application state management
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
/// Maximum number of messages to keep in buffer
const MAX_MESSAGES: usize = 1000;
/// TUI Application state
pub struct TuiApp {
/// Shared message buffer (thread-safe)
pub message_buffer: Arc<Mutex<VecDeque<String>>>,
/// Current scroll offset (0 = bottom, 1 = one line up, etc.)
pub scroll_offset: usize,
/// Whether auto-scroll is enabled
pub auto_scroll: bool,
/// Current input text
pub input: String,
/// Cursor position in input
pub cursor_position: usize,
/// Room ID being monitored
pub room_id: String,
/// Whether to quit the application
pub should_quit: bool,
/// Shared online user count (thread-safe, updated from event handler)
pub online_count: Arc<AtomicU64>,
/// Whether to show raw event messages
pub show_raw: bool,
/// Shared log buffer for capturing log messages (thread-safe)
pub log_buffer: Arc<Mutex<VecDeque<String>>>,
/// Whether to show the logs panel
pub show_logs: bool,
/// Scroll offset for logs panel (0 = bottom)
pub log_scroll_offset: usize,
/// Whether auto-scroll is enabled for logs panel
pub log_auto_scroll: bool,
/// Whether the help overlay is visible
pub show_help: bool,
/// Whether Vim-style visual selection is active
pub visual_mode: bool,
/// Frozen message snapshot used while visual mode is active
frozen_messages: Vec<String>,
/// Frozen log snapshot used while visual mode is active
frozen_logs: Vec<String>,
/// Rendered wrapped lines for the active pane
rendered_lines: Vec<String>,
/// First visible rendered line for the active pane
rendered_start_line: usize,
/// Visible height for the active pane
rendered_visible_height: usize,
/// Selection anchor in rendered line coordinates
visual_anchor: usize,
/// Current cursor in rendered line coordinates
visual_cursor: usize,
/// Current line cursor in normal pane navigation
pane_cursor: usize,
/// Whether the pane cursor has been initialized
pane_cursor_initialized: bool,
/// History of submitted input messages (oldest first)
input_history: Vec<String>,
/// Position when browsing input history; None means editing the live draft
history_index: Option<usize>,
/// Draft saved when we start browsing input history
history_draft: String,
}
impl TuiApp {
/// Create a new TUI application with shared message buffer
pub fn new(message_buffer: Arc<Mutex<VecDeque<String>>>, room_id: String) -> Self {
Self::with_online_count(message_buffer, room_id, Arc::new(AtomicU64::new(0)))
}
/// Create a new TUI application with shared message buffer and online count
pub fn with_online_count(
message_buffer: Arc<Mutex<VecDeque<String>>>,
room_id: String,
online_count: Arc<AtomicU64>,
) -> Self {
Self {
message_buffer,
scroll_offset: 0,
auto_scroll: true,
input: String::new(),
cursor_position: 0,
room_id,
should_quit: false,
online_count,
show_raw: false,
log_buffer: Arc::new(Mutex::new(VecDeque::new())),
show_logs: false,
log_scroll_offset: 0,
log_auto_scroll: true,
show_help: false,
visual_mode: false,
frozen_messages: Vec::new(),
frozen_logs: Vec::new(),
rendered_lines: Vec::new(),
rendered_start_line: 0,
rendered_visible_height: 0,
visual_anchor: 0,
visual_cursor: 0,
pane_cursor: 0,
pane_cursor_initialized: false,
input_history: Vec::new(),
history_index: None,
history_draft: String::new(),
}
}
/// Get the current online count
pub fn get_online_count(&self) -> u64 {
self.online_count.load(Ordering::Relaxed)
}
/// Update the online count (called from event handler)
pub fn set_online_count(online_count: &Arc<AtomicU64>, count: u64) {
online_count.store(count, Ordering::Relaxed);
}
/// Add a message to the buffer (called from event handler)
pub fn add_message(buffer: &Arc<Mutex<VecDeque<String>>>, message: String) {
if let Ok(mut messages) = buffer.lock() {
messages.push_back(message);
while messages.len() > MAX_MESSAGES {
messages.pop_front();
}
}
}
/// Get messages for display (returns a copy of the buffer)
pub fn get_messages(&self) -> Vec<String> {
if self.visual_mode {
return self.frozen_messages.clone();
}
if let Ok(messages) = self.message_buffer.lock() {
messages.iter().cloned().collect()
} else {
Vec::new()
}
}
/// Get the number of messages in buffer
pub fn message_count(&self) -> usize {
if let Ok(messages) = self.message_buffer.lock() {
messages.len()
} else {
0
}
}
/// Scroll up (increase offset)
pub fn scroll_up(&mut self, amount: usize) {
let max_offset = self.message_count().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
if self.scroll_offset > 0 {
self.auto_scroll = false;
}
}
/// Scroll down (decrease offset)
pub fn scroll_down(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
if self.scroll_offset == 0 {
self.auto_scroll = true;
}
}
/// Scroll to bottom
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = 0;
self.auto_scroll = true;
}
/// Handle character input
pub fn enter_char(&mut self, c: char) {
let byte_pos = self.byte_index();
self.input.insert(byte_pos, c);
self.cursor_position += 1;
}
/// Delete character before cursor
pub fn delete_char(&mut self) {
if self.cursor_position > 0 {
let byte_pos = self.byte_index_at(self.cursor_position - 1);
self.input.remove(byte_pos);
self.cursor_position -= 1;
}
}
/// Move cursor left
pub fn move_cursor_left(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
}
/// Move cursor right
pub fn move_cursor_right(&mut self) {
let char_count = self.input.chars().count();
if self.cursor_position < char_count {
self.cursor_position += 1;
}
}
fn byte_index(&self) -> usize {
self.input
.char_indices()
.nth(self.cursor_position)
.map(|(idx, _)| idx)
.unwrap_or(self.input.len())
}
fn byte_index_at(&self, char_pos: usize) -> usize {
self.input
.char_indices()
.nth(char_pos)
.map(|(idx, _)| idx)
.unwrap_or(self.input.len())
}
/// Get current input and clear it
pub fn take_input(&mut self) -> String {
let input = self.input.clone();
self.input.clear();
self.cursor_position = 0;
if !input.is_empty() && self.input_history.last() != Some(&input) {
self.input_history.push(input.clone());
}
self.history_index = None;
self.history_draft.clear();
input
}
/// Recall the previous (older) entry from input history into the input box
pub fn history_prev(&mut self) {
if self.input_history.is_empty() {
return;
}
let new_index = match self.history_index {
None => {
self.history_draft = self.input.clone();
self.input_history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.history_index = Some(new_index);
self.input = self.input_history[new_index].clone();
self.cursor_position = self.input.chars().count();
}
/// Recall the next (newer) entry from input history, or restore the draft
pub fn history_next(&mut self) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.input_history.len() {
let new_index = index + 1;
self.history_index = Some(new_index);
self.input = self.input_history[new_index].clone();
} else {
self.history_index = None;
self.input = std::mem::take(&mut self.history_draft);
}
self.cursor_position = self.input.chars().count();
}
/// Quit the application
pub fn quit(&mut self) {
self.should_quit = true;
}
/// Toggle raw message visibility
pub fn toggle_show_raw(&mut self) {
self.show_raw = !self.show_raw;
}
/// Toggle logs panel visibility
pub fn toggle_show_logs(&mut self) {
self.show_logs = !self.show_logs;
self.show_help = false;
if self.show_logs {
self.log_scroll_offset = 0;
self.log_auto_scroll = true;
}
}
/// Toggle help overlay visibility
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
/// Get the number of log messages in buffer
pub fn log_message_count(&self) -> usize {
if let Ok(logs) = self.log_buffer.lock() {
logs.len()
} else {
0
}
}
/// Scroll logs up (increase offset)
pub fn log_scroll_up(&mut self, amount: usize) {
let max_offset = self.log_message_count().saturating_sub(1);
self.log_scroll_offset = (self.log_scroll_offset + amount).min(max_offset);
if self.log_scroll_offset > 0 {
self.log_auto_scroll = false;
}
}
/// Scroll logs down (decrease offset)
pub fn log_scroll_down(&mut self, amount: usize) {
self.log_scroll_offset = self.log_scroll_offset.saturating_sub(amount);
if self.log_scroll_offset == 0 {
self.log_auto_scroll = true;
}
}
/// Scroll logs to bottom
pub fn log_scroll_to_bottom(&mut self) {
self.log_scroll_offset = 0;
self.log_auto_scroll = true;
}
/// Set the log buffer (used to share with the TuiLogger)
pub fn set_log_buffer(&mut self, log_buffer: Arc<Mutex<VecDeque<String>>>) {
self.log_buffer = log_buffer;
}
/// Get log messages for display
pub fn get_log_messages(&self) -> Vec<String> {
if self.visual_mode {
return self.frozen_logs.clone();
}
if let Ok(logs) = self.log_buffer.lock() {
logs.iter().cloned().collect()
} else {
Vec::new()
}
}
/// Store the current wrapped-line model for the active pane
pub fn set_rendered_lines(
&mut self,
lines: Vec<String>,
start_line: usize,
visible_height: usize,
) -> usize {
let old_total_lines = self.rendered_lines.len();
let old_start_line = self.rendered_start_line;
let old_visible_height = self.rendered_visible_height.max(1);
let old_last_visible = old_start_line
.saturating_add(old_visible_height.saturating_sub(1))
.min(old_total_lines.saturating_sub(1));
let was_following_bottom = self.pane_cursor_initialized
&& old_total_lines > 0
&& self.pane_cursor >= old_last_visible
&& self.active_auto_scroll();
self.rendered_lines = lines;
self.rendered_start_line = start_line;
self.rendered_visible_height = visible_height.max(1);
if self.visual_mode {
let max_index = self.rendered_lines.len().saturating_sub(1);
self.visual_anchor = self.visual_anchor.min(max_index);
self.visual_cursor = self.visual_cursor.min(max_index);
self.sync_visual_view();
} else if self.rendered_lines.is_empty() {
self.pane_cursor = 0;
self.pane_cursor_initialized = false;
} else {
let max_index = self.rendered_lines.len() - 1;
if !self.pane_cursor_initialized || was_following_bottom {
self.pane_cursor = self.initial_visible_cursor();
self.pane_cursor_initialized = true;
} else {
self.pane_cursor = self.pane_cursor.min(max_index);
self.sync_pane_view();
}
}
self.rendered_start_line
}
/// Enter Vim-style visual selection mode
pub fn enter_visual_mode(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.frozen_messages = self
.message_buffer
.lock()
.map(|messages| messages.iter().cloned().collect())
.unwrap_or_default();
self.frozen_logs = self
.log_buffer
.lock()
.map(|logs| logs.iter().cloned().collect())
.unwrap_or_default();
self.show_help = false;
self.visual_mode = true;
self.visual_cursor = self.pane_cursor;
self.visual_anchor = self.visual_cursor;
self.sync_visual_view();
}
/// Exit visual selection mode and resume live updates
pub fn exit_visual_mode(&mut self) {
self.visual_mode = false;
self.frozen_messages.clear();
self.frozen_logs.clear();
}
/// Toggle visual selection mode
pub fn toggle_visual_mode(&mut self) {
if self.visual_mode {
self.exit_visual_mode();
} else {
self.enter_visual_mode();
}
}
/// Move the normal pane cursor up
pub fn pane_up(&mut self, amount: usize) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.pane_cursor = self.pane_cursor.saturating_sub(amount);
self.sync_pane_view();
}
/// Move the normal pane cursor down
pub fn pane_down(&mut self, amount: usize) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
let max_index = self.rendered_lines.len().saturating_sub(1);
self.pane_cursor = (self.pane_cursor + amount).min(max_index);
self.sync_pane_view();
}
/// Jump the normal pane cursor to the first line
pub fn pane_top(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.pane_cursor = 0;
self.sync_pane_view();
}
/// Jump the normal pane cursor to the last line
pub fn pane_bottom(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.pane_cursor = self.rendered_lines.len() - 1;
self.sync_pane_view();
}
/// Move the visual cursor up
pub fn visual_up(&mut self, amount: usize) {
if !self.visual_mode {
return;
}
self.visual_cursor = self.visual_cursor.saturating_sub(amount);
self.sync_visual_view();
}
/// Move the visual cursor down
pub fn visual_down(&mut self, amount: usize) {
if !self.visual_mode {
return;
}
let max_index = self.rendered_lines.len().saturating_sub(1);
self.visual_cursor = (self.visual_cursor + amount).min(max_index);
self.sync_visual_view();
}
/// Jump the visual cursor to the first line
pub fn visual_top(&mut self) {
if !self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.visual_cursor = 0;
self.sync_visual_view();
}
/// Jump the visual cursor to the last line
pub fn visual_bottom(&mut self) {
if !self.visual_mode || self.rendered_lines.is_empty() {
return;
}
self.visual_cursor = self.rendered_lines.len() - 1;
self.sync_visual_view();
}
/// Get the selected rendered-line range
pub fn visual_range(&self) -> Option<(usize, usize)> {
if !self.visual_mode || self.rendered_lines.is_empty() {
return None;
}
Some((
self.visual_anchor.min(self.visual_cursor),
self.visual_anchor.max(self.visual_cursor),
))
}
/// Get the current visual cursor position
pub fn visual_cursor(&self) -> Option<usize> {
if self.visual_mode && !self.rendered_lines.is_empty() {
Some(self.visual_cursor)
} else {
None
}
}
/// Get the current pane cursor position
pub fn pane_cursor(&self) -> Option<usize> {
if !self.visual_mode && self.pane_cursor_initialized && !self.rendered_lines.is_empty() {
Some(self.pane_cursor)
} else {
None
}
}
/// Return the selected text from the current visual range
pub fn selected_text(&self) -> Option<String> {
let (start, end) = self.visual_range()?;
Some(self.rendered_lines[start..=end].join("\n"))
}
fn initial_visible_cursor(&self) -> usize {
if self.rendered_lines.is_empty() {
return 0;
}
let last_visible = self
.rendered_start_line
.saturating_add(self.rendered_visible_height.saturating_sub(1));
last_visible.min(self.rendered_lines.len() - 1)
}
fn sync_pane_view(&mut self) {
if self.visual_mode || self.rendered_lines.is_empty() {
return;
}
let total_lines = self.rendered_lines.len();
let visible_height = self.rendered_visible_height.max(1).min(total_lines);
let max_start = total_lines.saturating_sub(visible_height);
let mut start_line = self.rendered_start_line.min(max_start);
if self.pane_cursor < start_line {
start_line = self.pane_cursor;
} else if self.pane_cursor >= start_line + visible_height {
start_line = self.pane_cursor + 1 - visible_height;
}
self.rendered_start_line = start_line;
let scroll_offset = total_lines.saturating_sub(visible_height + start_line);
if self.show_logs {
self.log_scroll_offset = scroll_offset;
self.log_auto_scroll = scroll_offset == 0;
} else {
self.scroll_offset = scroll_offset;
self.auto_scroll = scroll_offset == 0;
}
}
fn active_auto_scroll(&self) -> bool {
if self.show_logs {
self.log_auto_scroll
} else {
self.auto_scroll
}
}
fn sync_visual_view(&mut self) {
if !self.visual_mode || self.rendered_lines.is_empty() {
return;
}
let total_lines = self.rendered_lines.len();
let visible_height = self.rendered_visible_height.max(1).min(total_lines);
let max_start = total_lines.saturating_sub(visible_height);
let mut start_line = self.rendered_start_line.min(max_start);
if self.visual_cursor < start_line {
start_line = self.visual_cursor;
} else if self.visual_cursor >= start_line + visible_height {
start_line = self.visual_cursor + 1 - visible_height;
}
self.rendered_start_line = start_line;
let scroll_offset = total_lines.saturating_sub(visible_height + start_line);
if self.show_logs {
self.log_scroll_offset = scroll_offset;
self.log_auto_scroll = scroll_offset == 0;
} else {
self.scroll_offset = scroll_offset;
self.auto_scroll = scroll_offset == 0;
}
}
}
+254
View File
@@ -0,0 +1,254 @@
// src/tui/event.rs
//! Event handling and main TUI loop
use crate::tui::app::TuiApp;
use crate::tui::ui;
use arboard::Clipboard;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::time::Duration;
/// Run the TUI application
pub fn run_tui<F>(mut app: TuiApp, mut on_message: F) -> io::Result<()>
where
F: FnMut(String),
{
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_app(&mut terminal, &mut app, &mut on_message);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_app<F>(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut TuiApp,
on_message: &mut F,
) -> io::Result<()>
where
F: FnMut(String),
{
let mut needs_redraw = true;
let mut clipboard = Clipboard::new().ok();
let mut last_message_count = app.message_count();
let mut last_log_count = app.log_message_count();
let mut last_online_count = app.get_online_count();
loop {
let message_count = app.message_count();
let log_count = app.log_message_count();
let online_count = app.get_online_count();
if !app.visual_mode
&& (message_count != last_message_count
|| log_count != last_log_count
|| online_count != last_online_count)
{
needs_redraw = true;
}
last_message_count = message_count;
last_log_count = log_count;
last_online_count = online_count;
if needs_redraw {
terminal.draw(|f| ui::render(f, app))?;
needs_redraw = false;
}
if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.quit();
needs_redraw = true;
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.toggle_visual_mode();
needs_redraw = true;
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !app.visual_mode {
app.toggle_help();
needs_redraw = true;
}
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !app.visual_mode {
app.toggle_show_raw();
needs_redraw = true;
}
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !app.visual_mode {
app.toggle_show_logs();
needs_redraw = true;
}
}
KeyCode::Esc => {
if app.visual_mode {
app.exit_visual_mode();
} else if app.show_help {
app.show_help = false;
} else if app.show_logs {
app.toggle_show_logs();
} else {
app.quit();
}
needs_redraw = true;
}
_ if app.show_help => {}
_ if app.visual_mode => {
match key.code {
KeyCode::Char('k') | KeyCode::Up => app.visual_up(1),
KeyCode::Char('j') | KeyCode::Down => app.visual_down(1),
KeyCode::PageUp => app.visual_up(10),
KeyCode::PageDown => app.visual_down(10),
KeyCode::Char('g') | KeyCode::Home => app.visual_top(),
KeyCode::Char('G') | KeyCode::End => app.visual_bottom(),
KeyCode::Char('y') => {
copy_selection(app, clipboard.as_mut())?;
app.exit_visual_mode();
}
_ => {}
}
needs_redraw = true;
}
_ if app.show_logs => match key.code {
KeyCode::Up => {
app.pane_up(1);
needs_redraw = true;
}
KeyCode::Down => {
app.pane_down(1);
needs_redraw = true;
}
KeyCode::PageUp => {
app.pane_up(10);
needs_redraw = true;
}
KeyCode::PageDown => {
app.pane_down(10);
needs_redraw = true;
}
KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_top();
needs_redraw = true;
}
KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_bottom();
needs_redraw = true;
}
KeyCode::Home => {
app.pane_top();
needs_redraw = true;
}
KeyCode::End => {
app.pane_bottom();
needs_redraw = true;
}
_ => {}
},
KeyCode::Char(c) => {
app.enter_char(c);
needs_redraw = true;
}
KeyCode::Backspace => {
app.delete_char();
needs_redraw = true;
}
KeyCode::Enter => {
let input = app.take_input();
if !input.is_empty() {
if input == "/quit" || input == "/exit" {
app.quit();
} else {
on_message(input);
}
}
needs_redraw = true;
}
KeyCode::Up => {
app.history_prev();
needs_redraw = true;
}
KeyCode::Down => {
app.history_next();
needs_redraw = true;
}
KeyCode::Left => {
app.move_cursor_left();
needs_redraw = true;
}
KeyCode::Right => {
app.move_cursor_right();
needs_redraw = true;
}
KeyCode::PageUp => {
app.pane_up(10);
needs_redraw = true;
}
KeyCode::PageDown => {
app.pane_down(10);
needs_redraw = true;
}
KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_top();
needs_redraw = true;
}
KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pane_bottom();
needs_redraw = true;
}
KeyCode::Home => {
app.cursor_position = 0;
needs_redraw = true;
}
KeyCode::End => {
app.cursor_position = app.input.chars().count();
needs_redraw = true;
}
_ => {}
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn copy_selection(app: &TuiApp, clipboard: Option<&mut Clipboard>) -> io::Result<()> {
let Some(text) = app.selected_text() else {
return Ok(());
};
let Some(clipboard) = clipboard else {
return Err(io::Error::other("clipboard is unavailable"));
};
clipboard.set_text(text).map_err(io::Error::other)
}
+73
View File
@@ -0,0 +1,73 @@
// src/tui/logger.rs
//! Custom logger that captures log messages into a shared buffer for TUI display
use log::{Log, Metadata, Record};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Instant;
/// Maximum number of log messages to keep in buffer
const MAX_LOG_MESSAGES: usize = 1000;
/// A logger that writes log messages to a shared buffer for TUI display.
/// It also optionally forwards to env_logger for file/stderr output.
pub struct TuiLogger {
buffer: Arc<Mutex<VecDeque<String>>>,
level: log::LevelFilter,
start_time: Instant,
}
impl TuiLogger {
/// Create a new TuiLogger with the given shared buffer and level filter.
pub fn new(buffer: Arc<Mutex<VecDeque<String>>>, level: log::LevelFilter) -> Self {
Self {
buffer,
level,
start_time: Instant::now(),
}
}
/// Initialize this logger as the global logger.
/// Returns the shared buffer so it can be passed to TuiApp.
pub fn init(level: log::LevelFilter) -> Arc<Mutex<VecDeque<String>>> {
let buffer = Arc::new(Mutex::new(VecDeque::new()));
let logger = TuiLogger::new(Arc::clone(&buffer), level);
log::set_boxed_logger(Box::new(logger)).expect("Failed to set TuiLogger");
log::set_max_level(level);
buffer
}
}
impl Log for TuiLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
let elapsed = self.start_time.elapsed();
let secs = elapsed.as_secs();
let mins = secs / 60;
let hours = mins / 60;
let timestamp = format!("{:02}:{:02}:{:02}", hours, mins % 60, secs % 60);
let msg = format!(
"[{}] [{}] [{}] {}",
timestamp,
record.level(),
record.target(),
record.args()
);
if let Ok(mut buf) = self.buffer.lock() {
buf.push_back(msg);
while buf.len() > MAX_LOG_MESSAGES {
buf.pop_front();
}
}
}
fn flush(&self) {}
}
+11
View File
@@ -0,0 +1,11 @@
// src/tui/mod.rs
//! TUI module for displaying messages and handling user input
pub mod app;
pub mod event;
pub mod logger;
pub mod ui;
pub use app::TuiApp;
pub use event::run_tui;
pub use logger::TuiLogger;
+355
View File
@@ -0,0 +1,355 @@
// src/tui/ui.rs
//! UI rendering logic for the TUI
use crate::tui::app::TuiApp;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
use unicode_width::UnicodeWidthStr;
pub fn render(f: &mut Frame, app: &mut TuiApp) {
if app.show_logs {
render_logs_panel(f, app, f.area());
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(90), Constraint::Percentage(10)])
.split(f.area());
render_message_list(f, app, chunks[0]);
render_input_box(f, app, chunks[1]);
}
if app.show_help {
render_help_overlay(f, app);
}
}
fn render_message_list(f: &mut Frame, app: &mut TuiApp, area: Rect) {
let messages = app.get_messages();
let inner_width = area.width.saturating_sub(2) as usize;
let visible_height = area.height.saturating_sub(2) as usize;
let mut all_lines = Vec::new();
for msg in &messages {
if !app.show_raw && msg.starts_with("[Raw]") {
continue;
}
let style = get_message_style(msg);
for line_text in wrap_text(msg, inner_width) {
all_lines.push((line_text, style));
}
}
let total_lines = all_lines.len();
let start_line = if app.auto_scroll {
total_lines.saturating_sub(visible_height)
} else {
total_lines.saturating_sub(visible_height + app.scroll_offset)
};
let start_line = app.set_rendered_lines(
all_lines.iter().map(|(text, _)| text.clone()).collect(),
start_line,
visible_height,
);
let visible_lines = all_lines
.into_iter()
.enumerate()
.skip(start_line)
.take(visible_height)
.map(|(idx, (line_text, style))| {
Line::from(Span::styled(
line_text,
style_for_line(app, idx, style, Color::Blue),
))
})
.collect::<Vec<_>>();
let scroll_indicator = if app.visual_mode {
"VISUAL | j/k move | g/G jump | y copy | Esc cancel"
} else if app.pane_cursor().is_some() {
"Up/Down history | PgUp/Dn scroll | Ctrl+Y select"
} else if app.auto_scroll {
"Auto-scroll"
} else {
"Paused - PgUp/Dn to scroll"
};
let online_count = app.get_online_count();
let online_display = if online_count > 0 {
format!(" | Online: {}", online_count)
} else {
String::new()
};
let raw_indicator = if app.show_raw { "Raw:ON" } else { "Raw:OFF" };
let title = format!(
" Room {}{} | {} | {} ",
app.room_id, online_display, scroll_indicator, raw_indicator
);
let paragraph = Paragraph::new(visible_lines)
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
fn get_message_style(msg: &str) -> Style {
if msg.starts_with("[Danmu]") {
Style::default().fg(Color::Cyan)
} else if msg.starts_with("[Gift]") {
Style::default().fg(Color::Yellow)
} else if msg.starts_with("[Raw]") {
Style::default().fg(Color::Magenta)
} else if msg.starts_with("[Unsupported") {
Style::default().fg(Color::DarkGray)
} else if msg.starts_with("[System]") {
Style::default().fg(Color::Green)
} else {
Style::default()
}
}
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for ch in text.chars() {
let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + char_width > max_width && !current_line.is_empty() {
lines.push(current_line);
current_line = String::new();
current_width = 0;
}
current_line.push(ch);
current_width += char_width;
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn render_logs_panel(f: &mut Frame, app: &mut TuiApp, area: Rect) {
let logs = app.get_log_messages();
let inner_width = area.width.saturating_sub(2) as usize;
let visible_height = area.height.saturating_sub(2) as usize;
let mut all_lines = Vec::new();
for log_msg in &logs {
let style = get_log_style(log_msg);
for line_text in wrap_text(log_msg, inner_width) {
all_lines.push((line_text, style));
}
}
let total_lines = all_lines.len();
let start_line = if app.log_auto_scroll {
total_lines.saturating_sub(visible_height)
} else {
total_lines.saturating_sub(visible_height + app.log_scroll_offset)
};
let start_line = app.set_rendered_lines(
all_lines.iter().map(|(text, _)| text.clone()).collect(),
start_line,
visible_height,
);
let visible_lines = all_lines
.into_iter()
.enumerate()
.skip(start_line)
.take(visible_height)
.map(|(idx, (line_text, style))| {
Line::from(Span::styled(
line_text,
style_for_line(app, idx, style, Color::LightBlue),
))
})
.collect::<Vec<_>>();
let scroll_indicator = if app.visual_mode {
"VISUAL | j/k move | g/G jump | y copy | Esc cancel"
} else if app.pane_cursor().is_some() {
"CURSOR | Up/Down move | Ctrl+Y visual from cursor"
} else if app.log_auto_scroll {
"Auto-scroll"
} else {
"Paused"
};
let title = format!(
" Logs ({} entries) | {} | Ctrl+Y: visual | Ctrl+H: help | Ctrl+L: close ",
logs.len(),
scroll_indicator
);
let paragraph = Paragraph::new(visible_lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::LightBlue)),
)
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
fn get_log_style(msg: &str) -> Style {
if msg.contains("[ERROR]") {
Style::default().fg(Color::Red)
} else if msg.contains("[WARN]") {
Style::default().fg(Color::Yellow)
} else if msg.contains("[INFO]") {
Style::default().fg(Color::Green)
} else if msg.contains("[DEBUG]") || msg.contains("[TRACE]") {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
}
}
fn render_input_box(f: &mut Frame, app: &TuiApp, area: Rect) {
let input_text = format!("> {}", app.input);
let paragraph = Paragraph::new(input_text.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Input (Up/Down: history | Ctrl+Y: visual | Ctrl+H: help | Ctrl+C: exit) ")
.border_style(Style::default().fg(Color::Green)),
)
.style(Style::default());
f.render_widget(paragraph, area);
let text_before_cursor: String = app.input.chars().take(app.cursor_position).collect();
let display_width = text_before_cursor.width();
let cursor_x = area.x + 1 + 2 + display_width as u16;
let cursor_y = area.y + 1;
if !app.visual_mode && cursor_x < area.x + area.width.saturating_sub(1) {
f.set_cursor_position((cursor_x, cursor_y));
}
}
fn render_help_overlay(f: &mut Frame, app: &TuiApp) {
let area = centered_rect(72, 72, f.area());
let lines = if app.show_logs {
vec![
Line::from("Key Map"),
Line::from(""),
Line::from("Ctrl+H Toggle this help"),
Line::from("Up/Down Pick start line"),
Line::from("Ctrl+Y Enter visual mode from cursor"),
Line::from("j/k Move visual selection"),
Line::from("g / G Jump to top or bottom"),
Line::from("y Copy selected lines"),
Line::from("Esc Close help, cancel visual, or close logs"),
Line::from("Up/Down Scroll logs normally"),
Line::from("PgUp/Dn Scroll faster"),
Line::from("Home/End Jump to top or bottom"),
Line::from("Ctrl+C Exit app"),
]
} else {
vec![
Line::from("Key Map"),
Line::from(""),
Line::from("Enter Send input"),
Line::from("Up/Down Browse sent-input history"),
Line::from("Ctrl+H Toggle this help"),
Line::from("Ctrl+Y Enter visual mode (scroll/select)"),
Line::from("j/k Move visual selection"),
Line::from("g / G Jump to top or bottom"),
Line::from("y Copy selected lines"),
Line::from("Ctrl+R Toggle raw messages"),
Line::from("Ctrl+L Toggle logs panel"),
Line::from("PgUp/Dn Scroll messages"),
Line::from("Left/Right Move input cursor"),
Line::from("Home/End Move input cursor"),
Line::from("Ctrl+Home Jump to top"),
Line::from("Ctrl+End Jump to bottom"),
Line::from("Esc Close help, cancel visual, or quit"),
Line::from("Ctrl+C Exit app"),
]
};
let title = if app.show_logs {
" Help - Logs "
} else {
" Help - Messages "
};
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false });
f.render_widget(Clear, area);
f.render_widget(paragraph, area);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn style_for_line(app: &TuiApp, idx: usize, base: Style, cursor_color: Color) -> Style {
if let Some((start, end)) = app.visual_range() {
if Some(idx) == app.visual_cursor() {
return base.bg(cursor_color).fg(Color::Black);
}
if (start..=end).contains(&idx) {
return base.bg(Color::DarkGray).fg(Color::White);
}
}
if Some(idx) == app.pane_cursor() {
return base.bg(Color::Rgb(40, 40, 40)).fg(Color::White);
}
base
}