Skip to main content

kovi_plugin_farm/
lib.rs

1use std::collections::VecDeque;
2use std::fs;
3use std::fs::File;
4use std::io::{Cursor, Read, Write};
5use std::path::PathBuf;
6use std::process::Stdio;
7use std::sync::{Arc, LazyLock};
8use std::time::Duration;
9use kovi::{Message, PluginBuilder as plugin, PluginBuilder};
10use kovi::log::{error, info};
11use kovi::tokio::time::{interval, timeout};
12use kovi_onebot::{EventRegistrar, MessageRegistrar, MsgEvent};
13use reqwest::{Client};
14use reqwest::header::HeaderMap;
15use serde::{Deserialize, Serialize};
16use tokio::process::{Child, Command};
17use dashmap::DashMap;
18use tokio::io::{AsyncBufReadExt, BufReader};
19use tokio::sync::Mutex;
20
21static STATUS: LazyLock<DashMap<String, Process>> = LazyLock::new(|| DashMap::new());
22
23struct Process {
24    child: Child,
25    output: Arc<Mutex<VecDeque<String>>>,
26}
27
28#[kovi::plugin]
29async fn main() {
30    let bot = PluginBuilder::get_runtime_bot();
31    let data_path = bot.get_data_path();
32    let path = check_exists(data_path).await;
33    plugin::on_msg(move |event| {
34        let bot = bot.clone();
35        let path = path.clone();
36        async move {
37            let text = event.borrow_text().unwrap_or("");
38            if text.starts_with("登录农场") {
39                let data = get_login_url(event.clone(), path).await;
40                let msg = Message::new()
41                    .add_text(data.url);
42                event.reply(msg);
43            } else if text.starts_with("农场状态") {
44                let uid = event.user_id.to_string();
45                if STATUS.contains_key(&uid) {
46                    let output = get_output(uid).await;
47                    let mut text = "农场状态:\n".to_string();
48                    for i in output {
49                        text.push_str(format!("{}\n", i).as_str());
50                    }
51                    let msg = Message::new()
52                        .add_text(text);
53                    event.reply(msg);
54                } else {
55                    let msg = Message::new()
56                        .add_text("您还未登录,请先登录后操作");
57                    event.reply(msg);
58                }
59            } else if text.starts_with("获取QQ名") {
60                let msg = Message::new()
61                    .add_text(format!("{}", event.get_sender_nickname()));
62                event.reply(msg);
63            } else if text.starts_with("农场在线数") {
64                let msg = Message::new()
65                    .add_text(format!("当前脚本在线数:{}", STATUS.len()));
66                event.reply(msg);
67            } else if text.starts_with("退出农场") {
68                let uid = event.user_id.to_string();
69                if let Some(mut entry) = STATUS.get_mut(&uid) {
70                    entry.child.kill().await.ok();
71                    drop(entry);
72                    STATUS.remove(&uid);
73                    event.reply("已退出农场,脚本已停止运行");
74                } else {
75                    event.reply("您当前没有正在运行的农场脚本");
76                }
77            } else if text.starts_with("农场帮助") {
78                let help_text = r#"🌾 农场插件帮助
79
80可用命令:
81• 登录农场 - 获取登录链接,登录后自动启动农场脚本
82• 农场状态 - 查看当前脚本运行状态及最近日志
83• 农场在线数 - 查看当前脚本在线数量
84• 退出农场 - 停止当前运行的农场脚本
85• 农场帮助 - 显示此帮助信息"#;
86                let msg = Message::new()
87                    .add_text(help_text);
88                event.reply(msg);
89            }
90        }
91    });
92}
93
94async fn get_output(user_id: String) -> VecDeque<String> {
95    if let Some(entry) = STATUS.get(&user_id) {
96        entry.output.lock().await.clone()
97    } else {
98        VecDeque::new()
99    }
100}
101
102async fn check_exists(mut data_path: PathBuf) -> PathBuf {
103    data_path.push("qq-farm-bot");
104    if data_path.exists() && data_path.is_dir() {
105        info!("目录存在,无需下载");
106    } else {
107        info!("目录不存在,触发下载请求");
108        download(data_path.clone()).await.unwrap();
109    }
110
111    // 执行 npm install
112    info!("正在执行 npm install...");
113
114    let status = if cfg!(target_os = "windows") {
115        Command::new("cmd")
116            .args(["/C", "npm", "install"])
117            .current_dir(&data_path)
118            .status()
119    } else {
120        Command::new("npm")
121            .arg("install")
122            .current_dir(&data_path)
123            .status()
124    };
125
126    match status.await {
127        Ok(s) if s.success() => info!("npm install 完成"),
128        Ok(s) => error!("npm install 失败,退出码: {:?}", s.code()),
129        Err(e) => error!("npm install 执行失败: {}", e),
130    }
131
132    data_path
133}
134
135async fn download(path: PathBuf) -> Result<String, Box<dyn std::error::Error>> {
136    let owner = "ryunnet";
137    let repo = "qq-farm-bot";
138    let branch = "main";
139
140    let url = format!(
141        "https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
142    );
143
144    info!("正在下载 {owner}/{repo}...");
145    let response = reqwest::get(&url).await?;
146
147    if !response.status().is_success() {
148        return Err(format!("下载失败: {}", response.status()).into());
149    }
150
151    let bytes = response.bytes().await?;
152
153    // 解压
154    let cursor = Cursor::new(bytes);
155    let mut archive = zip::ZipArchive::new(cursor)?;
156
157    let output_dir = path;
158
159    for i in 0..archive.len() {
160        let mut entry = archive.by_index(i)?;
161        let name = entry.name().to_string();
162
163        // 去掉第一层目录(如"qq-farm-bot-main/")
164        let stripped = match name.find('/') {
165            Some(pos) => &name[pos + 1..],
166            None => continue,
167        };
168
169        //跳过空路径(即根目录本身)
170        if stripped.is_empty() {
171            continue;
172        }
173
174        let out_path = output_dir.join(stripped);
175
176        if entry.is_dir() {
177            fs::create_dir_all(&out_path)?;
178        } else {
179            if let Some(parent) = out_path.parent() {
180                fs::create_dir_all(parent)?;
181            }
182            let mut buf = Vec::new();
183            entry.read_to_end(&mut buf)?;
184            let mut file = File::create(&out_path)?;
185            file.write_all(&buf)?;
186        }
187    }
188
189    info!("解压完成到 {:?}", output_dir);
190
191    return Ok(String::new());
192}
193
194async fn get_login_url(event: Arc<MsgEvent>, path: PathBuf) -> Data {
195    let client = Client::builder().build().unwrap();
196    let res = client.get("https://q.qq.com/ide/devtoolAuth/GetLoginCode").headers(get_headers()).send().await.unwrap();
197    let text = res.text().await.unwrap();
198    let res: Login = serde_json::from_str(&text).unwrap();
199    info!("{:#?}", res);
200
201    let data = Data {
202        code: res.data.code.clone(),
203        url: format!("https://h5.qzone.qq.com/qqq/code/{}?_proxy=1&from=ide", res.data.code.clone())
204    };
205
206    let code = data.code.clone();
207    tokio::spawn(async move {
208        let result = timeout(Duration::from_secs(300), async {
209            let mut ticker = interval(Duration::from_secs(2));
210            loop {
211                ticker.tick().await;
212                let data = check_login_status(code.clone()).await;
213                if let Some(ok) = data.data.ok {
214                    if ok == 1 {
215                        info!("data = {:?}", data);
216                        event.reply(format!("正在为[ {} ]启动脚本!", event.get_sender_nickname()));
217                        let uid = event.user_id.to_string();
218                        if let Some(mut child) = STATUS.get_mut(&uid) {
219                            child.child.kill().await.ok();
220                        }
221                        STATUS.remove(&uid);
222                        let code = get_auth_code(data.data.ticket.unwrap()).await;
223                        start(code, path.clone(), uid).await;
224                        break;
225                    }
226                }
227            }
228        }).await;
229    });
230
231    data
232}
233
234async fn start(code: String, path: PathBuf, user_id: String) {
235    let mut child = if cfg!(target_os = "windows") {
236        Command::new("cmd")
237            .args(["/C", "node", "client.js", "--code", code.as_str()])
238            .stdout(Stdio::piped())
239            .stderr(Stdio::piped())
240            .current_dir(&path)
241            .kill_on_drop(true)
242            .spawn().unwrap()
243    } else {
244        Command::new("node")
245            .args(["client.js", "--code", code.as_str()])
246            .stdout(Stdio::piped())
247            .stderr(Stdio::piped())
248            .current_dir(&path)
249            .kill_on_drop(true)
250            .spawn().unwrap()
251    };
252    let stdout = child.stdout.take().unwrap();
253    let stderr = child.stderr.take().unwrap();
254    let output = Arc::new(Mutex::new(VecDeque::new()));
255
256    let process = Process {
257        child,
258        output: Arc::clone(&output),
259    };
260    STATUS.insert(user_id.clone(), process);
261
262    // 异步收集 stdout,并检测错误关键词
263    let stdout_uid = user_id.clone();
264    let stdout_output = Arc::clone(&output);
265    tokio::spawn(async move {
266        let mut reader = BufReader::new(stdout).lines();
267        while let Ok(Some(line)) = reader.next_line().await {
268            println!("[{}] {}", stdout_uid, line);
269
270            // 检测错误关键词
271            let is_error = line.contains("连接未打开")
272                || line.contains("检查失败")
273                || line.contains("巡查失败")
274                || line.contains("连接失败");
275
276            let mut output = stdout_output.lock().await;
277            if output.len() >= 10 {
278                output.pop_front();
279            }
280
281            if is_error {
282                output.push_back(format!("[错误] {}", line));
283                drop(output);
284
285                // kill 进程并移除状态
286                if let Some(mut entry) = STATUS.get_mut(&stdout_uid) {
287                    entry.child.kill().await.ok();
288                }
289                STATUS.remove(&stdout_uid);
290                error!("[{}] 检测到连接错误,已终止进程", stdout_uid);
291                break;
292            } else {
293                output.push_back(line);
294            }
295        }
296    });
297
298    // 异步监听 stderr,出现异常输出时 kill 进程
299    let stderr_uid = user_id.clone();
300    tokio::spawn(async move {
301        let mut reader = BufReader::new(stderr).lines();
302        while let Ok(Some(line)) = reader.next_line().await {
303            error!("[{}] stderr: {}", stderr_uid, line);
304            let mut output = output.lock().await;
305            if output.len() >= 10 {
306                output.pop_front();
307            }
308            output.push_back(format!("[异常] {}", line));
309            drop(output);
310
311            // kill 进程并移除状态
312            if let Some(mut entry) = STATUS.get_mut(&stderr_uid) {
313                entry.child.kill().await.ok();
314            }
315            STATUS.remove(&stderr_uid);
316            error!("[{}] 检测到异常输出,已终止进程", stderr_uid);
317            break;
318        }
319    });
320}
321
322async fn check_login_status(code: String) -> Login {
323    let client = Client::builder().build().unwrap();
324    let url = format!("https://q.qq.com/ide/devtoolAuth/syncScanSateGetTicket?code={code}");
325    let res = client.get(url).headers(get_headers()).send().await.unwrap();
326    let text = res.text().await.unwrap();
327
328    let data: Login = serde_json::from_str(text.as_str()).unwrap();
329
330    data
331}
332
333#[derive(Deserialize, Debug)]
334struct Data {
335    code: String,
336    url: String
337}
338
339#[derive(Deserialize, Debug)]
340struct Login {
341    code: i16,
342    data: LoginData,
343    message: Option<String>,
344}
345
346#[derive(Deserialize, Debug)]
347struct LoginData {
348    code: String,
349    ticket: Option<String>,
350    ok: Option<i16>,
351    uin: Option<String>
352}
353
354fn get_headers() -> HeaderMap {
355    let mut headers = HeaderMap::new();
356    headers.insert("content-type", "application/json".parse().unwrap());
357    headers.insert("qua", "V1_HT5_QDT_0.70.2209190_x64_0_DEV_D".parse().unwrap());
358    headers.insert("host", "q.qq.com".parse().unwrap());
359    headers.insert("accept", "application/json".parse().unwrap());
360    headers
361}
362
363pub async fn get_auth_code(ticket: String) -> String{
364    let client = Client::builder().build().unwrap();
365    let url = "https://q.qq.com/ide/login".to_string();
366    let auth = Auth {
367        appid: String::from("1112386029"),
368        ticket: ticket.clone()
369    };
370    let res = client.post(url).headers(get_headers()).json(&auth).send().await.unwrap();
371    let text = res.text().await.unwrap();
372    println!("{:#?}", text);
373    let json = serde_json::from_str::<AuthCode>(text.as_str()).unwrap();
374    json.code
375}
376
377#[derive(Deserialize, Debug)]
378struct AuthCode {
379    code: String,
380    message: String,
381}
382
383#[derive(Deserialize, Serialize)]
384struct Auth {
385    appid: String,
386    ticket: String,
387}