From d611a780750357ebc16b0252c260ca9397dffd62 Mon Sep 17 00:00:00 2001 From: meyou Date: Tue, 10 Mar 2026 21:42:20 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(persona):=20=E8=AE=BE=E7=BD=AE=E4=BA=BA?= =?UTF-8?q?=E8=AE=BE=E6=97=B6=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0=20Bot=20?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 handle_set 中添加更新 Bot 显示名称的逻辑,与 handle_off 中的行为保持一致。 设置人设后会移除旧的人设后缀并添加新的,格式为 "基础名称 (人设名称)"。 --- src/modules/persona/handlers.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 From d089cc86c1e23f39b408202ae474e1d8f2ad1f51 Mon Sep 17 00:00:00 2001 From: meyou Date: Tue, 10 Mar 2026 22:22:45 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(meme):=20=E5=AE=9E=E7=8E=B0=20!meme=20?= =?UTF-8?q?=E6=A2=97=E5=9B=BE=E6=90=9C=E7=B4=A2=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 MemeConfig 配置项(TENOR_API_KEY, MEME_ENABLED, MEME_LIMIT) - 创建 meme 模块和 TenorClient API 客户端 - 实现 MemeHandler 命令处理器,支持 !meme <关键词> 搜索 GIF - 搜索结果随机选择并上传到 Matrix media server 发送 - 更新测试文件以支持新的配置项 --- .env.example | 5 ++ Cargo.lock | 1 + Cargo.toml | 1 + src/config.rs | 54 ++++++++++++ src/event_handler.rs | 11 +++ src/modules/meme/handlers.rs | 128 +++++++++++++++++++++++++++++ src/modules/meme/mod.rs | 9 ++ src/modules/meme/tenor.rs | 112 +++++++++++++++++++++++++ src/modules/mod.rs | 1 + tests/bot_integration.rs | 2 + tests/event_handler_integration.rs | 1 + 11 files changed, 325 insertions(+) create mode 100644 src/modules/meme/handlers.rs create mode 100644 src/modules/meme/mod.rs create mode 100644 src/modules/meme/tenor.rs diff --git a/.env.example b/.env.example index 1ed1ef9..1304faa 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,11 @@ OPENAI_MODEL=gpt-4o-mini # MCP_ENABLED=true # MCP_BUILTIN_TOOLS_ENABLED=true +# Meme 梗图配置 (可选) +# TENOR_API_KEY=your_tenor_api_key # Tenor GIF API Key,从 https://developers.google.com/tenor/guides/quickstart 获取 +# 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..8c444a1 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,23 @@ pub struct LogConfig { pub level: String, } +/// Meme 梗图配置 +#[derive(Debug, Clone, Deserialize)] +pub struct MemeConfig { + /// 是否启用梗图功能。 + #[serde(default = "default_meme_enabled")] + pub enabled: bool, + + /// Tenor API Key。 + /// + /// 从 https://developers.google.com/tenor/guides/quickstart 获取 + pub api_key: Option, + + /// 每次搜索返回的结果数量。 + #[serde(default = "default_meme_limit")] + pub limit: u32, +} + // 默认值函数 fn default_device_display_name() -> String { "AI Bot".to_string() @@ -289,6 +310,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 +380,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 +541,23 @@ 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("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; + } + // 如果设置了 TENOR_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..ec63bf1 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::{MemeHandler, TenorClient}; 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 { + if let Some(ref api_key) = config.meme.api_key { + let tenor = TenorClient::new(api_key.clone(), config.meme.limit); + command_gateway.register(Arc::new(MemeHandler::new(tenor))); + 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..35e8695 --- /dev/null +++ b/src/modules/meme/handlers.rs @@ -0,0 +1,128 @@ +//! Meme 命令处理器。 + +use anyhow::Result; +use async_trait::async_trait; +use matrix_sdk::ruma::events::room::message::{ImageMessageEventContent, MessageType, RoomMessageEventContent}; +use matrix_sdk::ruma::MxcUri; + +use crate::command::{CommandContext, CommandHandler, Permission}; +use crate::modules::meme::tenor::TenorClient; +use crate::ui::{error, info_card}; + +/// Meme 梗图命令处理器。 +/// +/// 使用 Tenor GIF API 搜索并发送梗图。 +/// +/// # 命令 +/// +/// `!meme <关键词>` - 搜索并发送一张匹配的 GIF +/// +/// # 权限 +/// +/// 任何房间成员都可以执行。 +pub struct MemeHandler { + tenor: TenorClient, +} + +impl MemeHandler { + /// 创建新的 Meme 命令处理器。 + pub fn new(tenor: TenorClient) -> Self { + Self { tenor } + } +} + +#[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<()> { + let query: String = ctx.sub_args().join(" "); + if query.is_empty() { + let html = info_card("Meme 命令", &[("!meme <关键词>", "搜索并发送梗图")]); + return send_html(&ctx.room, &html).await; + } + + // 搜索 GIF + let gif_result = match self.tenor.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!("Tenor 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; + } + }; + + // 上传到 Matrix media server + let media = ctx.client.media(); + let mime_type: mime::Mime = "image/gif".parse()?; + let upload_response = match media.upload(&mime_type, bytes.to_vec(), None).await { + Ok(r) => r, + Err(e) => { + let html = error(&format!("上传梗图失败: {}", e)); + return send_html(&ctx.room, &html).await; + } + }; + + // 发送图片消息 + let mxc_uri: &MxcUri = &upload_response.content_uri; + let image_content = ImageMessageEventContent::plain( + query.clone(), + mxc_uri.to_owned().into(), + ); + + let message = RoomMessageEventContent::new(MessageType::Image(image_content)); + ctx.room.send(message).await?; + + tracing::info!("已发送梗图: {}", query); + 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 = RoomMessageEventContent::text_html(plain_text, html); + room.send(content).await?; + Ok(()) +} \ 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..f70d97a --- /dev/null +++ b/src/modules/meme/mod.rs @@ -0,0 +1,9 @@ +//! Meme 梗图模块。 +//! +//! 提供 `!meme` 命令,使用 Tenor GIF API 搜索并发送梗图。 + +mod handlers; +mod tenor; + +pub use handlers::MemeHandler; +pub use tenor::TenorClient; \ No newline at end of file diff --git a/src/modules/meme/tenor.rs b/src/modules/meme/tenor.rs new file mode 100644 index 0000000..0f993fb --- /dev/null +++ b/src/modules/meme/tenor.rs @@ -0,0 +1,112 @@ +//! Tenor GIF API 客户端。 + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// Tenor API 客户端。 +/// +/// 用于搜索 GIF 图片。 +pub struct TenorClient { + api_key: String, + limit: u32, + http_client: reqwest::Client, +} + +/// Tenor 搜索响应。 +#[derive(Debug, Deserialize)] +struct TenorSearchResponse { + results: Vec, +} + +#[derive(Debug, Deserialize)] +struct TenorResult { + media_formats: MediaFormats, +} + +#[derive(Debug, Deserialize)] +struct MediaFormats { + gif: MediaFormat, + #[serde(default)] + tinygif: Option, +} + +#[derive(Debug, Deserialize)] +struct MediaFormat { + url: String, +} + +/// GIF 搜索结果。 +#[derive(Debug, Clone)] +pub struct GifResult { + pub url: String, +} + +impl TenorClient { + /// 创建新的 Tenor 客户端。 + 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); + let url = format!( + "https://tenor.googleapis.com/v2/search?q={}&key={}&limit={}&random=true&media_filter=gif,tinygif", + encoded_query, self.api_key, self.limit + ); + + tracing::debug!("Tenor API 请求: {}", url.replace(&self.api_key, "API_KEY")); + + let response = self + .http_client + .get(&url) + .send() + .await + .context("Tenor API 请求失败")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Tenor API 返回错误: {} - {}", status, body); + } + + let search_response: TenorSearchResponse = response + .json() + .await + .context("解析 Tenor API 响应失败")?; + + if search_response.results.is_empty() { + return Ok(None); + } + + // 随机选择一个结果 + use rand::prelude::IndexedRandom; + let result = search_response + .results + .choose(&mut rand::rng()) + .context("没有可用的 GIF 结果")?; + + // 优先使用 tinygif(更小的文件),如果没有则使用普通 gif + let url = result + .media_formats + .tinygif + .as_ref() + .map(|t| t.url.as_str()) + .unwrap_or(&result.media_formats.gif.url) + .to_string(); + + Ok(Some(GifResult { url })) + } +} \ 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/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(), } } From 751eb6ec7db814cefbf7cc0ed2ca9b3c8f529456 Mon Sep 17 00:00:00 2001 From: meyou Date: Tue, 10 Mar 2026 22:31:16 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(meme):=20=E6=9C=AA=E9=85=8D=E7=BD=AE=20?= =?UTF-8?q?API=20Key=20=E6=97=B6=E8=BF=94=E5=9B=9E=E5=8F=8B=E5=A5=BD?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 MemeHandler 接受 Option - 在未配置 TENOR_API_KEY 时返回提示信息而非不注册命令 - 用户可以看到明确的配置指引 --- src/event_handler.rs | 10 +++++----- src/modules/meme/handlers.rs | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/event_handler.rs b/src/event_handler.rs index ec63bf1..8441899 100644 --- a/src/event_handler.rs +++ b/src/event_handler.rs @@ -300,11 +300,11 @@ impl EventHandler { // 注册 Meme 梗图命令 if config.meme.enabled { - if let Some(ref api_key) = config.meme.api_key { - let tenor = TenorClient::new(api_key.clone(), config.meme.limit); - command_gateway.register(Arc::new(MemeHandler::new(tenor))); - info!("Meme 命令已注册,可用命令: !meme <关键词>"); - } + let tenor = config.meme.api_key.clone().map(|key| { + TenorClient::new(key, config.meme.limit) + }); + command_gateway.register(Arc::new(MemeHandler::new(tenor))); + info!("Meme 命令已注册,可用命令: !meme <关键词>"); } Self { diff --git a/src/modules/meme/handlers.rs b/src/modules/meme/handlers.rs index 35e8695..df21a4d 100644 --- a/src/modules/meme/handlers.rs +++ b/src/modules/meme/handlers.rs @@ -21,12 +21,12 @@ use crate::ui::{error, info_card}; /// /// 任何房间成员都可以执行。 pub struct MemeHandler { - tenor: TenorClient, + tenor: Option, } impl MemeHandler { /// 创建新的 Meme 命令处理器。 - pub fn new(tenor: TenorClient) -> Self { + pub fn new(tenor: Option) -> Self { Self { tenor } } } @@ -56,8 +56,17 @@ impl CommandHandler for MemeHandler { return send_html(&ctx.room, &html).await; } + // 检查是否配置了 Tenor API Key + let tenor = match &self.tenor { + Some(t) => t, + None => { + let html = error("梗图功能未配置。请在 .env 中设置 TENOR_API_KEY"); + return send_html(&ctx.room, &html).await; + } + }; + // 搜索 GIF - let gif_result = match self.tenor.search(&query).await { + let gif_result = match tenor.search(&query).await { Ok(Some(result)) => result, Ok(None) => { let html = error(&format!("没有找到匹配「{}」的梗图", query)); From 28d8ec13826cac2604bfc2ff7dfa0da4aaee3417 Mon Sep 17 00:00:00 2001 From: meyou Date: Tue, 10 Mar 2026 22:48:19 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(meme):=20=E4=BC=98=E5=8C=96=20Tenor=20?= =?UTF-8?q?API=20GIF=20=E6=90=9C=E7=B4=A2=E5=8F=8A=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加对 mediumgif 媒体格式的支持,提升搜索结果丰富度 - 完善响应的状态码及内容的调试日志,便于排查问题 - 改进错误处理机制,解析响应时附带部分内容以辅助定位错误 - 添加查询返回空结果的警告日志 - 优先选择 tinygif,其次 mediumgif,最后 gif 作为返回的 GIF 链接 - 增加对字段的默认值与可选项处理,提升解析健壮性 --- .claude/settings.local.json | 3 ++- src/modules/meme/tenor.rs | 45 ++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 14 deletions(-) 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/src/modules/meme/tenor.rs b/src/modules/meme/tenor.rs index 0f993fb..7780bc6 100644 --- a/src/modules/meme/tenor.rs +++ b/src/modules/meme/tenor.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use serde::Deserialize; +use tracing::{debug, warn}; /// Tenor API 客户端。 /// @@ -16,18 +17,24 @@ pub struct TenorClient { #[derive(Debug, Deserialize)] struct TenorSearchResponse { results: Vec, + #[serde(default)] + next: Option, } #[derive(Debug, Deserialize)] struct TenorResult { + id: String, media_formats: MediaFormats, } #[derive(Debug, Deserialize)] struct MediaFormats { - gif: MediaFormat, + #[serde(default)] + gif: Option, #[serde(default)] tinygif: Option, + #[serde(default)] + mediumgif: Option, } #[derive(Debug, Deserialize)] @@ -63,11 +70,11 @@ impl TenorClient { pub async fn search(&self, query: &str) -> Result> { let encoded_query = urlencoding::encode(query); let url = format!( - "https://tenor.googleapis.com/v2/search?q={}&key={}&limit={}&random=true&media_filter=gif,tinygif", + "https://tenor.googleapis.com/v2/search?q={}&key={}&limit={}&media_filter=gif,tinygif,mediumgif", encoded_query, self.api_key, self.limit ); - tracing::debug!("Tenor API 请求: {}", url.replace(&self.api_key, "API_KEY")); + debug!("Tenor API 请求: {}", url.replace(&self.api_key, "API_KEY")); let response = self .http_client @@ -76,21 +83,27 @@ impl TenorClient { .await .context("Tenor API 请求失败")?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let status = response.status(); + let body = response.text().await.context("读取响应失败")?; + + debug!("Tenor API 响应状态: {}", status); + debug!("Tenor API 响应内容: {}", body.chars().take(500).collect::()); + + if !status.is_success() { anyhow::bail!("Tenor API 返回错误: {} - {}", status, body); } - let search_response: TenorSearchResponse = response - .json() - .await - .context("解析 Tenor API 响应失败")?; + // 尝试解析响应 + let search_response: TenorSearchResponse = serde_json::from_str(&body) + .with_context(|| format!("解析 Tenor API 响应失败,响应内容: {}", body.chars().take(200).collect::()))?; if search_response.results.is_empty() { + warn!("Tenor API 返回空结果,查询: {}", query); return Ok(None); } + debug!("Tenor API 返回 {} 个结果", search_response.results.len()); + // 随机选择一个结果 use rand::prelude::IndexedRandom; let result = search_response @@ -98,15 +111,21 @@ impl TenorClient { .choose(&mut rand::rng()) .context("没有可用的 GIF 结果")?; - // 优先使用 tinygif(更小的文件),如果没有则使用普通 gif + debug!("选中的 GIF ID: {}", result.id); + + // 优先使用 tinygif(更小的文件),然后是 mediumgif,最后是 gif let url = result .media_formats .tinygif .as_ref() - .map(|t| t.url.as_str()) - .unwrap_or(&result.media_formats.gif.url) + .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 From 6fd275586e259132a5095c651e3ee3dcb73406e0 Mon Sep 17 00:00:00 2001 From: meyou Date: Wed, 11 Mar 2026 13:43:28 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(meme):=20=E8=BF=81=E7=A7=BB=E5=88=B0=20?= =?UTF-8?q?KLIPY=20API=20=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Tenor API 替换为 KLIPY API(Tenor 已关闭注册) - 新增 KLIPY_API_KEY 环境变量,保留 TENOR_API_KEY 向后兼容 - 修复命令参数解析:使用 args 替代 sub_args - 修复图片显示为附件:使用 send_attachment API 自动处理图片信息 --- .env.example | 3 +- src/config.rs | 15 +++++-- src/event_handler.rs | 8 ++-- src/modules/meme/handlers.rs | 57 +++++++++++-------------- src/modules/meme/{tenor.rs => klipy.rs} | 48 ++++++++++++--------- src/modules/meme/mod.rs | 6 +-- 6 files changed, 73 insertions(+), 64 deletions(-) rename src/modules/meme/{tenor.rs => klipy.rs} (67%) diff --git a/.env.example b/.env.example index 1304faa..621e3f5 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,8 @@ OPENAI_MODEL=gpt-4o-mini # MCP_BUILTIN_TOOLS_ENABLED=true # Meme 梗图配置 (可选) -# TENOR_API_KEY=your_tenor_api_key # Tenor GIF API Key,从 https://developers.google.com/tenor/guides/quickstart 获取 +# 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 # 每次搜索返回的结果数量 diff --git a/src/config.rs b/src/config.rs index 8c444a1..f98ef16 100644 --- a/src/config.rs +++ b/src/config.rs @@ -260,9 +260,10 @@ pub struct MemeConfig { #[serde(default = "default_meme_enabled")] pub enabled: bool, - /// Tenor API Key。 + /// KLIPY API Key。 /// - /// 从 https://developers.google.com/tenor/guides/quickstart 获取 + /// 从 https://partner.klipy.com 获取。 + /// 也支持旧的 TENOR_API_KEY 环境变量(向后兼容)。 pub api_key: Option, /// 每次搜索返回的结果数量。 @@ -546,15 +547,21 @@ impl Config { if let Ok(v) = std::env::var("MEME_ENABLED") { self.meme.enabled = v.to_lowercase() != "false"; } - if let Ok(v) = std::env::var("TENOR_API_KEY") { + 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; } - // 如果设置了 TENOR_API_KEY,自动启用 meme 功能 + // 如果设置了 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 8441899..9f60575 100644 --- a/src/event_handler.rs +++ b/src/event_handler.rs @@ -70,7 +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::{MemeHandler, TenorClient}; +use crate::modules::meme::{KlipyClient, MemeHandler}; use crate::modules::muyu::{ BagHandler, MeritHandler, MuyuHandler, MuyuStore, RankHandler, TitleHandler, }; @@ -300,10 +300,10 @@ impl EventHandler { // 注册 Meme 梗图命令 if config.meme.enabled { - let tenor = config.meme.api_key.clone().map(|key| { - TenorClient::new(key, config.meme.limit) + let klipy = config.meme.api_key.clone().map(|key| { + KlipyClient::new(key, config.meme.limit) }); - command_gateway.register(Arc::new(MemeHandler::new(tenor))); + command_gateway.register(Arc::new(MemeHandler::new(klipy))); info!("Meme 命令已注册,可用命令: !meme <关键词>"); } diff --git a/src/modules/meme/handlers.rs b/src/modules/meme/handlers.rs index df21a4d..cd68ae8 100644 --- a/src/modules/meme/handlers.rs +++ b/src/modules/meme/handlers.rs @@ -2,16 +2,15 @@ use anyhow::Result; use async_trait::async_trait; -use matrix_sdk::ruma::events::room::message::{ImageMessageEventContent, MessageType, RoomMessageEventContent}; -use matrix_sdk::ruma::MxcUri; +use matrix_sdk::attachment::AttachmentConfig; use crate::command::{CommandContext, CommandHandler, Permission}; -use crate::modules::meme::tenor::TenorClient; +use crate::modules::meme::klipy::KlipyClient; use crate::ui::{error, info_card}; /// Meme 梗图命令处理器。 /// -/// 使用 Tenor GIF API 搜索并发送梗图。 +/// 使用 KLIPY GIF API 搜索并发送梗图。 /// /// # 命令 /// @@ -21,13 +20,13 @@ use crate::ui::{error, info_card}; /// /// 任何房间成员都可以执行。 pub struct MemeHandler { - tenor: Option, + klipy: Option, } impl MemeHandler { /// 创建新的 Meme 命令处理器。 - pub fn new(tenor: Option) -> Self { - Self { tenor } + pub fn new(klipy: Option) -> Self { + Self { klipy } } } @@ -50,30 +49,31 @@ impl CommandHandler for MemeHandler { } async fn execute(&self, ctx: &CommandContext<'_>) -> Result<()> { - let query: String = ctx.sub_args().join(" "); + // !meme 命令没有子命令结构,所有参数都是搜索关键词 + let query: String = ctx.args.join(" "); if query.is_empty() { let html = info_card("Meme 命令", &[("!meme <关键词>", "搜索并发送梗图")]); return send_html(&ctx.room, &html).await; } - // 检查是否配置了 Tenor API Key - let tenor = match &self.tenor { - Some(t) => t, + // 检查是否配置了 KLIPY API Key + let klipy = match &self.klipy { + Some(k) => k, None => { - let html = error("梗图功能未配置。请在 .env 中设置 TENOR_API_KEY"); + let html = error("梗图功能未配置。请在 .env 中设置 KLIPY_API_KEY\n注册地址: https://partner.klipy.com"); return send_html(&ctx.room, &html).await; } }; // 搜索 GIF - let gif_result = match tenor.search(&query).await { + 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!("Tenor API 错误: {}", e); + tracing::error!("KLIPY API 错误: {}", e); let html = error(&format!("搜索梗图失败: {}", e)); return send_html(&ctx.room, &html).await; } @@ -97,28 +97,21 @@ impl CommandHandler for MemeHandler { } }; - // 上传到 Matrix media server - let media = ctx.client.media(); + // 使用 send_attachment 发送图片(自动处理图片信息和缩略图) let mime_type: mime::Mime = "image/gif".parse()?; - let upload_response = match media.upload(&mime_type, bytes.to_vec(), None).await { - Ok(r) => r, + 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)); + let html = error(&format!("发送梗图失败: {}", e)); return send_html(&ctx.room, &html).await; } - }; - - // 发送图片消息 - let mxc_uri: &MxcUri = &upload_response.content_uri; - let image_content = ImageMessageEventContent::plain( - query.clone(), - mxc_uri.to_owned().into(), - ); - - let message = RoomMessageEventContent::new(MessageType::Image(image_content)); - ctx.room.send(message).await?; + } - tracing::info!("已发送梗图: {}", query); Ok(()) } } @@ -131,7 +124,7 @@ async fn send_html(room: &matrix_sdk::Room, html: &str) -> Result<()> { .take(100) .collect::(); - let content = RoomMessageEventContent::text_html(plain_text, html); + 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/tenor.rs b/src/modules/meme/klipy.rs similarity index 67% rename from src/modules/meme/tenor.rs rename to src/modules/meme/klipy.rs index 7780bc6..cef84ee 100644 --- a/src/modules/meme/tenor.rs +++ b/src/modules/meme/klipy.rs @@ -1,28 +1,32 @@ -//! Tenor GIF API 客户端。 +//! 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}; -/// Tenor API 客户端。 +/// KLIPY API 客户端。 /// /// 用于搜索 GIF 图片。 -pub struct TenorClient { +pub struct KlipyClient { api_key: String, limit: u32, http_client: reqwest::Client, } -/// Tenor 搜索响应。 +/// KLIPY 搜索响应(与 Tenor v2 兼容格式)。 #[derive(Debug, Deserialize)] -struct TenorSearchResponse { - results: Vec, +struct KlipySearchResponse { + results: Vec, #[serde(default)] + #[allow(dead_code)] next: Option, } #[derive(Debug, Deserialize)] -struct TenorResult { +struct KlipyResult { id: String, media_formats: MediaFormats, } @@ -35,6 +39,8 @@ struct MediaFormats { tinygif: Option, #[serde(default)] mediumgif: Option, + #[serde(default)] + nanogif: Option, } #[derive(Debug, Deserialize)] @@ -48,8 +54,8 @@ pub struct GifResult { pub url: String, } -impl TenorClient { - /// 创建新的 Tenor 客户端。 +impl KlipyClient { + /// 创建新的 KLIPY 客户端。 pub fn new(api_key: String, limit: u32) -> Self { Self { api_key, @@ -69,40 +75,41 @@ impl TenorClient { /// 返回随机一个 GIF 结果,如果没有结果则返回 None。 pub async fn search(&self, query: &str) -> Result> { let encoded_query = urlencoding::encode(query); + // KLIPY Tenor 兼容 API endpoint let url = format!( - "https://tenor.googleapis.com/v2/search?q={}&key={}&limit={}&media_filter=gif,tinygif,mediumgif", + "https://api.klipy.com/v2/search?q={}&key={}&limit={}&media_filter=gif,tinygif,mediumgif,nanogif", encoded_query, self.api_key, self.limit ); - debug!("Tenor API 请求: {}", url.replace(&self.api_key, "API_KEY")); + debug!("KLIPY API 请求: {}", url.replace(&self.api_key, "API_KEY")); let response = self .http_client .get(&url) .send() .await - .context("Tenor API 请求失败")?; + .context("KLIPY API 请求失败")?; let status = response.status(); let body = response.text().await.context("读取响应失败")?; - debug!("Tenor API 响应状态: {}", status); - debug!("Tenor API 响应内容: {}", body.chars().take(500).collect::()); + debug!("KLIPY API 响应状态: {}", status); + debug!("KLIPY API 响应内容: {}", body.chars().take(500).collect::()); if !status.is_success() { - anyhow::bail!("Tenor API 返回错误: {} - {}", status, body); + anyhow::bail!("KLIPY API 返回错误: {} - {}", status, body); } // 尝试解析响应 - let search_response: TenorSearchResponse = serde_json::from_str(&body) - .with_context(|| format!("解析 Tenor API 响应失败,响应内容: {}", body.chars().take(200).collect::()))?; + 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!("Tenor API 返回空结果,查询: {}", query); + warn!("KLIPY API 返回空结果,查询: {}", query); return Ok(None); } - debug!("Tenor API 返回 {} 个结果", search_response.results.len()); + debug!("KLIPY API 返回 {} 个结果", search_response.results.len()); // 随机选择一个结果 use rand::prelude::IndexedRandom; @@ -113,11 +120,12 @@ impl TenorClient { debug!("选中的 GIF ID: {}", result.id); - // 优先使用 tinygif(更小的文件),然后是 mediumgif,最后是 gif + // 优先使用 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()) diff --git a/src/modules/meme/mod.rs b/src/modules/meme/mod.rs index f70d97a..bec5fc1 100644 --- a/src/modules/meme/mod.rs +++ b/src/modules/meme/mod.rs @@ -1,9 +1,9 @@ //! Meme 梗图模块。 //! -//! 提供 `!meme` 命令,使用 Tenor GIF API 搜索并发送梗图。 +//! 提供 `!meme` 命令,使用 KLIPY GIF API 搜索并发送梗图。 mod handlers; -mod tenor; +mod klipy; pub use handlers::MemeHandler; -pub use tenor::TenorClient; \ No newline at end of file +pub use klipy::KlipyClient; \ No newline at end of file