Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
9.6 KiB
JSON

{"repo": "amtoaer/bocchi", "files": {"crates/bocchi_bot/src/plugin/gpt/mod.rs": {"63edacd5fcb4c52dea35a36bb4b34773c6a42b39": {"t": 1786067493.3496437, "n": 8767, "h": "24ec29b3f8d3d23a", "c": "use std::{env, sync::LazyLock};\n\nuse anyhow::{Error, Result};\nuse async_tempfile::TempFile;\nuse bocchi::{\n chain::{Context, Rule},\n plugin::Plugin,\n schema::{Emoji, MessageContent, MessageSegment},\n};\nuse dashmap::DashMap;\nuse serde_json::{Value, json};\nuse tokio::{io::AsyncWriteExt, sync::Mutex};\n\nuse crate::{\n migrate::database,\n model::memory::{CachedMessage, v1::Memory},\n utils::HTTP_CLIENT,\n};\n\nmod markdown;\n\nstatic DEEPSEEK_API_KEY: LazyLock<String> = LazyLock::new(|| env::var(\"DEEPSEEK_API_KEY\").unwrap_or_default());\nstatic LOCKS: LazyLock<DashMap<String, Mutex<()>>> = LazyLock::new(DashMap::new);\n\npub fn gpt_plugin() -> Plugin {\n let mut plugin = Plugin::new(\"GPT 插件\", \"使用 DeepSeek API 进行对话\");\n\n for (description, command, max_tokens, reply_image) in [\n (\"提问并获得文本答复\", \"#gpt\", Some(512), false), // gpt 使用文本输出,需要文本内容较短\n (\"提问并获得图片答复\", \"#igpt\", None, true), // igpt 使用图片输出,不需要限制 token\n ] {\n plugin.on(\n description,\n i32::default(),\n Rule::on_group_message() & Rule::on_prefix(command),\n move |ctx| async move { call_deepseek_api(ctx, command, max_tokens, reply_image).await },\n )\n }\n\n for (description, command, lookup_command, reply_image) in [\n (\"查询 gpt 历史记录\", \"#gpt_history\", \"#gpt\", false),\n (\"查询 igpt 历史记录\", \"#igpt_history\", \"#igpt\", true),\n ] {\n plugin.on(\n description,\n i32::default() + 1, // 确保查询历史记录的优先级高于提问\n Rule::on_group_message() & Rule::on_exact_match(command),\n move |ctx| async move { query_gpt_history(ctx, lookup_command, reply_image).await },\n );\n }\n\n plugin.on(\n \"清除 GPT 的消息历史\",\n i32::default(),\n Rule::on_group_message() & Rule::on_exact_match(\"#clear_gpt\"),\n move |ctx| async move {\n // 上面 matcher 条件写了 group_message,理论上可以直接拿到 group_id\n // 但为了保证 cache_key 的兼容性,还是使用 try_group_id().ok() 拿到 Option<u64> 使用\n let (user_id, optional_group_id) = (ctx.event.user_id(), ctx.event.try_group_id().ok());\n let rw = database().rw_transaction()?;\n for command in [\"#gpt\", \"#igpt\"] {\n let cache_key = format!(\"{}_{:?}_{}\", command, optional_group_id, user_id);\n if let Some(memory) = rw.get().primary::<Memory>(cache_key)? {\n rw.remove(memory)?;\n }\n }\n rw.commit()?;\n ctx.reply(\"GPT 消息历史已清除\").await?;\n Ok(true)\n },\n );\n plugin\n}\n\nasync fn call_deepseek_api(\n ctx: Context,\n command: &'static str,\n max_tokens: Option<i32>,\n reply_image: bool,\n) -> Result<bool> {\n let text = ctx\n .event\n .plain_text()\n .trim()\n .trim_start_matches(command)\n .trim()\n .to_owned();\n if text.is_empty() {\n return Ok(false);\n }\n ctx.set_reaction(Emoji::敬礼_1).await?;\n let (user_id, optional_group_id) = (ctx.event.user_id(), ctx.event.try_group_id().ok());\n let cache_key = format!(\"{}_{:?}_{}\", command, optional_group_id, user_id);\n let lock = LOCKS.entry(cache_key.clone()).or_default();\n let _guard = lock.lock().await;\n let r = database().r_transaction()?;\n let mut memory = r\n .get()\n .primary::<Memory>(cache_key.as_str())?\n .unwrap_or_else(|| Memory::new(cache_key.clone()));\n drop(r);\n memory.history.push_back(CachedMessage {\n sender: Some(ctx.event.sender().clone()),\n content: text,\n });\n let resp_text = async {\n let mut body = json!({\n \"model\": \"deepseek-chat\",\n \"messages\": memory.history.iter().map(CachedMessage::to_gpt_message).collect::<Vec<_>>(),\n \"stream\": false\n });\n if let Some(max_tokens) = max_tokens {\n body[\"max_tokens\"] = json!(max_tokens);\n }\n let resp = HTTP_CLIENT\n .post(\"https://api.deepseek.com/chat/completions\")\n .bearer_auth(DEEPSEEK_API_KEY.as_str())\n .json(&body)\n .send()\n .await?\n .error_for_status()?\n .json::<Value>()\n .await?;\n resp[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(ToOwned::to_owned)\n .ok_or_else(|| Error::msg(\"Invalid response\"))\n }\n .await;\n let (text, emoji, res) = match resp_text {\n Err(e) => (\"获取大模型回复失败,请稍后重试\".to_string(), Emoji::泪奔_1, Err(e)),\n Ok(resp_text) => (resp_text, Emoji::庆祝_1, Ok(true)),\n };\n memory.history.push_back(CachedMessage {\n sender: None,\n content: text.clone(),\n });\n while memory.history.len() > 10 {\n memory.history.pop_front();\n }\n let rw = database().rw_transaction()?;\n rw.upsert(memory)?;\n rw.commit()?;\n drop(_guard);\n let mut tempfile = TempFile::new().await?;\n let message = if reply_image {\n tempfile.write_all(&markdown::markdown_to_image(text).await?).await?;\n tempfile.flush().await?;\n MessageSegment::Image {\n /*\n 当前使用 /tmp 中转来进行 bot 与 onebot server 的文件传输,这要求 bot 与 onebot server 在同一台机器上,\n 且如果有容器等隔离环境,需要保证 /tmp 是共享的。更通用的做法是引入 base64 crates 通过 base64 传递图片。\n */\n file: format!(\"file://{}\", tempfile.file_path().to_string_lossy()),\n r#type: None,\n url: None,\n cache: Some(true),\n proxy: None,\n timeout: None,\n }\n } else {\n MessageSegment::Text { text }\n };\n ctx.set_reaction(emoji).await?;\n ctx.reply_content(vec![message]).await?;\n res\n}\n\nasync fn query_gpt_history(ctx: Context, command: &'static str, reply_image: bool) -> Result<bool> {\n let (user_id, optional_group_id) = (ctx.event.user_id(), ctx.event.try_group_id().ok());\n let cache_key = format!(\"{}_{:?}_{}\", command, optional_group_id, user_id);\n let r = database().r_transaction()?;\n let memory = r.get().primary::<Memory>(cache_key.as_str())?;\n drop(r);\n let command_name = command.trim_start_matches('#').to_uppercase();\n if let Some(memory) = memory {\n if memory.history.is_empty() {\n ctx.reply(format!(\"没有找到 {} 历史记录\", command_name)).await?;\n return Ok(true);\n }\n let mut messages = Vec::new();\n // 一般来说历史记录是一条用户一条 GPT,因此 len / 2 + 1 足够了\n let mut tempfiles = Vec::with_capacity(memory.history.len() / 2 + 1);\n for message in memory.history.iter() {\n match &message.sender {\n Some(sender) => {\n messages.push(MessageSegment::Node {\n id: None,\n user_id: sender.user_id.map(|id| id.to_string()),\n nickname: sender.nickname.clone(),\n content: Some(MessageContent::Text(message.content.clone())),\n });\n }\n None => {\n let message_user_id = Some(ctx.event.user_id().to_string());\n if reply_image {\n let mut tempfile = TempFile::new().await?;\n tempfile\n .write_all(&markdown::markdown_to_image(message.content.clone()).await?)\n .await?;\n tempfile.flush().await?;\n messages.push(MessageSegment::Node {\n id: None,\n user_id: message_user_id,\n nickname: Some(command_name.clone()),\n content: Some(MessageContent::Segment(vec![MessageSegment::Image {\n file: format!(\"file://{}\", tempfile.file_path().to_string_lossy()),\n r#type: None,\n url: None,\n cache: Some(true),\n proxy: Some(false),\n timeout: None,\n }])),\n });\n // 这里很关键,如果不将 tempfile 所有权转移出去,超出 scope 时会被 drop,临时文件被删除\n tempfiles.push(tempfile);\n } else {\n messages.push(MessageSegment::Node {\n id: None,\n user_id: message_user_id,\n nickname: Some(command_name.clone()),\n content: Some(MessageContent::Text(message.content.clone())),\n });\n }\n }\n }\n }\n ctx.send_forward_segment(messages).await?;\n } else {\n ctx.reply(format!(\"没有找到 {} 历史记录\", command_name)).await?;\n }\n Ok(true)\n}\n"}}}}