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