1use client::models::BiliMessage;
2use client::scheduler::{EventHandler, EventContext};
3use log::{debug, error, info, warn};
4use reqwest::header::{HeaderMap, HeaderValue};
5use serde::Serialize;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8use tokio::runtime::Runtime;
9
10#[derive(Debug, Clone)]
12pub struct TriggerConfig {
13 pub keywords: Vec<String>,
15 pub response: String,
17}
18
19#[derive(Debug, Clone)]
21pub struct AutoReplyConfig {
22 pub enabled: bool,
24 pub cooldown_seconds: u64,
26 pub triggers: Vec<TriggerConfig>,
28}
29
30impl Default for AutoReplyConfig {
31 fn default() -> Self {
32 Self {
33 enabled: false,
34 cooldown_seconds: 5,
35 triggers: vec![
36 TriggerConfig {
37 keywords: vec!["你好".to_string(), "hello".to_string()],
38 response: "欢迎来到直播间!".to_string(),
39 },
40 TriggerConfig {
41 keywords: vec!["谢谢".to_string(), "thanks".to_string()],
42 response: "不客气~".to_string(),
43 },
44 ],
45 }
46 }
47}
48
49#[derive(Serialize, Debug)]
51struct SendDanmakuRequest {
52 csrf: String,
53 roomid: u64,
54 msg: String,
55 rnd: u64,
56 fontsize: u32,
57 color: u32,
58 mode: u32,
59 bubble: u32,
60 room_type: u32,
61 jumpfrom: u32,
62 reply_mid: u32,
63 reply_attr: u32,
64 reply_uname: String,
65 replay_dmid: String,
66 statistics: String,
67 csrf_token: String,
68}
69
70pub struct AutoReplyHandler {
72 config: AutoReplyConfig,
73 last_reply: Arc<Mutex<Option<Instant>>>,
74 http_client: reqwest::Client,
75 runtime: Arc<Runtime>,
76}
77
78impl AutoReplyHandler {
79 pub fn new(config: AutoReplyConfig) -> Self {
81 let http_client = reqwest::Client::builder()
82 .timeout(Duration::from_secs(10))
83 .build()
84 .expect("Failed to create HTTP client");
85
86 let runtime = Arc::new(
87 Runtime::new().expect("Failed to create tokio runtime")
88 );
89
90 Self {
91 config,
92 last_reply: Arc::new(Mutex::new(None)),
93 http_client,
94 runtime,
95 }
96 }
97
98 fn find_matching_trigger(&self, text: &str) -> Option<&TriggerConfig> {
100 let text_lower = text.to_lowercase();
101
102 for trigger in &self.config.triggers {
103 for keyword in &trigger.keywords {
104 if text_lower.contains(&keyword.to_lowercase()) {
105 return Some(trigger);
106 }
107 }
108 }
109
110 None
111 }
112
113 fn select_response(&self, trigger: &TriggerConfig) -> Option<String> {
115 if trigger.response.is_empty() {
116 return None;
117 }
118 Some(trigger.response.clone())
119 }
120
121 fn check_cooldown(&self) -> bool {
123 let last_reply = self.last_reply.lock().unwrap();
124
125 match *last_reply {
126 Some(last_time) => {
127 let elapsed = last_time.elapsed();
128 elapsed >= Duration::from_secs(self.config.cooldown_seconds)
129 }
130 None => true,
131 }
132 }
133
134 fn update_last_reply(&self) {
136 let mut last_reply = self.last_reply.lock().unwrap();
137 *last_reply = Some(Instant::now());
138 }
139
140 fn extract_csrf_token(&self, cookies: &str) -> Option<String> {
142 for cookie in cookies.split(';') {
143 let cookie = cookie.trim();
144 if cookie.starts_with("bili_jct=") {
145 return Some(cookie[9..].to_string());
146 }
147 }
148 None
149 }
150
151 async fn send_danmaku(&self, message: &str, context: &EventContext) -> Result<(), reqwest::Error> {
153 let cookies = match &context.cookies {
154 Some(cookies) => cookies,
155 None => {
156 warn!("No cookies available for sending danmaku");
157 return Ok(());
158 }
159 };
160
161 let csrf_token = match self.extract_csrf_token(cookies) {
162 Some(token) => token,
163 None => {
164 error!("Could not extract CSRF token from cookies");
165 return Ok(());
166 }
167 };
168
169 let rnd = std::time::SystemTime::now()
171 .duration_since(std::time::UNIX_EPOCH)
172 .unwrap()
173 .as_secs();
174
175 let request = SendDanmakuRequest {
176 csrf: csrf_token.clone(),
177 roomid: context.room_id,
178 msg: message.to_string(),
179 rnd,
180 fontsize: 25,
181 color: 16777215, mode: 1, bubble: 0,
184 room_type: 0,
185 jumpfrom: 0,
186 reply_mid: 0,
187 reply_attr: 0,
188 reply_uname: String::new(),
189 replay_dmid: String::new(),
190 statistics: r#"{"appId":100,"platform":5}"#.to_string(),
191 csrf_token,
192 };
193
194 let mut headers = HeaderMap::new();
196 headers.insert("Cookie", HeaderValue::from_str(cookies).unwrap());
197 headers.insert("User-Agent", HeaderValue::from_static(
198 "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0"
199 ));
200 headers.insert("Referer", HeaderValue::from_str(
201 &format!("https://live.bilibili.com/{}", context.room_id)
202 ).unwrap());
203
204 debug!("Sending danmaku: {}", message);
205
206 let response = self.http_client
207 .post("https://api.live.bilibili.com/msg/send")
208 .headers(headers)
209 .form(&request)
210 .send()
211 .await?;
212
213 if response.status().is_success() {
214 info!("Successfully sent danmaku: {}", message);
215 } else {
216 warn!("Failed to send danmaku, status: {}", response.status());
217 let body = response.text().await.unwrap_or_default();
218 debug!("Response body: {}", body);
219 }
220
221 Ok(())
222 }
223}
224
225impl EventHandler for AutoReplyHandler {
226 fn handle(&self, msg: &BiliMessage, context: &EventContext) {
227 if !self.config.enabled {
228 return;
229 }
230
231 if let BiliMessage::Danmu { user: _, text } = msg {
233 if let Some(trigger) = self.find_matching_trigger(text) {
235 if !self.check_cooldown() {
237 debug!("Auto reply on cooldown, skipping");
238 return;
239 }
240
241 if let Some(response) = self.select_response(trigger) {
243 debug!("Auto reply triggered by '{}', responding with '{}'", text, response);
244
245 self.update_last_reply();
247
248 let runtime = Arc::clone(&self.runtime);
250 let _http_client = self.http_client.clone();
251 let response_msg = response.clone();
252 let context_clone = context.clone();
253 let handler = self.clone();
254
255 runtime.spawn(async move {
256 if let Err(e) = handler.send_danmaku(&response_msg, &context_clone).await {
257 error!("Failed to send auto reply: {}", e);
258 }
259 });
260 }
261 }
262 }
263 }
264}
265
266impl Clone for AutoReplyHandler {
268 fn clone(&self) -> Self {
269 Self {
270 config: self.config.clone(),
271 last_reply: Arc::clone(&self.last_reply),
272 http_client: self.http_client.clone(),
273 runtime: Arc::clone(&self.runtime),
274 }
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use client::models::BiliMessage;
282 use client::scheduler::{EventHandler, EventContext};
283
284 #[test]
285 fn test_keyword_matching() {
286 let config = AutoReplyConfig::default();
287 let handler = AutoReplyHandler::new(config);
288
289 assert!(handler.find_matching_trigger("你好世界").is_some());
291 assert!(handler.find_matching_trigger("Hello world").is_some());
292 assert!(handler.find_matching_trigger("谢谢大家").is_some());
293 assert!(handler.find_matching_trigger("Thanks everyone").is_some());
294 assert!(handler.find_matching_trigger("random text").is_none());
295 }
296
297 #[test]
298 fn test_response_selection() {
299 let config = AutoReplyConfig::default();
300 let handler = AutoReplyHandler::new(config);
301
302 let trigger = &handler.config.triggers[0];
303 let response = handler.select_response(trigger);
304 assert!(response.is_some());
305 assert_eq!(response.unwrap(), trigger.response);
306 }
307
308 #[test]
309 fn test_cooldown() {
310 let config = AutoReplyConfig {
311 enabled: true,
312 cooldown_seconds: 1,
313 triggers: vec![],
314 };
315 let handler = AutoReplyHandler::new(config);
316
317 assert!(handler.check_cooldown());
319
320 handler.update_last_reply();
322
323 assert!(!handler.check_cooldown());
325
326 std::thread::sleep(Duration::from_secs(2));
328
329 assert!(handler.check_cooldown());
331 }
332
333 #[test]
334 fn test_csrf_extraction() {
335 let config = AutoReplyConfig::default();
336 let handler = AutoReplyHandler::new(config);
337
338 let cookies = "SESSDATA=abc123; bili_jct=csrf_token_here; other=value";
339 let csrf = handler.extract_csrf_token(cookies);
340 assert_eq!(csrf, Some("csrf_token_here".to_string()));
341
342 let cookies_no_csrf = "SESSDATA=abc123; other=value";
343 let csrf = handler.extract_csrf_token(cookies_no_csrf);
344 assert_eq!(csrf, None);
345 }
346
347 #[test]
348 fn test_event_handler() {
349 let config = AutoReplyConfig {
350 enabled: true,
351 cooldown_seconds: 0, triggers: vec![
353 TriggerConfig {
354 keywords: vec!["test".to_string()],
355 response: "test response".to_string(),
356 }
357 ],
358 };
359 let handler = AutoReplyHandler::new(config);
360
361 let context = EventContext {
362 cookies: Some("bili_jct=test_csrf; SESSDATA=test".to_string()),
363 room_id: 12345,
364 };
365
366 let msg = BiliMessage::Danmu {
367 user: "test_user".to_string(),
368 text: "this is a test message".to_string(),
369 };
370
371 handler.handle(&msg, &context);
373 }
374}