Files
abot/plugins/xiuren_image/main.py
liuwei 9652c2594e 系统业务任务插件化迁移:下沉7项非刚需任务并接入平滑迁移
- 系统任务保留刚需三项:登录巡检、消息计数入库、媒体补偿处理;移除新闻/Epic/排行/PDF/秀人维护等业务型系统任务定义\n- 新增 daily_news、epic_free、daily_ranking、sehuatang_push 四个插件,将原系统业务任务改为插件可调度动作\n- 扩展 xiuren_image 插件调度动作,新增秀人下载、绅士R15下载、图片缓存更新三项维护任务\n- 新增系统任务到插件任务的幂等迁移逻辑:按旧 job_key 映射到插件 action,同步 trigger_type/trigger_config/enabled,并通过 payload 标记防止反复覆盖\n- 在 Robot 启动流程中接入迁移执行与重载,并清理已迁移的历史系统任务记录,避免后台双份维护\n- 扩展插件调度数据库操作:支持按 plugin_name + action_key 精确查询,便于迁移与对账
2026-04-16 16:05:59 +08:00

434 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
from loguru import logger
import os
import base64
import asyncio
from typing import Dict, Any, List, Optional, Tuple
from db.connection import DBConnectionManager
from base.plugin_common.message_plugin_interface import MessagePluginInterface
from base.plugin_common.plugin_interface import PluginStatus
from plugins.xiuren_image.images_cache import ImageCacheManager
from plugins.xiuren_image.meitu_dl import meitu_dowload_pub_pic
from plugins.xiuren_image.shenshi_r15 import run_daily_job
from utils.decorator.plugin_decorators import plugin_stats_decorator
from utils.decorator.rate_limit_decorator import group_feature_rate_limit
from utils.revoke.message_auto_revoke import MessageAutoRevoke
from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager
from utils.decorator.points_decorator import plugin_points_cost
from wechat_ipad import WechatAPIClient
class XiurenImagePlugin(MessagePluginInterface):
"""秀人图片插件"""
# 功能权限常量
FEATURE_KEY = "XIUREN_IMAGE"
FEATURE_DESCRIPTION = "🖼️ 图来能力 [图来, 秀人]"
@property
def name(self) -> str:
return "秀人图片"
@property
def version(self) -> str:
return "1.0.0"
@property
def description(self) -> str:
return "提供随机秀人网图片功能"
@property
def author(self) -> str:
return "liu.wei"
@property
def command_prefix(self) -> Optional[str]:
return "" # 不需要前缀,直接匹配命令
@property
def commands(self) -> List[str]:
return self._commands
@property
def feature_key(self) -> Optional[str]:
return self.FEATURE_KEY
@property
def feature_description(self) -> Optional[str]:
return self.FEATURE_DESCRIPTION
def __init__(self):
super().__init__()
# 使用Path对象处理路径自动适应不同操作系统
self.image_folder = str(Path(Path(__file__).parent.parent.parent, "xiuren"))
# 注册功能权限
self.feature = self.register_feature()
# 初始化图片缓存管理器
self.image_cache_manager = None
def initialize(self, context: Dict[str, Any]) -> bool:
"""初始化插件"""
self.LOG = logger
self.LOG.debug(f"正在初始化 {self.name} 插件...")
# 保存上下文对象
self.event_system = context.get("event_system")
self._commands = self._config.get("XiurenImage", {}).get("command", ["图来", "秀人"])
self.command_format = self._config.get("XiurenImage", {}).get("command-format", "图来")
self.enable = self._config.get("XiurenImage", {}).get("enable", True)
# 从配置获取图片文件夹,如果配置中有值则使用配置值
config_image_folder = self._config.get("XiurenImage", {}).get("image_folder")
if config_image_folder:
self.image_folder = config_image_folder
# 从配置获取缓存大小默认5张
cache_size = self._config.get("XiurenImage", {}).get("cache_size", 5)
# 检查图片文件夹是否存在
try:
if not os.path.exists(self.image_folder):
self.LOG.warning(f"图片文件夹不存在: {self.image_folder}")
os.makedirs(self.image_folder, exist_ok=True)
except Exception as e:
self.LOG.error(f"创建图片文件夹失败: {e}")
# 初始化图片缓存管理器
self.image_cache_manager = ImageCacheManager(self.image_folder, cache_size)
self.LOG.debug(
f"[{self.name}] 插件初始化完成,指令:{self._commands},图片目录:{self.image_folder},缓存大小:{cache_size}")
return True
def start(self) -> bool:
"""启动插件"""
self.LOG.debug(f"[{self.name}] 插件已启动")
self.status = PluginStatus.RUNNING
return True
def stop(self) -> bool:
"""停止插件"""
self.LOG.info(f"[{self.name}] 插件已停止")
self.status = PluginStatus.STOPPED
return True
def can_process(self, message: Dict[str, Any]) -> bool:
"""检查是否可以处理该消息"""
if not self.enable:
return False
content = str(message.get("content", "")).strip()
command = content.split(" ")[0]
return command in self._commands
@plugin_stats_decorator(plugin_name="秀人网图片")
@plugin_points_cost(5, "秀人网图片消耗积分", FEATURE_KEY)
@group_feature_rate_limit(max_per_minute=5, feature_key=FEATURE_KEY)
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""处理消息"""
content = str(message.get("content", "")).strip()
self.LOG.debug(f"插件执行: {self.name}{content}")
sender = message.get("sender")
roomid = message.get("roomid", "")
gbm: GroupBotManager = message.get("gbm")
bot: WechatAPIClient = message.get("bot")
revoke: MessageAutoRevoke = message.get("revoke")
# 检查权限
if roomid and gbm.get_group_permission(roomid, self.feature) == PermissionStatus.DISABLED:
return False, "没有权限"
try:
# 从缓存获取图片bytes数据
cached_image = self._get_cached_image()
if not cached_image:
client_msg_id, create_time, new_msg_id = await bot.send_text_message((roomid if roomid else sender),
f"❌未找到图片资源",
sender)
revoke.add_message_to_revoke(roomid, client_msg_id, create_time, new_msg_id, 5)
return False, "未找到图片资源"
# 发送图片
image_data = cached_image['bytes']
image_path = cached_image['path']
# 记录缓存状态
cache_count = self.image_cache_manager.get_cached_image_count()
self.LOG.info(f"从缓存获取图片成功,路径:{image_path},当前缓存数量:{cache_count}")
# 发送图片支持bytes格式
client_msg_id, create_time, new_msg_id = await bot.send_image_message((roomid if roomid else sender),
image_data)
# revoke.add_message_to_revoke(roomid, client_msg_id, create_time, new_msg_id, 90)
self.LOG.info(
f"发送图片结果,client_msg_id= {client_msg_id},create_time={create_time},new_msg_id={new_msg_id}")
return True, "发送成功"
except Exception as e:
self.LOG.error(f"处理图片请求出错: {e}")
return False, f"处理出错: {e}"
def _get_cached_image(self) -> Optional[Dict[str, any]]:
"""
从缓存获取图片数据
返回格式: {'path': str, 'bytes': bytes}
"""
try:
# 优先从内存缓存获取
cached_image = self.image_cache_manager.get_cached_image_bytes()
if cached_image:
return cached_image
# 如果缓存中没有,回退到原来的方式
self.LOG.warning("缓存中没有图片,回退到磁盘读取")
pic_path = self._get_random_pic()
if pic_path:
# 读取图片bytes
try:
with open(pic_path, 'rb') as f:
image_bytes = f.read()
return {
'path': pic_path,
'bytes': image_bytes
}
except Exception as e:
self.LOG.error(f"读取图片文件失败: {e}")
return None
return None
except Exception as e:
self.LOG.error(f"获取缓存图片失败: {e}")
return None
def _get_random_pic(self) -> Optional[str]:
"""
从 Redis 随机获取并移除图片路径(确保不重复使用)
"""
redis_key = ImageCacheManager.IMAGE_KEY_PREFIX + "all"
try:
# 使用 spop 随机弹出并删除一个元素
img = DBConnectionManager.get_instance().get_redis_connection().spop(redis_key)
if img:
# redis 返回 bytes转字符串
return img.decode('utf-8') if isinstance(img, bytes) else img
self.LOG.warning("Redis 中没有图片数据(可能已全部耗尽)")
return None
except Exception as e:
self.LOG.error(f"从 Redis 获取并删除随机图片失败: {e}")
return None
def get_schedule_actions(self) -> List[Dict[str, Any]]:
"""插件可调度动作定义。"""
return [
{
"action_key": "daily_push",
"name": "秀人群发推送",
"description": "按调度时间向目标群发送秀人图片",
"trigger_type": "at_times",
"trigger_config": {"time_list": ["17:30"]},
"target_scope": "all_enabled_groups",
"target_config": {},
"payload": {"max_per_group": 1},
"default_enabled": False,
},
{
"action_key": "resource_xiuren_download",
"name": "秀人资源下载",
"description": "执行秀人资源下载维护任务",
"trigger_type": "at_times",
"trigger_config": {"time_list": ["01:30"]},
"target_scope": "all_enabled_groups",
"target_config": {},
"payload": {},
"default_enabled": True,
},
{
"action_key": "resource_shenshi_r15_download",
"name": "绅士R15资源下载",
"description": "执行绅士R15资源下载维护任务",
"trigger_type": "at_times",
"trigger_config": {"time_list": ["02:30"]},
"target_scope": "all_enabled_groups",
"target_config": {},
"payload": {},
"default_enabled": True,
},
{
"action_key": "resource_update_image_cache",
"name": "图片缓存更新",
"description": "扫描并更新图片缓存",
"trigger_type": "at_times",
"trigger_config": {"time_list": ["05:00"]},
"target_scope": "all_enabled_groups",
"target_config": {},
"payload": {},
"default_enabled": True,
}
]
async def run_scheduled_action(self, action_key: str, context: Dict[str, Any]) -> Dict[str, Any]:
"""执行插件定时动作。"""
if action_key == "resource_xiuren_download":
try:
# 历史逻辑为同步下载任务,这里放到线程池执行,避免阻塞调度主循环。
await asyncio.to_thread(meitu_dowload_pub_pic)
return {
"success": True,
"summary": "秀人资源下载任务执行完成",
"detail": {},
}
except Exception as e:
return {
"success": False,
"summary": f"秀人资源下载失败: {e}",
"detail": {"error": str(e)},
}
if action_key == "resource_shenshi_r15_download":
try:
await asyncio.to_thread(run_daily_job)
return {
"success": True,
"summary": "绅士R15资源下载任务执行完成",
"detail": {},
}
except Exception as e:
return {
"success": False,
"summary": f"绅士R15资源下载失败: {e}",
"detail": {"error": str(e)},
}
if action_key == "resource_update_image_cache":
try:
if not self.image_cache_manager:
return {
"success": False,
"summary": "缓存管理器未初始化",
"detail": {},
}
await self.image_cache_manager.update_image_cache()
return {
"success": True,
"summary": "图片缓存更新完成",
"detail": {"image_folder": self.image_folder},
}
except Exception as e:
return {
"success": False,
"summary": f"图片缓存更新失败: {e}",
"detail": {"error": str(e)},
}
if action_key != "daily_push":
return {
"success": False,
"summary": f"不支持的动作: {action_key}",
"detail": {"action_key": action_key},
}
if not self.bot:
return {
"success": False,
"summary": "bot 未注入,无法执行群发",
"detail": {},
}
target_groups = context.get("target_groups") or []
if not target_groups:
return {
"success": False,
"summary": "没有可发送的目标群",
"detail": {"target_groups": []},
}
payload = context.get("payload") or {}
max_per_group = max(1, int(payload.get("max_per_group", 1)))
success_groups = []
failed_groups = {}
for group_id in target_groups:
try:
for _ in range(max_per_group):
cached_image = self._get_cached_image()
if not cached_image:
raise RuntimeError("未找到图片资源")
await self.bot.send_image_message(group_id, cached_image["bytes"])
success_groups.append(group_id)
except Exception as e:
failed_groups[group_id] = str(e)
success_count = len(success_groups)
fail_count = len(failed_groups)
summary = f"秀人群发完成: 成功 {success_count} 群, 失败 {fail_count}"
return {
"success": fail_count == 0,
"summary": summary,
"detail": {
"target_count": len(target_groups),
"success_groups": success_groups,
"failed_groups": failed_groups,
"max_per_group": max_per_group,
},
}
# def _get_random_pic(self) -> Optional[str]:
# """获取随机图片路径"""
# try:
# # 获取图片文件夹中的所有图片
# if not os.path.exists(self.image_folder):
# self.LOG.error(f"图片文件夹不存在: {self.image_folder}")
# return None
#
# # 检查读取权限
# if not os.access(self.image_folder, os.R_OK):
# self.LOG.error(f"没有图片文件夹的读取权限: {self.image_folder}")
# return None
#
# self.LOG.debug(f"扫描图片目录: {self.image_folder} (包括子目录)")
#
# image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'}
# image_files = []
#
# # 使用 os.walk() 递归遍历所有子目录
# try:
# for root, _, files in os.walk(self.image_folder):
# for file in files:
# try:
# _, ext = os.path.splitext(file)
# if ext.lower() in image_extensions:
# full_path = os.path.join(root, file)
# # 检查文件是否可读
# if os.access(full_path, os.R_OK):
# image_files.append(full_path)
# else:
# self.LOG.warning(f"文件无法读取: {full_path}")
# except Exception as file_err:
# self.LOG.warning(f"处理文件时出错: {file} - {file_err}")
# except Exception as walk_err:
# self.LOG.error(f"遍历目录时出错: {walk_err}")
# return None
#
# if not image_files:
# self.LOG.warning("在目录中未找到图片文件(包括子目录)")
# return None
#
# # 随机选择一张图片
# try:
# random_pic = random.choice(image_files)
# return random_pic # 直接返回路径不需要再调用os.path.abspath
# except Exception as choice_err:
# self.LOG.error(f"选择随机图片时出错: {choice_err}")
# return None
#
# except Exception as e:
# self.LOG.error(f"获取随机图片出错: {e}")
# return None