diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7aa3ed2..c9f14cf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(make check)", "Bash(make test)", "Bash(cargo check)", - "Bash(cargo clean)" + "Bash(cargo clean)", + "Bash(git commit --no-gpg-sign -m \"fix\\(persona\\): 设置人设时同步更新 Bot 显示名称\n\n在 handle_set 中添加更新 Bot 显示名称的逻辑,与 handle_off 中的行为保持一致。\n设置人设后会移除旧的人设后缀并添加新的,格式为 \"\"基础名称 \\(人设名称\\)\"\"。\")" ] } } diff --git a/.env.example b/.env.example index 1ed1ef9..621e3f5 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,12 @@ OPENAI_MODEL=gpt-4o-mini # MCP_ENABLED=true # MCP_BUILTIN_TOOLS_ENABLED=true +# Meme 梗图配置 (可选) +# KLIPY_API_KEY=your_tenor_api_key # KLIPY GIF API Key,从 https://partner.klipy.com 获取(推荐) +# TENOR_API_KEY=your_tenor_api_key # Tenor API Key(已弃用,仅向后兼容) +# MEME_ENABLED=true +# MEME_LIMIT=8 # 每次搜索返回的结果数量 + # WebFetch 内置工具配置 # MCP_BUILTIN_WEB_FETCH_ENABLED=true # MCP_BUILTIN_WEB_FETCH_MAX_LENGTH=10000 # 最大内容长度(字符) diff --git a/Cargo.lock b/Cargo.lock index 1a95771..98f1f48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "urlencoding", "wiremock", ] diff --git a/Cargo.toml b/Cargo.toml index 1edbe77..2fce100 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ rusqlite = { version = "0.37", features = ["bundled"] } chrono = { version = "0.4", features = ["serde"] } dashmap = "6" rand = "0.10" +urlencoding = "2.1" # MCP dependencies schemars = { version = "1.2", features = ["derive"] } diff --git a/src/config.rs b/src/config.rs index 05594a5..f98ef16 100644 --- a/src/config.rs +++ b/src/config.rs @@ -98,6 +98,10 @@ pub struct Config { /// MCP 配置 #[serde(default)] pub mcp: crate::mcp::McpConfig, + + /// Meme 梗图配置 + #[serde(default)] + pub meme: MemeConfig, } /// Matrix 连接配置 @@ -249,6 +253,24 @@ pub struct LogConfig { pub level: String, } +/// Meme 梗图配置 +#[derive(Debug, Clone, Deserialize)] +pub struct MemeConfig { + /// 是否启用梗图功能。 + #[serde(default = "default_meme_enabled")] + pub enabled: bool, + + /// KLIPY API Key。 + /// + /// 从 https://partner.klipy.com 获取。 + /// 也支持旧的 TENOR_API_KEY 环境变量(向后兼容)。 + pub api_key: Option, + + /// 每次搜索返回的结果数量。 + #[serde(default = "default_meme_limit")] + pub limit: u32, +} + // 默认值函数 fn default_device_display_name() -> String { "AI Bot".to_string() @@ -289,6 +311,12 @@ fn default_vision_max_image_size() -> u32 { fn default_log_level() -> String { "info".to_string() } +fn default_meme_enabled() -> bool { + true +} +fn default_meme_limit() -> u32 { + 8 +} impl Default for MatrixConfig { fn default() -> Self { @@ -353,6 +381,16 @@ impl Default for LogConfig { } } +impl Default for MemeConfig { + fn default() -> Self { + Self { + enabled: default_meme_enabled(), + api_key: None, + limit: default_meme_limit(), + } + } +} + impl Config { /// 从配置文件和环境变量加载配置。 /// @@ -504,6 +542,29 @@ impl Config { // MCP 配置 self.mcp.apply_env_overrides(); + + // Meme 配置 + if let Ok(v) = std::env::var("MEME_ENABLED") { + self.meme.enabled = v.to_lowercase() != "false"; + } + if let Ok(v) = std::env::var("KLIPY_API_KEY") { + self.meme.api_key = Some(v); + } + // 同时支持旧的 TENOR_API_KEY 环境变量(向后兼容) + if self.meme.api_key.is_none() { + if let Ok(v) = std::env::var("TENOR_API_KEY") { + self.meme.api_key = Some(v); + } + } + if let Ok(v) = std::env::var("MEME_LIMIT") + && let Ok(n) = v.parse() + { + self.meme.limit = n; + } + // 如果设置了 KLIPY_API_KEY,自动启用 meme 功能 + if self.meme.api_key.is_some() { + self.meme.enabled = true; + } } /// 验证必需字段 diff --git a/src/event_handler.rs b/src/event_handler.rs index 7d35e7e..9f60575 100644 --- a/src/event_handler.rs +++ b/src/event_handler.rs @@ -70,6 +70,7 @@ use crate::config::Config; use crate::media::download_image_as_base64; use crate::modules::admin::{BotInfoHandler, BotLeaveHandler, BotPingHandler}; use crate::modules::mcp::McpHandler; +use crate::modules::meme::{KlipyClient, MemeHandler}; use crate::modules::muyu::{ BagHandler, MeritHandler, MuyuHandler, MuyuStore, RankHandler, TitleHandler, }; @@ -297,6 +298,15 @@ impl EventHandler { command_gateway.register(Arc::new(BagHandler::new(store.clone()))); } + // 注册 Meme 梗图命令 + if config.meme.enabled { + let klipy = config.meme.api_key.clone().map(|key| { + KlipyClient::new(key, config.meme.limit) + }); + command_gateway.register(Arc::new(MemeHandler::new(klipy))); + info!("Meme 命令已注册,可用命令: !meme <关键词>"); + } + Self { ai_service, client, @@ -925,6 +935,7 @@ mod tests { }, proxy: None, mcp: crate::mcp::McpConfig::default(), + meme: crate::config::MemeConfig::default(), }; let bot_user_id = user_id!("@bot:matrix.org").to_owned(); let rt = tokio::runtime::Runtime::new().unwrap(); diff --git a/src/modules/meme/handlers.rs b/src/modules/meme/handlers.rs new file mode 100644 index 0000000..cd68ae8 --- /dev/null +++ b/src/modules/meme/handlers.rs @@ -0,0 +1,130 @@ +//! Meme 命令处理器。 + +use anyhow::Result; +use async_trait::async_trait; +use matrix_sdk::attachment::AttachmentConfig; + +use crate::command::{CommandContext, CommandHandler, Permission}; +use crate::modules::meme::klipy::KlipyClient; +use crate::ui::{error, info_card}; + +/// Meme 梗图命令处理器。 +/// +/// 使用 KLIPY GIF API 搜索并发送梗图。 +/// +/// # 命令 +/// +/// `!meme <关键词>` - 搜索并发送一张匹配的 GIF +/// +/// # 权限 +/// +/// 任何房间成员都可以执行。 +pub struct MemeHandler { + klipy: Option, +} + +impl MemeHandler { + /// 创建新的 Meme 命令处理器。 + pub fn new(klipy: Option) -> Self { + Self { klipy } + } +} + +#[async_trait] +impl CommandHandler for MemeHandler { + fn name(&self) -> &str { + "meme" + } + + fn description(&self) -> &str { + "搜索并发送梗图" + } + + fn usage(&self) -> &str { + "meme <关键词>" + } + + fn permission(&self) -> Permission { + Permission::Anyone + } + + async fn execute(&self, ctx: &CommandContext<'_>) -> Result<()> { + // !meme 命令没有子命令结构,所有参数都是搜索关键词 + let query: String = ctx.args.join(" "); + if query.is_empty() { + let html = info_card("Meme 命令", &[("!meme <关键词>", "搜索并发送梗图")]); + return send_html(&ctx.room, &html).await; + } + + // 检查是否配置了 KLIPY API Key + let klipy = match &self.klipy { + Some(k) => k, + None => { + let html = error("梗图功能未配置。请在 .env 中设置 KLIPY_API_KEY\n注册地址: https://partner.klipy.com"); + return send_html(&ctx.room, &html).await; + } + }; + + // 搜索 GIF + let gif_result = match klipy.search(&query).await { + Ok(Some(result)) => result, + Ok(None) => { + let html = error(&format!("没有找到匹配「{}」的梗图", query)); + return send_html(&ctx.room, &html).await; + } + Err(e) => { + tracing::error!("KLIPY API 错误: {}", e); + let html = error(&format!("搜索梗图失败: {}", e)); + return send_html(&ctx.room, &html).await; + } + }; + + // 下载 GIF + let http_client = reqwest::Client::new(); + let response = match http_client.get(&gif_result.url).send().await { + Ok(r) => r, + Err(e) => { + let html = error(&format!("下载梗图失败: {}", e)); + return send_html(&ctx.room, &html).await; + } + }; + + let bytes = match response.bytes().await { + Ok(b) => b, + Err(e) => { + let html = error(&format!("读取梗图数据失败: {}", e)); + return send_html(&ctx.room, &html).await; + } + }; + + // 使用 send_attachment 发送图片(自动处理图片信息和缩略图) + let mime_type: mime::Mime = "image/gif".parse()?; + let filename = format!("{}.gif", query); + let config = AttachmentConfig::new(); + + match ctx.room.send_attachment(&filename, &mime_type, bytes.to_vec(), config).await { + Ok(_) => { + tracing::info!("已发送梗图: {}", query); + } + Err(e) => { + let html = error(&format!("发送梗图失败: {}", e)); + return send_html(&ctx.room, &html).await; + } + } + + Ok(()) + } +} + +/// 发送 HTML 消息 +async fn send_html(room: &matrix_sdk::Room, html: &str) -> Result<()> { + let plain_text = html + .replace(|c: char| !c.is_ascii_alphanumeric() && c != ' ', "") + .chars() + .take(100) + .collect::(); + + let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_html(plain_text, html); + room.send(content).await?; + Ok(()) +} \ No newline at end of file diff --git a/src/modules/meme/klipy.rs b/src/modules/meme/klipy.rs new file mode 100644 index 0000000..cef84ee --- /dev/null +++ b/src/modules/meme/klipy.rs @@ -0,0 +1,139 @@ +//! KLIPY GIF API 客户端。 +//! +//! KLIPY 是 Tenor API 的替代方案,提供 GIF、Sticker、Meme 等内容搜索。 +//! 注册 API Key: https://partner.klipy.com + +use anyhow::{Context, Result}; +use serde::Deserialize; +use tracing::{debug, warn}; + +/// KLIPY API 客户端。 +/// +/// 用于搜索 GIF 图片。 +pub struct KlipyClient { + api_key: String, + limit: u32, + http_client: reqwest::Client, +} + +/// KLIPY 搜索响应(与 Tenor v2 兼容格式)。 +#[derive(Debug, Deserialize)] +struct KlipySearchResponse { + results: Vec, + #[serde(default)] + #[allow(dead_code)] + next: Option, +} + +#[derive(Debug, Deserialize)] +struct KlipyResult { + id: String, + media_formats: MediaFormats, +} + +#[derive(Debug, Deserialize)] +struct MediaFormats { + #[serde(default)] + gif: Option, + #[serde(default)] + tinygif: Option, + #[serde(default)] + mediumgif: Option, + #[serde(default)] + nanogif: Option, +} + +#[derive(Debug, Deserialize)] +struct MediaFormat { + url: String, +} + +/// GIF 搜索结果。 +#[derive(Debug, Clone)] +pub struct GifResult { + pub url: String, +} + +impl KlipyClient { + /// 创建新的 KLIPY 客户端。 + pub fn new(api_key: String, limit: u32) -> Self { + Self { + api_key, + limit, + http_client: reqwest::Client::new(), + } + } + + /// 搜索 GIF。 + /// + /// # Arguments + /// + /// * `query` - 搜索关键词 + /// + /// # Returns + /// + /// 返回随机一个 GIF 结果,如果没有结果则返回 None。 + pub async fn search(&self, query: &str) -> Result> { + let encoded_query = urlencoding::encode(query); + // KLIPY Tenor 兼容 API endpoint + let url = format!( + "https://api.klipy.com/v2/search?q={}&key={}&limit={}&media_filter=gif,tinygif,mediumgif,nanogif", + encoded_query, self.api_key, self.limit + ); + + debug!("KLIPY API 请求: {}", url.replace(&self.api_key, "API_KEY")); + + let response = self + .http_client + .get(&url) + .send() + .await + .context("KLIPY API 请求失败")?; + + let status = response.status(); + let body = response.text().await.context("读取响应失败")?; + + debug!("KLIPY API 响应状态: {}", status); + debug!("KLIPY API 响应内容: {}", body.chars().take(500).collect::()); + + if !status.is_success() { + anyhow::bail!("KLIPY API 返回错误: {} - {}", status, body); + } + + // 尝试解析响应 + let search_response: KlipySearchResponse = serde_json::from_str(&body) + .with_context(|| format!("解析 KLIPY API 响应失败,响应内容: {}", body.chars().take(200).collect::()))?; + + if search_response.results.is_empty() { + warn!("KLIPY API 返回空结果,查询: {}", query); + return Ok(None); + } + + debug!("KLIPY API 返回 {} 个结果", search_response.results.len()); + + // 随机选择一个结果 + use rand::prelude::IndexedRandom; + let result = search_response + .results + .choose(&mut rand::rng()) + .context("没有可用的 GIF 结果")?; + + debug!("选中的 GIF ID: {}", result.id); + + // 优先使用 tinygif(更小的文件),然后是 nanogif、mediumgif,最后是 gif + let url = result + .media_formats + .tinygif + .as_ref() + .or(result.media_formats.nanogif.as_ref()) + .or(result.media_formats.mediumgif.as_ref()) + .or(result.media_formats.gif.as_ref()) + .map(|m| m.url.as_str()) + .context("GIF 没有可用的媒体格式")? + .to_string(); + + debug!("GIF URL: {}", url); + + Ok(Some(GifResult { url })) + } +} \ No newline at end of file diff --git a/src/modules/meme/mod.rs b/src/modules/meme/mod.rs new file mode 100644 index 0000000..bec5fc1 --- /dev/null +++ b/src/modules/meme/mod.rs @@ -0,0 +1,9 @@ +//! Meme 梗图模块。 +//! +//! 提供 `!meme` 命令,使用 KLIPY GIF API 搜索并发送梗图。 + +mod handlers; +mod klipy; + +pub use handlers::MemeHandler; +pub use klipy::KlipyClient; \ No newline at end of file diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 7dcef0a..90d4eb2 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -2,5 +2,6 @@ pub mod admin; pub mod mcp; +pub mod meme; pub mod muyu; pub mod persona; diff --git a/src/modules/persona/handlers.rs b/src/modules/persona/handlers.rs index a45d461..889209e 100644 --- a/src/modules/persona/handlers.rs +++ b/src/modules/persona/handlers.rs @@ -246,6 +246,26 @@ impl PersonaHandler { self.store .set_room_persona(&room_id, &persona_id, &set_by)?; + // 更新 Bot 的显示名称:添加人设后缀 + let account = ctx.client.account(); + let current_name = account + .get_display_name() + .await + .ok() + .flatten() + .unwrap_or_else(|| "Aether".to_string()); + + // 移除旧的人设后缀(如果存在),然后添加新的 + let base_name = current_name + .find(" (") + .map(|pos| current_name[..pos].to_string()) + .unwrap_or(current_name); + + let new_name = format!("{} ({})", base_name, persona.name); + if let Err(e) = account.set_display_name(Some(&new_name)).await { + tracing::warn!("更新显示名称失败: {}", e); + } + let emoji = persona.avatar_emoji.as_deref().unwrap_or(""); let html = success(&format!("已设置人设: {} {}", emoji, persona.name)); send_html(&ctx.room, &html).await diff --git a/tests/bot_integration.rs b/tests/bot_integration.rs index 853338c..d1672cd 100644 --- a/tests/bot_integration.rs +++ b/tests/bot_integration.rs @@ -80,6 +80,7 @@ mod config_tests { }, proxy: None, mcp: aether_matrix::mcp::McpConfig::default(), + meme: aether_matrix::config::MemeConfig::default(), }; assert_eq!(config.matrix.homeserver, "https://custom.server"); @@ -136,6 +137,7 @@ mod bot_tests { }, proxy: None, mcp: aether_matrix::mcp::McpConfig::default(), + meme: aether_matrix::config::MemeConfig::default(), } } diff --git a/tests/event_handler_integration.rs b/tests/event_handler_integration.rs index ef83aae..0823ede 100644 --- a/tests/event_handler_integration.rs +++ b/tests/event_handler_integration.rs @@ -159,6 +159,7 @@ fn create_test_config() -> Config { }, proxy: None, mcp: aether_matrix::mcp::McpConfig::default(), + meme: aether_matrix::config::MemeConfig::default(), } }