kovi_plugin_live_agent/
util.rs

1//! High level abstractions
2
3use kovi::{
4    tokio::time::{interval, sleep},
5    Message,
6};
7use rand::{thread_rng, Rng};
8use serde::{Deserialize, Serialize};
9use std::{future::Future, path::PathBuf, time::Duration};
10use time::{
11    macros::{format_description, offset},
12    OffsetDateTime,
13};
14
15use crate::{
16    db_warn, exception::PluginResult, global_state, std_db_error, std_info, store, BOT_QQ, CONFIG,
17};
18
19/// Schedule a periodic task that blocks current task forever.
20pub async fn schedule_task_blocking<F, Fut>(duration: Duration, mut task: F)
21where
22    F: FnMut() -> Fut,
23    Fut: Future<Output = ()>,
24{
25    let mut timer = interval(duration);
26    loop {
27        timer.tick().await;
28        task().await;
29    }
30}
31
32pub async fn sleep_rand_time() {
33    let config = CONFIG.get().unwrap();
34    let max_sleep_sec = config.global.max_sleep_sec as f64;
35    let rand_time = {
36        let mut rng = thread_rng();
37        rng.gen_range(0.0..max_sleep_sec)
38    };
39    std_info!("Sleep {rand_time} seconds.");
40    sleep(Duration::from_secs_f64(rand_time)).await;
41}
42
43pub async fn extract_text(msg: &Message) -> String {
44    let text_segs = msg.get("text");
45    let mut buf = String::new();
46    for seg in text_segs {
47        let Ok(content) = serde_json::from_value::<String>(seg.data["text"].clone()) else {
48            std_db_error!(
49                "
50                Extract text: data object inside text segment has no text field
51                Data: {}
52                ",
53                seg.data
54            );
55            return buf;
56        };
57        buf.push_str(&content);
58        buf.push('\n');
59    }
60    buf
61}
62
63pub async fn extract_segments<T>(msg: T) -> Vec<(String, String)>
64where
65    T: Into<Message>,
66{
67    let message = msg.into();
68    let len = message.iter().len();
69    let mut list = Vec::with_capacity(len);
70    for seg in message.iter() {
71        let seg_type = seg.type_.clone();
72        let content: Option<String> = match seg_type.as_str() {
73            "text" => serde_json::from_value(seg.data["text"].clone()).ok(),
74            "image" | "record" | "video" => serde_json::from_value(seg.data["file"].clone()).ok(),
75            "at" => serde_json::from_value(seg.data["qq"].clone()).ok(),
76            "share" => serde_json::from_value(seg.data["url"].clone()).ok(),
77            "contact" | "reply" | "forward" | "node" => {
78                serde_json::from_value(seg.data["id"].clone()).ok()
79            }
80            _ => None,
81        };
82        let Some(content) = content else {
83            db_warn!(
84                "
85                Skip extract segment that is not pre-defined: 
86                Data: {}
87                ",
88                seg.data
89            );
90            continue;
91        };
92        list.push((seg_type, content));
93    }
94    list
95}
96
97/// Obtain "[year-month-day hour:minute:second]".
98pub fn cur_time_iso8601() -> String {
99    let offset = offset!(+8);
100    let datetime = OffsetDateTime::now_utc().to_offset(offset);
101    let desc = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
102    datetime.format(desc).unwrap()
103}
104
105/// Convert unix timestamp to "[year-month-day hour:minute:second]".  
106/// This may fail if the timestamp passed in is before 1970.
107pub fn iso8601_from_timestamp(timestamp: i64) -> PluginResult<String> {
108    let offset = offset!(+8);
109    let datetime = OffsetDateTime::from_unix_timestamp(timestamp)?.to_offset(offset);
110    let desc = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
111    Ok(datetime.format(desc)?)
112}
113
114/// There is a one-way conversion from timestamp to iso8601, because this plugin exclusively uses
115/// iso8601.
116pub enum TimeRepr {
117    Iso8601(String),
118    UnixTimeStamp(i64),
119}
120
121impl TimeRepr {
122    /// Silently log time error and return None on failure.
123    pub async fn to_iso8601(&self) -> Option<String> {
124        match self {
125            Self::Iso8601(t) => Some(t.clone()),
126            Self::UnixTimeStamp(t) => match iso8601_from_timestamp(*t) {
127                Ok(t) => Some(t),
128                Err(err) => {
129                    std_db_error!("{err}");
130                    None
131                }
132            },
133        }
134    }
135}
136
137impl Default for TimeRepr {
138    fn default() -> Self {
139        Self::Iso8601(cur_time_iso8601())
140    }
141}
142
143/// Get human readable name of a user in specified group with best effort.  
144///
145/// Returns one of the following in descending priority:  
146/// 0. known member config  
147/// 1. card, the nickname used exclusively in specified group  
148/// 2. username, the global nickname for user account  
149/// 3. user id, wouldn't bother querying stranger info  
150pub async fn get_name_in_group(group_id: i64, user_id: i64) -> String {
151    // decide to nest for short circuit 0
152    // if let else syntax cannot fall through normal control
153    let config = CONFIG.get().unwrap();
154    if let Some(ref groups) = config.groups {
155        if let Some(group) = groups.iter().find(|&g| g.id == group_id) {
156            if let Some(ref agent) = group.agent {
157                // is a known member -> return configured name
158                if let Some((name, _)) = agent.known_members.get(&user_id.to_string()) {
159                    return name.to_string();
160                }
161            }
162        }
163    }
164
165    // fallback to 1, 2, 3
166    let bot = global_state::get_bot();
167    let group_member_api = bot.get_group_member_info(group_id, user_id, false).await;
168
169    match group_member_api {
170        Ok(api) => {
171            // request success
172            let group_member_info =
173                serde_json::from_value::<GroupMemberInfoResponse>(api.data.clone());
174            match group_member_info {
175                Ok(info) => {
176                    // deserialize success
177                    // 1, 2
178                    let first_non_empty = [info.card, info.nickname]
179                        .into_iter()
180                        .find(|x| !x.is_empty());
181                    match first_non_empty {
182                        Some(name) => name,
183                        // 3
184                        None => user_id.to_string(),
185                    }
186                }
187                Err(err) => {
188                    // deserialize fail
189                    // 3
190                    std_db_error!(
191                        "
192                        GroupMemberInfo deserialize failed.
193                        Cause: {err}
194                        Data: {}
195                        ",
196                        api.data
197                    );
198                    // 3
199                    user_id.to_string()
200                }
201            }
202        }
203        Err(err) => {
204            // request fail
205            // 3
206            std_db_error!(
207                "
208                GroupMemberInfo api request failed.
209                Cause: {err}
210                "
211            );
212            user_id.to_string()
213        }
214    }
215}
216
217/// For somewhat reason [bot.send_group_msg][kovi::RuntimeBot::send_group_msg] invokes [From] thus
218/// clone is inevitable here.
219pub async fn send_group_and_log<T>(group_id: i64, message: T)
220where
221    T: Into<Message>,
222    T: Serialize,
223{
224    let bot = global_state::get_bot();
225    let message: Message = message.into();
226    let sender_id = *BOT_QQ.get().unwrap();
227    bot.send_group_msg(group_id, message.clone());
228    store::write_group_msg(group_id, 0, None, sender_id, message).await;
229}
230
231/// Execute the configured script to upload a file and return its stdout.  
232///
233/// It is safe to call it without [object config][global_state::Config::object_storage], or with a
234/// script that does not function correctly. In such cases the return value will fallback to file
235/// path thus no data loss.
236pub async fn call_upload(file_path_str: &str) -> String {
237    let config = CONFIG.get().unwrap();
238    // object storage not configured, return original file path
239    let Some(ref obj) = config.object_storage else {
240        return file_path_str.to_string();
241    };
242
243    // script path
244    let exec_path_str = &obj.script_path;
245    let exec_path = PathBuf::from(exec_path_str);
246    let Ok(abs_exec) = exec_path.canonicalize() else {
247        std_db_error!("Script path cannot be parsed to an absolute path: {exec_path_str}");
248        return file_path_str.to_string();
249    };
250    let abs_exec_str = abs_exec.to_string_lossy().to_string();
251
252    // file path to be uploaded
253    let file_path = PathBuf::from(file_path_str);
254    let abs_file_str = file_path.to_string_lossy().to_string();
255
256    std_info!("Execute script: {abs_exec_str}, Argument: {abs_file_str}");
257
258    // launch child process
259    let mut cmd = kovi::tokio::process::Command::new(abs_exec_str);
260    let output = match cmd.arg(abs_file_str).output().await {
261        Ok(out) => out,
262        Err(err) => {
263            std_db_error!("Launch process failed: {err}");
264            return file_path_str.to_string();
265        }
266    };
267
268    if !output.status.success() {
269        // script terminates with code != 0
270        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
271        std_db_error!(
272            "
273            Upload script failed.
274            Stderr: {stderr}
275            "
276        );
277        return file_path_str.to_string();
278    }
279    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
280    std_info!("Upload script succeed with online path: {stdout}");
281    stdout
282}
283
284#[derive(Deserialize, Debug, PartialEq, Eq)]
285pub struct GroupMemberInfoResponse {
286    group_id: i64,
287    user_id: i64,
288    nickname: String,
289    card: String,
290    sex: String,
291    age: i32,
292    area: String,
293    join_time: i32,
294    last_sent_time: i32,
295    level: String,
296    role: String,
297    unfriendly: bool,
298    title: String,
299    title_expire_time: i32,
300    card_changeable: bool,
301}