代码Rust实现微信公众号OpenAI机器人
Cargo.toml 配置:[package] name = "robot" version = "0.1.0" authors = ["xwb"] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] actix-web = "4.*" actix = "0.*" actix-rt = "2.*" awc = { version = "3.*", features = ["openssl"] } serde = {version = "1.*", features = ["derive"]} serde_json = "1.*" quick-xml = "0.*" hex = "0.*" sha1 = "0.*" redis = "0.*" [profile.release] incremental = true
main.rs 代码:use actix_web::{App, HttpMessage, HttpRequest, HttpResponse, HttpServer}; use actix_web::web::Query; use awc::ClientBuilder; use serde::Deserialize; use serde_json; use serde_json::value::Value; use redis::{Commands, RedisResult}; use quick_xml::events::{Event, BytesCData, BytesEnd, BytesStart}; use quick_xml::reader::Reader; use quick_xml::writer::Writer; use sha1::{Sha1, Digest}; use hex; use std::{env, str}; use std::time::SystemTime; use std::collections::HashMap; use std::io::Cursor; use std::time::Duration; const OPENAI_COMPLETION_URL: &str = "https://api.openai.com/v1/completions"; const OPENAI_MODEL: &str = "text-davinci-003"; const OPENAI_MAX_TOKENS: u32 = 1024; const OPENAI_IMAGE_URL: &str = "https://api.openai.com/v1/images/generations"; const OPENAI_IMAGE_SIZE: &str = "512x512"; const RETRY_MSG: &str = "请给小木一点时间思考,然后再问小木一次"; fn get_env_var(k: &str, default: &str) -> String { match env::var(k) { Ok(v) => v, Err(_) => default.to_owned() } } fn sha1(v: &str) -> String { let mut sha = Sha1::new(); sha.update(v.as_bytes()); hex::encode(sha.finalize().as_slice()) } fn parse_xml(body: &str) -> HashMap { let mut reader = Reader::from_str(body); let mut buf = Vec::new(); reader.trim_text(true); let mut map: HashMap = HashMap::new(); let mut name = String::from(""); loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => { name = str::from_utf8(e.name().as_ref()).unwrap().to_owned() } Ok(Event::Text(e)) => { let v = e.unescape().unwrap().into_owned(); map.insert(name.clone(), v); } Ok(Event::CData(e)) => { let v = str::from_utf8(e.into_inner().into_owned().as_slice()).unwrap().to_owned(); map.insert(name.clone(), v); } Ok(Event::Eof) => break, _ => (), } } map } async fn request_openai(content: &str) -> String { let openai_api_key = get_env_var("OPENAI_API_KEY", ""); let client = ClientBuilder::new() .timeout(Duration::from_secs(100)) .bearer_auth(openai_api_key) .finish(); if content.starts_with("#") { let data = serde_json::json!({ "prompt": content, "size": OPENAI_IMAGE_SIZE, }); let mut rsp = client.post(OPENAI_IMAGE_URL) .send_json(&data) .await .unwrap(); let body = rsp.body().await.unwrap(); let rst: HashMap = serde_json::from_str( str::from_utf8(body.as_ref()).unwrap() ).unwrap(); rst["data"][0]["url"].as_str().unwrap().to_owned() } else { let data = serde_json::json!({ "model": OPENAI_MODEL, "prompt": content, "max_tokens": OPENAI_MAX_TOKENS, }); let mut rsp = client.post(OPENAI_COMPLETION_URL) .send_json(&data) .await .unwrap(); let body = rsp.body().await.unwrap(); let rst: HashMap = serde_json::from_str( str::from_utf8(body.as_ref()).unwrap() ).unwrap(); rst["choices"][0]["text"].as_str().unwrap().to_owned() } } #[derive(Deserialize)] struct VerifyTokenInfo { signature: String, timestamp: String, nonce: String, echostr: String } #[actix_web::get("/")] async fn verify_token(info: Query) -> HttpResponse { let mut l = std::vec![ get_env_var("WX_TOKEN", ""), info.timestamp.clone(), info.nonce.clone() ]; l.sort(); let msg = l.join(""); if info.signature.eq(sha1(msg.as_str()).as_str()) { HttpResponse::Ok().body(info.echostr.clone()) } else { HttpResponse::Forbidden().body("") } } #[actix_web::post("/")] async fn chat(req: HttpRequest, body: String) -> HttpResponse { if !req.content_type().to_lowercase().ends_with("xml") { return HttpResponse::Ok().body(""); } let map = parse_xml(body.as_str()); if !map.get("MsgType").unwrap().eq("text") { return HttpResponse::Ok().body(""); } let content = map.get("Content").unwrap(); let mut cache = redis::Client::open("redis://127.0.0.1/0") .unwrap() .get_connection() .unwrap(); let k = sha1(content); let v: RedisResult = cache.get(&k); let content_rsp: String; match v { Ok(d) => { content_rsp = d; } Err(_) => { let _: () = cache.set_ex(&k, RETRY_MSG, 30).unwrap(); content_rsp = request_openai(content).await; let _: () = cache.set_ex(&k, content_rsp.as_str(), 600).unwrap(); } } let create_time = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs() .to_string(); let mut writer = Writer::new(Cursor::new(Vec::new())); assert!(writer.write_event(Event::Start(BytesStart::new("xml"))).is_ok()); for (k, v) in [ ("FromUserName", map.get("ToUserName").unwrap().as_str()), ("ToUserName", map.get("FromUserName").unwrap().as_str()), ("CreateTime", create_time.as_str()), ("MsgType", "text"), ("Content", content_rsp.as_str()) ] { assert!(writer.write_event(Event::Start(BytesStart::new(k))).is_ok()); assert!(writer.write_event(Event::CData(BytesCData::new(v))).is_ok()); assert!(writer.write_event(Event::End(BytesEnd::new(k))).is_ok()); } assert!(writer.write_event(Event::End(BytesEnd::new("xml"))).is_ok()); let body_rsp = str::from_utf8(writer.into_inner().into_inner().as_slice()).unwrap().to_owned(); println!("{}", body_rsp); return HttpResponse::Ok().body(body_rsp); } #[actix_web::main] async fn main() -> std::io::Result<()> { let workers = get_env_var("WEB_WORKERS", "4").parse::().unwrap(); let port = get_env_var("WEB_PORT", "8000").parse::().unwrap(); HttpServer::new(|| { App::new() .service(verify_token) .service(chat) }) .bind(("0.0.0.0", port))? .workers(workers) .run() .await }
start.sh 脚本:export WX_TOKEN= export WX_APP_ID= export WX_APP_SECRET= export OPENAI_API_KEY= export WEB_PORT=8000 export WEB_WORKERS=8 pkill -f robot nohup ./target/release/robot &
占用资源非常少,适合小配置机器。
测试
体坛联播莱万哑火巴萨02拜仁,字母哥遭驱逐希腊队淘汰14日凌晨,在欧冠的赛场上,拜仁勒沃库森和法兰克福都赢球了,上一次有三支德甲球队在同一天赢下欧冠,还要追溯到25年前。在这三场比赛中,最受关注的自然是拜仁20巴萨的对决。卢卡斯和萨
轿子雪山要建旅游大环线,上百公里国家步道串联10个乡镇过去,旅客到轿子雪山旅游更多的是乘坐观光缆车登顶的打卡式旅游,将来,国家步道项目将赋予雪山旅游更多的内涵。日前,环轿子山国家步道建设概念性规划项目计划在10月份招标,项目将串联山下
中超山东泰山平武汉三镇9月14日,山东泰山队球员刘洋(右)在比赛中进球后。新华社记者朱峥摄当日,在山东济南举行的2022赛季中国足球协会超级联赛第18轮比赛中,山东泰山队主场以1比1战平武汉三镇队。9月
欧洲杯最新夺冠赔率出炉!法国蹿升第1德国第2,戈贝尔拒绝爆冷?北京时间9月15日,2022年男篮欧洲杯结束四分之一决赛,随着法国通过加时赛93比85击败意大利,波兰爆冷90比87击败斯洛文尼亚,今年欧洲杯夺冠赔率也发生了变化。法国冲上第一,东
期待!这天尤其适合拍照海报制作冯娟星空有约嫦娥与天王将在天宇邂逅天文预报显示,9月15日,月球将与天王星相遇并擦肩而过,上演月掩天王星的现象。天文科普专家表示,我国虽然看不到遮掩的过程,但感兴趣的公众1
国庆节倒计时提醒怎么设置?当我们想要知道距离某一天或者某件事还有多久,一般会采用倒计时的方式。这不马上就要国庆了,想必许多小伙伴都在数着日子上班,今天分享给大家一个设置倒计时的方法,既能增加一些期待感,也方
张翰,要不你还是去拍土味小视频吧一个男的,喝醉酒,进错房间,躺在了一个陌生女人的床上,当两人惊醒后,你猜他俩啥反应?男的,明明喝成了一个二百五,却一点不耽误他在第一时间审视陌生女人的三围,看对方长得挺漂亮,或许还
风云战国之枭雄今晚开播,3大看点,7位实力派,有爆款潜质认真想一下,已经很久没有看过一部历史剧佳作了。前几天,和一位朋友一起看了几集大明风华,也是感慨良多,现在的历史剧大多处于一种尴尬的境地既不是历史正剧,但又比戏说多了那么一丝严谨。简
相同预算,买笔记本还是台式机?光学习办公用,有必要挑台式机?如果你想买台笔记本,询问一下身边懂行的大佬来推荐推荐他多半会来一句但是笔记本真的不如台式机吗?相同预算下,买笔记本还是台式机?买台台式机,怎么挑才能不出错?光学习办公用,有必要挑台
打针之后,为何感冒好的更慢了?感冒时应该如何用药?最近朋友跟小南聊起他家孩子,因为平时上班比较忙,孩子都是爷爷奶奶照顾,一有点头疼感冒爷爷奶奶就立马带着孩子去打针,结果几年下来,孩子变得弱不禁风,现在感冒了打针一般都得一个星期以上
五岁时还不会说话的孩子后来怎么样了?如果有人问一个五岁时还不会说话的孩子后来怎么样了?也许人们说会成为哑巴,或者语言障碍而说话不清,或智力发育迟缓不够聪明。其实,他仍然和绝大多数普通的孩子一样,不是特别聪明,也不是很