diff --git a/.gitignore b/.gitignore index eb8391a92..0af7218ed 100644 --- a/.gitignore +++ b/.gitignore @@ -311,6 +311,7 @@ run_pet.bat /plugins/* !/plugins -!/plugins/example_plugin +!/plugins/hello_world_plugin +!/plugins/take_picture_plugin config.toml \ No newline at end of file diff --git a/plugins/hello_world_plugin/plugin.py b/plugins/hello_world_plugin/plugin.py new file mode 100644 index 000000000..ad5f0f748 --- /dev/null +++ b/plugins/hello_world_plugin/plugin.py @@ -0,0 +1,145 @@ +from typing import List, Tuple, Type +from src.plugin_system import ( + BasePlugin, register_plugin, BaseAction, BaseCommand, + ComponentInfo, ActionActivationType, ChatMode, + ConfigField +) + +# ===== Action组件 ===== + +class HelloAction(BaseAction): + """问候Action - 简单的问候动作""" + + # === 基本信息(必须填写)=== + action_name = "hello_greeting" + action_description = "向用户发送问候消息" + + # === 功能描述(必须填写)=== + action_parameters = { + "greeting_message": "要发送的问候消息" + } + action_require = [ + "需要发送友好问候时使用", + "当有人向你问好时使用", + "当你遇见没有见过的人时使用" + ] + associated_types = ["text"] + + async def execute(self) -> Tuple[bool, str]: + """执行问候动作 - 这是核心功能""" + # 发送问候消息 + greeting_message = self.action_data.get("greeting_message","") + base_message = self.get_config("greeting.message", "嗨!很开心见到你!😊") + message = base_message + greeting_message + await self.send_text(message) + + return True, "发送了问候消息" + +class ByeAction(BaseAction): + """告别Action - 只在用户说再见时激活""" + + action_name = "bye_greeting" + action_description = "向用户发送告别消息" + + # 使用关键词激活 + focus_activation_type = ActionActivationType.KEYWORD + normal_activation_type = ActionActivationType.KEYWORD + + # 关键词设置 + activation_keywords = ["再见", "bye", "88", "拜拜"] + keyword_case_sensitive = False + + action_parameters = {"bye_message": "要发送的告别消息"} + action_require = [ + "用户要告别时使用", + "当有人要离开时使用", + "当有人和你说再见时使用", + ] + associated_types = ["text"] + + async def execute(self) -> Tuple[bool, str]: + bye_message = self.action_data.get("bye_message","") + + message = "再见!期待下次聊天!👋" + bye_message + await self.send_text(message) + return True, "发送了告别消息" + +class TimeCommand(BaseCommand): + """时间查询Command - 响应/time命令""" + + command_name = "time" + command_description = "查询当前时间" + + # === 命令设置(必须填写)=== + command_pattern = r"^/time$" # 精确匹配 "/time" 命令 + command_help = "查询当前时间" + command_examples = ["/time"] + intercept_message = True # 拦截消息,不让其他组件处理 + + async def execute(self) -> Tuple[bool, str]: + """执行时间查询""" + import datetime + + # 获取当前时间 + time_format = self.get_config("time.format", "%Y-%m-%d %H:%M:%S") + now = datetime.datetime.now() + time_str = now.strftime(time_format) + + # 发送时间信息 + message = f"⏰ 当前时间:{time_str}" + await self.send_text(message) + + return True, f"显示了当前时间: {time_str}" + + +# ===== 插件注册 ===== + +@register_plugin +class HelloWorldPlugin(BasePlugin): + """Hello World插件 - 你的第一个MaiCore插件""" + + # 插件基本信息 + plugin_name = "hello_world_plugin" + plugin_description = "我的第一个MaiCore插件,包含问候功能" + plugin_version = "1.0.0" + plugin_author = "你的名字" + enable_plugin = True + config_file_name = "config.toml" # 配置文件名 + + # 配置节描述 + config_section_descriptions = { + "plugin": "插件基本信息", + "greeting": "问候功能配置", + "time": "时间查询配置" + } + + # 配置Schema定义 + config_schema = { + "plugin": { + "name": ConfigField(type=str, default="hello_world_plugin", description="插件名称"), + "version": ConfigField(type=str, default="1.0.0", description="插件版本"), + "enabled": ConfigField(type=bool, default=False, description="是否启用插件") + }, + "greeting": { + "message": ConfigField( + type=str, + default="嗨!很开心见到你!😊", + description="默认问候消息" + ), + "enable_emoji": ConfigField(type=bool, default=True, description="是否启用表情符号") + }, + "time": { + "format": ConfigField( + type=str, + default="%Y-%m-%d %H:%M:%S", + description="时间显示格式" + ) + } + } + + def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]: + return [ + (HelloAction.get_action_info(), HelloAction), + (ByeAction.get_action_info(), ByeAction), # 添加告别Action + (TimeCommand.get_command_info(), TimeCommand), + ] \ No newline at end of file diff --git a/plugins/take_picture_plugin/plugin.py b/plugins/take_picture_plugin/plugin.py new file mode 100644 index 000000000..de600a1be --- /dev/null +++ b/plugins/take_picture_plugin/plugin.py @@ -0,0 +1,532 @@ +""" +拍照插件 + +功能特性: +- Action: 生成一张自拍照,prompt由人设和模板生成 +- Command: 展示最近生成的照片 + +#此插件并不完善 +#此插件并不完善 + +#此插件并不完善 + +#此插件并不完善 + +#此插件并不完善 + +#此插件并不完善 + +#此插件并不完善 + + + +包含组件: +- 拍照Action - 生成自拍照 +- 展示照片Command - 展示最近生成的照片 +""" +from typing import List, Tuple, Type, Optional +import random +import datetime +import json +import os +import asyncio +import urllib.request +import urllib.error +import base64 +import traceback + +from src.plugin_system.base.base_plugin import BasePlugin, register_plugin +from src.plugin_system.base.base_action import BaseAction +from src.plugin_system.base.base_command import BaseCommand +from src.plugin_system.base.component_types import ComponentInfo, ActionActivationType, ChatMode +from src.plugin_system.base.config_types import ConfigField +from src.common.logger import get_logger + +logger = get_logger("take_picture_plugin") + +# 定义数据目录常量 +DATA_DIR = os.path.join("data", "take_picture_data") +# 确保数据目录存在 +os.makedirs(DATA_DIR, exist_ok=True) +# 创建全局锁 +file_lock = asyncio.Lock() + + +class TakePictureAction(BaseAction): + """生成一张自拍照""" + + focus_activation_type = ActionActivationType.KEYWORD + normal_activation_type = ActionActivationType.KEYWORD + mode_enable = ChatMode.ALL + parallel_action = False + + action_name = "take_picture" + action_description = "生成一张用手机拍摄,比如自拍或者近照" + activation_keywords = ["拍张照", "自拍", "发张照片", "看看你", "你的照片"] + keyword_case_sensitive = False + + action_parameters = {} + + action_require = [ + "当用户想看你的照片时使用", + "当用户让你发自拍时使用" + "当想随手拍眼前的场景时使用" + ] + + associated_types = ["text","image"] + + # 内置的Prompt模板,如果配置文件中没有定义,将使用这些模板 + DEFAULT_PROMPT_TEMPLATES = [ + "极其频繁无奇的iPhone自拍照,没有明确的主体或构图感,就是随手一拍的快照照片略带运动模糊,阳光或室内打光不均匀导致的轻微曝光过度,整体呈现出一种刻意的平庸感,就像是从口袋里拿手机时不小心拍到的一张自拍。主角是{name},{personality}" + ] + + # 简单的请求缓存,避免短时间内重复请求 + _request_cache = {} + + async def execute(self) -> Tuple[bool, Optional[str]]: + logger.info(f"{self.log_prefix} 执行拍照动作") + + try: + # 配置验证 + http_base_url = self.api.get_config("api.base_url") + http_api_key = self.api.get_config("api.volcano_generate_api_key") + + if not (http_base_url and http_api_key): + error_msg = "抱歉,照片生成功能所需的API配置(如API地址或密钥)不完整,无法提供服务。" + await self.send_text(error_msg) + logger.error(f"{self.log_prefix} HTTP调用配置缺失: base_url 或 volcano_generate_api_key.") + return False, "API配置不完整" + + # API密钥验证 + if http_api_key == "YOUR_DOUBAO_API_KEY_HERE": + error_msg = "照片生成功能尚未配置,请设置正确的API密钥。" + await self.send_text(error_msg) + logger.error(f"{self.log_prefix} API密钥未配置") + return False, "API密钥未配置" + + # 获取全局配置信息 + bot_nickname = self.api.get_global_config("bot.nickname", "麦麦") + bot_personality = self.api.get_global_config("personality.personality_core", "") + + + personality_sides = self.api.get_global_config("personality.personality_sides", []) + if personality_sides: + bot_personality += random.choice(personality_sides) + + # 准备模板变量 + template_vars = { + "name": bot_nickname, + "personality": bot_personality + } + + logger.info(f"{self.log_prefix} 使用的全局配置: name={bot_nickname}, personality={bot_personality}") + + # 尝试从配置文件获取模板,如果没有则使用默认模板 + templates = self.api.get_config("picture.prompt_templates", self.DEFAULT_PROMPT_TEMPLATES) + if not templates: + logger.warning(f"{self.log_prefix} 未找到有效的提示词模板,使用默认模板") + templates = self.DEFAULT_PROMPT_TEMPLATES + + prompt_template = random.choice(templates) + + # 填充模板 + final_prompt = prompt_template.format(**template_vars) + + logger.info(f"{self.log_prefix} 生成的最终Prompt: {final_prompt}") + + # 从配置获取参数 + model = self.api.get_config("picture.default_model", "doubao-seedream-3-0-t2i-250415") + size = self.api.get_config("picture.default_size", "1024x1024") + watermark = self.api.get_config("picture.default_watermark", True) + guidance_scale = self.api.get_config("picture.default_guidance_scale", 2.5) + seed = self.api.get_config("picture.default_seed", 42) + + # 检查缓存 + enable_cache = self.api.get_config("storage.enable_cache", True) + if enable_cache: + cache_key = self._get_cache_key(final_prompt, model, size) + if cache_key in self._request_cache: + cached_result = self._request_cache[cache_key] + logger.info(f"{self.log_prefix} 使用缓存的图片结果") + await self.send_text("我之前拍过类似的照片,用之前的结果~") + + # 直接发送缓存的结果 + send_success = await self._send_image(cached_result) + if send_success: + await self.send_text("这是我的照片,好看吗?") + return True, "照片已发送(缓存)" + else: + # 缓存失败,清除这个缓存项并继续正常流程 + del self._request_cache[cache_key] + + await self.send_text("正在为你拍照,请稍候...") + + try: + seed = random.randint(1, 1000000) + success, result = await asyncio.to_thread( + self._make_http_image_request, + prompt=final_prompt, + model=model, + size=size, + seed=seed, + guidance_scale=guidance_scale, + watermark=watermark, + ) + except Exception as e: + logger.error(f"{self.log_prefix} (HTTP) 异步请求执行失败: {e!r}", exc_info=True) + traceback.print_exc() + success = False + result = f"照片生成服务遇到意外问题: {str(e)[:100]}" + + if success: + image_url = result + logger.info(f"{self.log_prefix} 图片URL获取成功: {image_url[:70]}... 下载并编码.") + + try: + encode_success, encode_result = await asyncio.to_thread(self._download_and_encode_base64, image_url) + except Exception as e: + logger.error(f"{self.log_prefix} (B64) 异步下载/编码失败: {e!r}", exc_info=True) + traceback.print_exc() + encode_success = False + encode_result = f"图片下载或编码时发生内部错误: {str(e)[:100]}" + + if encode_success: + base64_image_string = encode_result + # 更新缓存 + if enable_cache: + self._update_cache(final_prompt, model, size, base64_image_string) + + # 发送图片 + send_success = await self._send_image(base64_image_string) + if send_success: + # 存储到文件 + await self._store_picture_info(final_prompt, image_url) + logger.info(f"{self.log_prefix} 成功生成并存储照片: {image_url}") + await self.send_text("当当当当~这是我刚拍的照片,好看吗?") + return True, f"成功生成照片: {image_url}" + else: + await self.send_text("照片生成了,但发送失败了,可能是格式问题...") + return False, "照片发送失败" + else: + await self.send_text(f"照片下载失败: {encode_result}") + return False, encode_result + else: + await self.send_text(f"哎呀,拍照失败了: {result}") + return False, result + + except Exception as e: + logger.error(f"{self.log_prefix} 执行拍照动作失败: {e}", exc_info=True) + traceback.print_exc() + await self.send_text("呜呜,拍照的时候出了一点小问题...") + return False, str(e) + + async def _store_picture_info(self, prompt: str, image_url: str): + """将照片信息存入日志文件""" + log_file = self.api.get_config("storage.log_file", "picture_log.json") + log_path = os.path.join(DATA_DIR, log_file) + max_photos = self.api.get_config("storage.max_photos", 50) + + async with file_lock: + try: + if os.path.exists(log_path): + with open(log_path, 'r', encoding='utf-8') as f: + log_data = json.load(f) + else: + log_data = [] + except (json.JSONDecodeError, FileNotFoundError): + log_data = [] + + # 添加新照片 + log_data.append({ + "prompt": prompt, + "image_url": image_url, + "timestamp": datetime.datetime.now().isoformat() + }) + + # 如果超过最大数量,删除最旧的 + if len(log_data) > max_photos: + log_data = sorted(log_data, key=lambda x: x.get('timestamp', ''), reverse=True)[:max_photos] + + try: + with open(log_path, 'w', encoding='utf-8') as f: + json.dump(log_data, f, ensure_ascii=False, indent=4) + except Exception as e: + logger.error(f"{self.log_prefix} 写入照片日志文件失败: {e}", exc_info=True) + + def _make_http_image_request( + self, prompt: str, model: str, size: str, seed: int, guidance_scale: float, watermark: bool + ) -> Tuple[bool, str]: + """发送HTTP请求到火山引擎豆包API生成图片""" + try: + base_url = self.api.get_config("api.base_url") + api_key = self.api.get_config("api.volcano_generate_api_key") + + # 构建请求URL和头部 + endpoint = f"{base_url.rstrip('/')}/images/generations" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + + # 构建请求体 + request_body = { + "model": model, + "prompt": prompt, + "response_format": "url", + "size": size, + "seed": seed, + "guidance_scale": guidance_scale, + "watermark": watermark, + "api-key": api_key, + } + + # 创建请求对象 + req = urllib.request.Request( + endpoint, + data=json.dumps(request_body).encode("utf-8"), + headers=headers, + method="POST", + ) + + # 发送请求并获取响应 + with urllib.request.urlopen(req, timeout=60) as response: + response_data = json.loads(response.read().decode("utf-8")) + + # 解析响应 + image_url = None + if ( + isinstance(response_data.get("data"), list) + and response_data["data"] + and isinstance(response_data["data"][0], dict) + ): + image_url = response_data["data"][0].get("url") + elif response_data.get("url"): + image_url = response_data.get("url") + + if image_url: + return True, image_url + else: + error_msg = response_data.get("error", {}).get("message", "未知错误") + logger.error(f"API返回错误: {error_msg}") + return False, f"API错误: {error_msg}" + + except urllib.error.HTTPError as e: + error_body = e.read().decode("utf-8") + logger.error(f"HTTP错误 {e.code}: {error_body}") + return False, f"HTTP错误 {e.code}: {error_body[:100]}..." + except Exception as e: + logger.error(f"请求异常: {e}", exc_info=True) + return False, f"请求异常: {str(e)}" + + def _download_and_encode_base64(self, image_url: str) -> Tuple[bool, str]: + """下载图片并转换为Base64编码""" + try: + with urllib.request.urlopen(image_url) as response: + image_data = response.read() + + base64_encoded = base64.b64encode(image_data).decode('utf-8') + return True, base64_encoded + except Exception as e: + logger.error(f"图片下载编码失败: {e}", exc_info=True) + return False, str(e) + + async def _send_image(self, base64_image: str) -> bool: + """发送图片""" + try: + # 使用聊天流信息确定发送目标 + chat_stream = self.api.get_service("chat_stream") + if not chat_stream: + logger.error(f"{self.log_prefix} 没有可用的聊天流发送图片") + return False + + if chat_stream.group_info: + # 群聊 + return await self.api.send_message_to_target( + message_type="image", + content=base64_image, + platform=chat_stream.platform, + target_id=str(chat_stream.group_info.group_id), + is_group=True, + display_message="发送生成的照片", + ) + else: + # 私聊 + return await self.api.send_message_to_target( + message_type="image", + content=base64_image, + platform=chat_stream.platform, + target_id=str(chat_stream.user_info.user_id), + is_group=False, + display_message="发送生成的照片", + ) + except Exception as e: + logger.error(f"{self.log_prefix} 发送图片时出错: {e}") + return False + + @classmethod + def _get_cache_key(cls, description: str, model: str, size: str) -> str: + """生成缓存键""" + return f"{description}|{model}|{size}" + + def _update_cache(self, description: str, model: str, size: str, base64_image: str): + """更新缓存""" + max_cache_size = self.api.get_config("storage.max_cache_size", 10) + cache_key = self._get_cache_key(description, model, size) + + # 添加到缓存 + self._request_cache[cache_key] = base64_image + + # 如果缓存超过最大大小,删除最旧的项 + if len(self._request_cache) > max_cache_size: + oldest_key = next(iter(self._request_cache)) + del self._request_cache[oldest_key] + + +class ShowRecentPicturesCommand(BaseCommand): + """展示最近生成的照片""" + + command_name = "show_recent_pictures" + command_description = "展示最近生成的5张照片" + command_pattern = r"^/show_pics$" + command_help = "用法: /show_pics" + command_examples = ["/show_pics"] + intercept_message = True + + async def execute(self) -> Tuple[bool, Optional[str]]: + logger.info(f"{self.log_prefix} 执行展示最近照片命令") + log_file = self.api.get_config("storage.log_file", "picture_log.json") + log_path = os.path.join(DATA_DIR, log_file) + + async with file_lock: + try: + if not os.path.exists(log_path): + await self.send_text("最近还没有拍过照片哦,快让我自拍一张吧!") + return True, "没有照片日志文件" + + with open(log_path, 'r', encoding='utf-8') as f: + log_data = json.load(f) + + if not log_data: + await self.send_text("最近还没有拍过照片哦,快让我自拍一张吧!") + return True, "没有照片" + + # 获取最新的5张照片 + recent_pics = sorted(log_data, key=lambda x: x['timestamp'], reverse=True)[:5] + + # 先发送文本消息 + await self.send_text("这是我最近拍的几张照片~") + + # 逐个发送图片 + for pic in recent_pics: + # 尝试获取图片URL + image_url = pic.get('image_url') + if image_url: + try: + # 下载图片并转换为Base64 + with urllib.request.urlopen(image_url) as response: + image_data = response.read() + base64_encoded = base64.b64encode(image_data).decode('utf-8') + + # 发送图片 + await self.send_type( + message_type="image", + content=base64_encoded, + display_message="发送最近的照片" + ) + except Exception as e: + logger.error(f"{self.log_prefix} 下载或发送照片失败: {e}", exc_info=True) + + return True, "成功展示最近的照片" + + except json.JSONDecodeError: + await self.send_text("照片记录文件好像损坏了...") + return False, "JSON解码错误" + except Exception as e: + logger.error(f"{self.log_prefix} 展示照片失败: {e}", exc_info=True) + await self.send_text("哎呀,查找照片的时候出错了。") + return False, str(e) + + +@register_plugin +class TakePicturePlugin(BasePlugin): + """拍照插件""" + plugin_name = "take_picture_plugin" + plugin_description = "提供生成自拍照和展示最近照片的功能" + plugin_version = "1.0.0" + plugin_author = "SengokuCola" + enable_plugin = True + config_file_name = "config.toml" + + # 配置节描述 + config_section_descriptions = { + "plugin": "插件基本信息配置", + "api": "API相关配置,包含火山引擎API的访问信息", + "components": "组件启用控制", + "picture": "拍照功能核心配置", + "storage": "照片存储相关配置", + } + + # 配置Schema定义 + config_schema = { + "plugin": { + "name": ConfigField(type=str, default="take_picture_plugin", description="插件名称", required=True), + "version": ConfigField(type=str, default="1.3.0", description="插件版本号"), + "enabled": ConfigField(type=bool, default=False, description="是否启用插件"), + "description": ConfigField(type=str, default="提供生成自拍照和展示最近照片的功能", description="插件描述", required=True), + }, + "api": { + "base_url": ConfigField( + type=str, + default="https://ark.cn-beijing.volces.com/api/v3", + description="API基础URL", + example="https://api.example.com/v1", + ), + "volcano_generate_api_key": ConfigField( + type=str, default="YOUR_DOUBAO_API_KEY_HERE", description="火山引擎豆包API密钥", required=True + ), + }, + "components": { + "enable_take_picture_action": ConfigField(type=bool, default=True, description="是否启用拍照Action"), + "enable_show_pics_command": ConfigField(type=bool, default=True, description="是否启用展示照片Command"), + }, + "picture": { + "default_model": ConfigField( + type=str, + default="doubao-seedream-3-0-t2i-250415", + description="默认使用的文生图模型", + choices=["doubao-seedream-3-0-t2i-250415", "doubao-seedream-2-0-t2i"], + ), + "default_size": ConfigField( + type=str, + default="1024x1024", + description="默认图片尺寸", + example="1024x1024", + choices=["1024x1024", "1024x1280", "1280x1024", "1024x1536", "1536x1024"], + ), + "default_watermark": ConfigField(type=bool, default=True, description="是否默认添加水印"), + "default_guidance_scale": ConfigField( + type=float, default=2.5, description="模型指导强度,影响图片与提示的关联性", example="2.0" + ), + "default_seed": ConfigField(type=int, default=42, description="随机种子,用于复现图片"), + "prompt_templates": ConfigField( + type=list, + default=TakePictureAction.DEFAULT_PROMPT_TEMPLATES, + description="用于生成自拍照的prompt模板" + ), + }, + "storage": { + "max_photos": ConfigField(type=int, default=50, description="最大保存的照片数量"), + "log_file": ConfigField(type=str, default="picture_log.json", description="照片日志文件名"), + "enable_cache": ConfigField(type=bool, default=True, description="是否启用请求缓存"), + "max_cache_size": ConfigField(type=int, default=10, description="最大缓存数量"), + } + } + + def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]: + """返回插件包含的组件列表""" + components = [] + if self.get_config("components.enable_take_picture_action", True): + components.append((TakePictureAction.get_action_info(), TakePictureAction)) + if self.get_config("components.enable_show_pics_command", True): + components.append((ShowRecentPicturesCommand.get_command_info(), ShowRecentPicturesCommand)) + return components \ No newline at end of file