Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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设置人设后会移除旧的人设后缀并添加新的,格式为 \"\"基础名称 \\(人设名称\\)\"\"。\")"
]
}
}
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 最大内容长度(字符)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
61 changes: 61 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ pub struct Config {
/// MCP 配置
#[serde(default)]
pub mcp: crate::mcp::McpConfig,

/// Meme 梗图配置
#[serde(default)]
pub meme: MemeConfig,
}

/// Matrix 连接配置
Expand Down Expand Up @@ -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<String>,

/// 每次搜索返回的结果数量。
#[serde(default = "default_meme_limit")]
pub limit: u32,
}

// 默认值函数
fn default_device_display_name() -> String {
"AI Bot".to_string()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
/// 从配置文件和环境变量加载配置。
///
Expand Down Expand Up @@ -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;
}
}

/// 验证必需字段
Expand Down
11 changes: 11 additions & 0 deletions src/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -297,6 +298,15 @@ impl<T: AiServiceTrait> EventHandler<T> {
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,
Expand Down Expand Up @@ -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();
Expand Down
130 changes: 130 additions & 0 deletions src/modules/meme/handlers.rs
Original file line number Diff line number Diff line change
@@ -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<KlipyClient>,
}

impl MemeHandler {
/// 创建新的 Meme 命令处理器。
pub fn new(klipy: Option<KlipyClient>) -> 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::<String>();

let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_html(plain_text, html);
room.send(content).await?;
Ok(())
}
Loading
Loading