import asyncio import random import os import sys import subprocess from nonebot import on_message, get_plugin_config, get_driver, logger from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment from nonebot.plugin import PluginMetadata from nonebot.exception import FinishedException from openai import OpenAI from .config import Config from .screenshot import markdown_to_image import pyppeteer # 插件元信息 __plugin_meta__ = PluginMetadata( name="chatai", description="一个对接 DeepSeek 的聊天 AI 插件", usage="发送以 * 开头的消息,AI 会回复你,两分钟后自动撤销", config=Config, ) # 获取插件配置 plugin_config = get_plugin_config(Config) # 全局浏览器实例 _browser: pyppeteer.browser.Browser | None = None _browser_lock = asyncio.Lock() # OpenAI 客户端(延迟初始化) _ai_client: OpenAI | None = None # 撤回任务引用集合,防止被GC回收 _recall_tasks: set[asyncio.Task] = set() # 注册消息事件处理器 message_handler = on_message(priority=50, block=True) # 确保输出目录存在 os.makedirs("data/chatai", exist_ok=True) # 获取 NoneBot 驱动器 driver = get_driver() def _force_kill_chrome(): """强制终止残留的 headless Chrome 进程(仅 pyppeteer 创建的)""" try: if sys.platform == "win32": # 只杀带 --remote-debugging-port 参数的 chrome(避免误杀用户浏览器) subprocess.run( ["taskkill", "/F", "/FI", "IMAGENAME eq chrome.exe", "/FI", "MODULES eq pyppeteer*"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) else: subprocess.run( ["pkill", "-9", "-f", "chrome.*--remote-debugging-port"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except Exception: pass @driver.on_startup async def startup_cleanup(): """启动时清理残留Chrome进程""" _force_kill_chrome() @driver.on_shutdown async def close_browser(): """在 NoneBot 关闭时关闭浏览器""" global _browser async with _browser_lock: if _browser is not None: try: await _browser.close() except Exception as e: logger.warning(f"关闭浏览器异常: {e}") _browser = None _force_kill_chrome() async def init_browser() -> "pyppeteer.browser.Browser": """初始化或复用浏览器实例""" global _browser async with _browser_lock: if _browser is None or not _browser.process: try: _browser = await pyppeteer.launch( headless=True, args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], ) logger.info("chatai: 浏览器实例已创建") except Exception as e: logger.error(f"chatai: 浏览器启动失败: {e}") raise return _browser _AI_API_TIMEOUT = 60 # seconds _AI_SYSTEM_PROMPT = ( "你是一个活泼可爱的群助手。请在回复中使用丰富的 Emoji 表情" "(如 😊 😄 🎉 ✨ 💖 等)。你的语气要俏皮活泼,像在和朋友聊天一样自然。" "在回答问题时要保持专业性的同时,也要让回复显得生动有趣。" "每条回复都必须包含至少2-3个 Emoji 表情。" "如果你需要展示代码片段,请确保代码中不包含任何颜文字或表情符号," "保持代码的专业性和可读性。" ) def _get_ai_client() -> OpenAI: """获取或创建 OpenAI 客户端(单例)""" global _ai_client if _ai_client is None: _ai_client = OpenAI( api_key=plugin_config.deepseek_token, base_url="https://api.siliconflow.cn/v1", timeout=_AI_API_TIMEOUT, ) return _ai_client async def call_ai_api(message: str) -> str: """调用 AI 接口""" client = _get_ai_client() response = await asyncio.to_thread( client.chat.completions.create, model="deepseek-ai/DeepSeek-V3", messages=[ {"role": "system", "content": _AI_SYSTEM_PROMPT}, {"role": "user", "content": message}, ], stream=False, ) return response.choices[0].message.content or "" @message_handler.handle() async def handle_message(event: MessageEvent, bot: Bot): user_message = event.get_plaintext().strip() if not user_message.startswith("*"): return user_message = user_message[1:].strip() if not user_message: await asyncio.sleep(random.uniform(2, 3)) await message_handler.finish("请输入有效内容哦~") try: browser = await init_browser() response = await call_ai_api(user_message) if response: await asyncio.sleep(random.uniform(2, 3)) # 使用事件ID+时间戳避免并发路径冲突 image_path = f"data/chatai/output_{event.message_id}.png" await markdown_to_image(response, image_path, browser) sent_message = await bot.send( event, MessageSegment.image(f"file:///{os.path.abspath(image_path)}") ) # 清理临时图片文件 try: os.remove(image_path) except OSError: pass # 保存task引用防止GC回收 task = asyncio.create_task( _delete_message_after_delay(bot, sent_message["message_id"]) ) _recall_tasks.add(task) task.add_done_callback(_recall_tasks.discard) except FinishedException: raise except Exception as e: logger.error(f"chatai处理失败: user_id={event.user_id} error={e}") await asyncio.sleep(random.uniform(2, 3)) await message_handler.finish("出错了,请稍后再试~") async def _delete_message_after_delay(bot: Bot, message_id: int): """两分钟后撤回消息""" await asyncio.sleep(120) try: await bot.delete_msg(message_id=message_id) except Exception as e: logger.debug(f"chatai撤回消息失败(可忽略): msg_id={message_id} error={e}")