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