kovi_plugin_octowatch/
lib.rs

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