Skip to main content

kovi_plugin_octowatch/
lib.rs

1mod config;
2mod consts;
3
4use std::{collections::HashMap, sync::Arc};
5
6use anyhow::Context;
7use kovi::{
8    PluginBuilder as plugin, RuntimeBot, Segment,
9    bot::runtimebot::kovi_api::SetAccessControlList,
10    chrono::{Duration, Utc},
11    log::{info, warn},
12    serde_json::json,
13};
14use kovi_onebot::*;
15use octocrab::Octocrab;
16use openai::chat::{ChatCompletion, ChatCompletionMessage, ChatCompletionMessageRole};
17
18use config::RepoConfig;
19use consts::*;
20
21#[kovi::plugin]
22async fn main() {
23    let bot = plugin::get_runtime_bot();
24    let config = config::init(bot.get_data_path())
25        .await
26        .with_context(|| format!("[{PLUGIN_HEAD}] Error when parsing config"))
27        .unwrap();
28
29    bot.set_plugin_access_control(PLUGIN_NAME, true).unwrap();
30    bot.set_plugin_access_control_list(
31        PLUGIN_NAME,
32        true,
33        SetAccessControlList::Adds(
34            config
35                .repos
36                .iter()
37                .cloned()
38                .flat_map(|r| r.groups)
39                .collect(),
40        ),
41    )
42    .unwrap();
43
44    let mut gh = octocrab::OctocrabBuilder::new();
45    if let Some(token) = &config.github_token {
46        gh = gh.user_access_token(token.clone());
47    }
48    let gh = Arc::new(gh.build().unwrap());
49
50    for repo in &config.repos {
51        plugin::cron(&format!("{}/{} * * ?", repo.time, repo.interval), {
52            let bot = bot.clone();
53            let gh = gh.clone();
54            move || handle_repo_check(repo, bot.clone(), gh.clone())
55        })
56        .unwrap();
57    }
58
59    info!("[{PLUGIN_HEAD}] Ready to watch some github repos!")
60}
61
62struct Contribution {
63    author: String,
64    commits: Vec<String>,
65}
66
67async fn handle_repo_check(repo: &RepoConfig, bot: Arc<RuntimeBot>, gh: Arc<Octocrab>) {
68    let conf = config::CONFIG.get().unwrap();
69
70    let now: kovi::chrono::DateTime<Utc> = Utc::now();
71    let commits = gh
72        .repos(&repo.owner, &repo.repo)
73        .list_commits()
74        .since(now - Duration::hours(repo.interval.into()))
75        .send()
76        .await;
77
78    if let Err(e) = commits {
79        warn!("[{PLUGIN_HEAD}] Failed to fetch commits: {e}");
80        return;
81    }
82
83    let commits = commits.unwrap();
84    let cnt = commits.items.len();
85
86    info!(
87        "[{PLUGIN_HEAD}] Retrived {} commit(s) from {}/{}",
88        cnt, repo.owner, repo.repo
89    );
90
91    let mut conts: HashMap<String, Contribution> = HashMap::new();
92    for commit in commits {
93        let author = match commit.commit.author {
94            Some(c) => c,
95            None => {
96                info!(
97                    "[{PLUGIN_HEAD}] Commit {} has no author, skipped",
98                    &commit.sha[0..6]
99                );
100                continue;
101            }
102        };
103
104        let email = author.email;
105        if email.is_none() {
106            continue;
107        }
108        let email = email.unwrap();
109
110        if !conts.contains_key(&email) {
111            conts.insert(
112                email.clone(),
113                Contribution {
114                    author: author.name,
115                    commits: vec![],
116                },
117            );
118        }
119
120        let cont = conts.get_mut(&email).unwrap();
121        let msg = commit.commit.message.trim().to_string();
122
123        let msg = if let Some(idx) = msg.find('\n') {
124            let is_merge = msg.starts_with("Merge");
125
126            if is_merge {
127                format!("[Merge] {}", msg[idx + 1..].trim())
128            } else {
129                msg[..idx].trim().to_string()
130            }
131        } else {
132            commit.commit.message
133        };
134        cont.commits.push(msg.trim().to_string());
135    }
136
137    info!(
138        "[{PLUGIN_HEAD}] {} user has contributed, gathered.",
139        conts.len()
140    );
141
142    let mut prompts = vec![];
143
144    if !conts.is_empty() {
145        prompts.push(ChatCompletionMessage {
146            role: ChatCompletionMessageRole::User,
147            content: Some(conf.llm.prompt_summary.clone()),
148            name: None,
149            function_call: None,
150            tool_calls: None,
151            tool_call_id: None,
152        });
153        prompts.extend(
154            conts
155                .values()
156                .flat_map(|e| &e.commits)
157                .map(|e| ChatCompletionMessage {
158                    role: ChatCompletionMessageRole::User,
159                    content: Some(e.clone()),
160                    name: None,
161                    function_call: None,
162                    tool_calls: None,
163                    tool_call_id: None,
164                }),
165        );
166    } else {
167        prompts.push(ChatCompletionMessage {
168            role: ChatCompletionMessageRole::User,
169            content: Some(conf.llm.prompt_criticize.clone()),
170            name: None,
171            function_call: None,
172            tool_calls: None,
173            tool_call_id: None,
174        });
175    }
176
177    let cmpl = ChatCompletion::builder(&conf.llm.model, prompts)
178        .credentials(conf.llm.cred.clone())
179        .create()
180        .await;
181
182    if let Err(e) = cmpl {
183        warn!("[{PLUGIN_HEAD}] Failed to create LLM completion: {e}");
184        return;
185    }
186
187    let cmpl = cmpl.unwrap().choices[0].message.content.clone();
188
189    if cmpl.is_none() {
190        warn!("[{PLUGIN_HEAD}] No content returned from LLM");
191        return;
192    }
193
194    let cmpl = cmpl.unwrap();
195    let cmpl = if cmpl.contains("</think>") {
196        cmpl.split("</think>").nth(1).unwrap().to_string()
197    } else {
198        cmpl
199    };
200
201    let mut txts: Vec<String> = vec![
202        format!("仓库 {}/{}", repo.owner, repo.repo),
203        format!(
204            "在过去的 {} 小时里共接收到 {} 次 commit\n",
205            repo.interval, cnt
206        ),
207        cmpl.trim().to_string(),
208    ];
209
210    if !conts.is_empty() {
211        txts.push("".into());
212        txts.push("各成员贡献情况:\n".into());
213    }
214
215    let mut msgs: Vec<Segment> = vec![Segment::new(
216        "text",
217        json!(
218            {
219                "text": txts.join("\n")
220            }
221        ),
222    )];
223
224    for (usr, cont) in conts {
225        let head = if !usr.ends_with("qq.com") {
226            let u = cont.author;
227            Segment::new(
228                "text",
229                json!({
230                    "text":u
231                }),
232            )
233        } else {
234            let qq = usr.split('@').next().unwrap();
235
236            match qq.parse::<u32>().ok() {
237                Some(qq) => {
238                    info!("[{PLUGIN_HEAD}] Extracted QQ: {}", qq);
239
240                    Segment::new(
241                        "at",
242                        json!({
243                           "qq":qq
244                        }),
245                    )
246                }
247                None => {
248                    let u = cont.author;
249                    Segment::new(
250                        "text",
251                        json!({
252                            "text":u
253                        }),
254                    )
255                }
256            }
257        };
258
259        msgs.push(head);
260
261        let cmts = cont
262            .commits
263            .iter()
264            .map(|msg| format!("- {msg}"))
265            .collect::<Vec<String>>()
266            .join("\n");
267
268        msgs.push(Segment::new(
269            "text",
270            json!({
271                "text":format!("\n{}\n\n", cmts)
272            }),
273        ));
274    }
275
276    for g in &repo.groups {
277        bot.send_group_msg(g.try_as_i64_or_panic(), msgs.clone());
278    }
279}