Skip to main content

kovi_plugin_octowatch/
lib.rs

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