blivedm_plugins/
auto_reply.rs

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/// Configuration for keyword-response triggers
11#[derive(Debug, Clone)]
12pub struct TriggerConfig {
13    /// Keywords that trigger this response
14    pub keywords: Vec<String>,
15    /// Response message to send
16    pub response: String,
17}
18
19/// Configuration for the auto reply plugin
20#[derive(Debug, Clone)]
21pub struct AutoReplyConfig {
22    /// Whether the plugin is enabled
23    pub enabled: bool,
24    /// Minimum cooldown between replies in seconds
25    pub cooldown_seconds: u64,
26    /// List of trigger configurations
27    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/// Parameters for sending a danmaku message to Bilibili API
50#[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
70/// Auto reply handler that monitors danmaku for keywords and sends responses
71pub 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    /// Create a new auto reply handler with the given configuration
80    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    /// Check if any keyword matches the message text
99    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    /// Get the response from the trigger
114    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    /// Check if enough time has passed since the last reply
122    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    /// Update the last reply timestamp
135    fn update_last_reply(&self) {
136        let mut last_reply = self.last_reply.lock().unwrap();
137        *last_reply = Some(Instant::now());
138    }
139
140    /// Extract CSRF token from cookies
141    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    /// Send a danmaku message to the Bilibili API
152    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        // Current timestamp
170        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, // White color
182            mode: 1,        // Scroll mode
183            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        // Set up headers
195        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        // Only process danmaku messages
232        if let BiliMessage::Danmu { user: _, text } = msg {
233            // Check for keyword match
234            if let Some(trigger) = self.find_matching_trigger(text) {
235                // Check cooldown
236                if !self.check_cooldown() {
237                    debug!("Auto reply on cooldown, skipping");
238                    return;
239                }
240
241                // Select response
242                if let Some(response) = self.select_response(trigger) {
243                    debug!("Auto reply triggered by '{}', responding with '{}'", text, response);
244                    
245                    // Update cooldown
246                    self.update_last_reply();
247
248                    // Send the reply asynchronously
249                    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
266// Implement Clone for AutoReplyHandler
267impl 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        // Test keyword matching
290        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        // Initial check should pass
318        assert!(handler.check_cooldown());
319        
320        // Update timestamp
321        handler.update_last_reply();
322        
323        // Should be on cooldown now
324        assert!(!handler.check_cooldown());
325        
326        // Wait for cooldown
327        std::thread::sleep(Duration::from_secs(2));
328        
329        // Should be off cooldown now
330        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, // No cooldown for testing
352            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        // This should trigger the auto reply (but won't actually send due to test environment)
372        handler.handle(&msg, &context);
373    }
374}