kovi_plugin_octowatch/
lib.rs1mod 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}