feat:初版

This commit is contained in:
2025-12-03 15:48:44 +08:00
commit b4df26f61d
199 changed files with 23434 additions and 0 deletions

624
plugins/GrokVideo/main.py Normal file
View File

@@ -0,0 +1,624 @@
"""
Grok 视频生成插件
用户引用图片并发送 /视频 提示词 来生成视频
支持队列系统和积分制
"""
import re
import tomllib
import httpx
import xml.etree.ElementTree as ET
import asyncio
import pymysql
import uuid
from pathlib import Path
from loguru import logger
from typing import Dict, Optional
from dataclasses import dataclass
from datetime import datetime
from utils.plugin_base import PluginBase
from utils.decorators import on_text_message
from minio import Minio
from minio.error import S3Error
# 定义引用消息装饰器
def on_quote_message(priority=50):
"""引用消息装饰器"""
def decorator(func):
setattr(func, '_event_type', 'quote_message') # 修复:应该是 quote_message
setattr(func, '_priority', min(max(priority, 0), 99))
return func
return decorator
@dataclass
class VideoTask:
"""视频生成任务"""
user_wxid: str
from_wxid: str
prompt: str
cdnurl: str
aeskey: str
is_group: bool
timestamp: datetime
class GrokVideo(PluginBase):
"""Grok 视频生成插件"""
description = "使用 Grok AI 根据图片和提示词生成视频(支持队列和积分系统)"
author = "ShiHao"
version = "2.0.0"
def __init__(self):
super().__init__()
self.config = None
self.task_queue: asyncio.Queue = None
self.processing_tasks: Dict[str, VideoTask] = {} # 正在处理的任务
self.worker_task = None
self.minio_client = None
async def async_init(self):
"""插件异步初始化"""
config_path = Path(__file__).parent / "config.toml"
with open(config_path, "rb") as f:
self.config = tomllib.load(f)
self.api_url = f"{self.config['api']['server_url'].rstrip('/')}/v1/chat/completions"
# 初始化MinIO客户端
self.minio_client = Minio(
"101.201.65.129:19000",
access_key="admin",
secret_key="80012029Lz",
secure=False
)
self.minio_bucket = "wechat"
# 初始化队列
max_queue_size = self.config.get("queue", {}).get("max_queue_size", 10)
self.task_queue = asyncio.Queue(maxsize=max_queue_size)
# 启动工作线程
max_concurrent = self.config.get("queue", {}).get("max_concurrent", 1)
self.worker_task = asyncio.create_task(self._queue_worker())
logger.success(f"Grok 视频生成插件已加载")
logger.info(f"API: {self.api_url}")
logger.info(f"队列配置: 最大并发={max_concurrent}, 最大队列长度={max_queue_size}")
logger.info(f"积分系统: {'启用' if self.config.get('points', {}).get('enabled') else '禁用'}")
if self.config.get('points', {}).get('enabled'):
logger.info(f"每次生成消耗: {self.config['points']['cost']} 积分")
def get_db_connection(self):
"""获取数据库连接"""
db_config = self.config["database"]
return pymysql.connect(
host=db_config["host"],
port=db_config["port"],
user=db_config["user"],
password=db_config["password"],
database=db_config["database"],
charset=db_config["charset"],
autocommit=True
)
def get_user_points(self, wxid: str) -> int:
"""获取用户积分"""
try:
with self.get_db_connection() as conn:
with conn.cursor() as cursor:
sql = "SELECT points FROM user_signin WHERE wxid = %s"
cursor.execute(sql, (wxid,))
result = cursor.fetchone()
return result[0] if result else 0
except Exception as e:
logger.error(f"获取用户积分失败: {e}")
return 0
def deduct_points(self, wxid: str, points: int) -> bool:
"""扣除用户积分"""
try:
with self.get_db_connection() as conn:
with conn.cursor() as cursor:
# 检查积分是否足够
sql_check = "SELECT points FROM user_signin WHERE wxid = %s"
cursor.execute(sql_check, (wxid,))
result = cursor.fetchone()
if not result or result[0] < points:
return False
# 扣除积分
sql_update = "UPDATE user_signin SET points = points - %s WHERE wxid = %s"
cursor.execute(sql_update, (points, wxid))
logger.info(f"用户 {wxid} 扣除 {points} 积分")
return True
except Exception as e:
logger.error(f"扣除用户积分失败: {e}")
return False
def is_admin(self, wxid: str) -> bool:
"""检查是否是管理员"""
admins = self.config.get("points", {}).get("admins", [])
return wxid in admins
async def upload_video_to_minio(self, local_file: str, original_filename: str = "") -> str:
"""上传视频到MinIO"""
try:
# 生成唯一文件名
file_ext = Path(local_file).suffix
unique_id = uuid.uuid4().hex
if original_filename:
# 使用原始文件名(去掉扩展名)+ 唯一ID + 扩展名
original_name = Path(original_filename).stem
# 清理文件名中的特殊字符
import re
original_name = re.sub(r'[^\w\-_\.]', '_', original_name)
filename = f"{original_name}_{unique_id}{file_ext}"
else:
filename = f"grok_video_{unique_id}{file_ext}"
object_name = f"videos/{datetime.now().strftime('%Y%m%d')}/{filename}"
# 上传文件
await asyncio.to_thread(
self.minio_client.fput_object,
self.minio_bucket,
object_name,
local_file
)
# 返回访问URL
url = f"http://101.201.65.129:19000/{self.minio_bucket}/{object_name}"
logger.info(f"视频上传成功: {url}")
return url
except S3Error as e:
logger.error(f"上传视频到MinIO失败: {e}")
return ""
async def _queue_worker(self):
"""队列工作线程"""
logger.info("视频生成队列工作线程已启动")
while True:
try:
# 从队列获取任务
task_data = await self.task_queue.get()
bot, task = task_data
# 处理任务
await self._process_video_task(bot, task)
# 标记任务完成
self.task_queue.task_done()
except Exception as e:
logger.error(f"队列工作线程错误: {e}")
await asyncio.sleep(1)
async def _process_video_task(self, bot, task: VideoTask):
"""处理视频生成任务"""
logger.info(f"开始处理视频任务: user={task.user_wxid}, prompt={task.prompt}")
try:
# 下载图片并转换为 base64
image_base64 = await self._download_and_encode_image(bot, task.cdnurl, task.aeskey)
if not image_base64:
await bot.send_text(task.from_wxid, "❌ 无法下载图片")
return
# 调用 Grok API
video_url = await self._call_grok_api(task.prompt, image_base64)
if video_url:
# 下载视频
video_path = await self._download_video(video_url)
if video_path:
# 上传视频到MinIO
logger.info(f"上传视频到MinIO: {video_path}")
video_filename = Path(video_path).name
minio_url = await self.upload_video_to_minio(video_path, video_filename)
# 发送视频文件
logger.info(f"准备发送视频文件: {video_path}")
video_sent = await bot.send_file(task.from_wxid, video_path)
if video_sent:
# 通知MessageLogger记录机器人发送的视频消息
try:
from plugins.MessageLogger.main import MessageLogger
message_logger = MessageLogger.get_instance()
if message_logger and minio_url:
await message_logger.save_bot_message(
task.from_wxid,
f"[视频] {task.prompt}",
"video",
minio_url
)
logger.info(f"已记录机器人视频消息到数据库: {minio_url}")
except Exception as e:
logger.warning(f"记录机器人视频消息失败: {e}")
# 如果启用了积分系统,显示积分信息
points_config = self.config.get("points", {})
if points_config.get("enabled", False):
if self.is_admin(task.user_wxid):
# 管理员免费使用
success_msg = "✅ 视频生成成功!\n🎖️ 管理员免费使用"
else:
# 普通用户显示积分消耗
cost = points_config.get("cost", 50)
remaining_points = self.get_user_points(task.user_wxid)
success_msg = f"✅ 视频生成成功!\n💎 本次消耗:{cost} 积分\n💰 剩余积分:{remaining_points}"
else:
# 积分系统未启用
success_msg = "✅ 视频生成成功!"
await bot.send_text(task.from_wxid, success_msg)
logger.success(f"视频文件发送成功: {video_path}")
# 清理本地文件
try:
Path(video_path).unlink()
logger.info(f"已清理本地视频文件: {video_path}")
except Exception as e:
logger.warning(f"清理本地视频文件失败: {e}")
else:
await bot.send_text(task.from_wxid, "❌ 视频发送失败")
logger.error(f"视频文件发送失败: {video_path}")
else:
await bot.send_text(task.from_wxid, "❌ 视频下载失败")
else:
await bot.send_text(task.from_wxid, "❌ 视频生成失败,请稍后再试")
except Exception as e:
logger.error(f"处理视频任务失败: {e}")
await bot.send_text(task.from_wxid, f"❌ 视频生成失败: {str(e)}")
@on_quote_message(priority=90)
async def handle_video_command(self, bot, message: dict):
"""处理 /视频 命令(引用消息)"""
content = message.get("Content", "").strip()
from_wxid = message.get("FromWxid", "")
sender_wxid = message.get("SenderWxid", "")
is_group = message.get("IsGroup", False)
# 获取实际发送者群聊中使用SenderWxid私聊使用FromWxid
user_wxid = sender_wxid if is_group else from_wxid
# 解析 XML 获取标题和引用消息
try:
root = ET.fromstring(content)
title = root.find(".//title")
if title is None or not title.text:
return
title_text = title.text.strip()
# 检查是否是 /视频 命令
if not title_text.startswith("/视频"):
return
# 检查是否启用
if not self.config["behavior"]["enabled"]:
return False
# 检查群聊过滤
if is_group:
enabled_groups = self.config["behavior"]["enabled_groups"]
disabled_groups = self.config["behavior"]["disabled_groups"]
if from_wxid in disabled_groups:
return False
if enabled_groups and from_wxid not in enabled_groups:
return False
# 提取提示词
prompt = title_text[3:].strip() # 去掉 "/视频"
if not prompt:
await bot.send_text(from_wxid, "❌ 请提供提示词,例如:/视频 让太阳升起来")
return False
# 获取引用消息中的图片信息
refermsg = root.find(".//refermsg")
if refermsg is None:
await bot.send_text(from_wxid, "❌ 请引用一张图片后使用此命令")
return False
# 解析引用消息的内容(需要解码 HTML 实体)
refer_content = refermsg.find("content")
if refer_content is None or not refer_content.text:
await bot.send_text(from_wxid, "❌ 引用的消息中没有图片")
return False
# 解码 HTML 实体
import html
refer_xml = html.unescape(refer_content.text)
refer_root = ET.fromstring(refer_xml)
# 提取图片信息
img = refer_root.find(".//img")
if img is None:
await bot.send_text(from_wxid, "❌ 引用的消息中没有图片")
return False
# 获取图片的 CDN URL 和 AES Key
cdnbigimgurl = img.get("cdnbigimgurl", "")
aeskey = img.get("aeskey", "")
if not cdnbigimgurl or not aeskey:
await bot.send_text(from_wxid, "❌ 无法获取图片信息")
return False
logger.info(f"收到视频生成请求: user={user_wxid}, prompt={prompt}")
except Exception as e:
logger.error(f"解析引用消息失败: {e}")
return
# 检查积分系统
points_config = self.config.get("points", {})
if points_config.get("enabled", False):
# 检查是否是管理员
if not self.is_admin(user_wxid):
# 检查积分
cost = points_config.get("cost", 50)
current_points = self.get_user_points(user_wxid)
if current_points < cost:
await bot.send_text(
from_wxid,
f"❌ 积分不足!\n💰 当前积分:{current_points}\n💎 需要积分:{cost}\n\n请先签到获取积分~"
)
return False
# 扣除积分
if not self.deduct_points(user_wxid, cost):
await bot.send_text(from_wxid, "❌ 积分扣除失败,请稍后重试")
return False
logger.info(f"用户 {user_wxid} 已扣除 {cost} 积分,剩余 {current_points - cost} 积分")
else:
logger.info(f"管理员 {user_wxid} 免费使用")
# 检查队列是否已满
if self.task_queue.full():
await bot.send_text(from_wxid, f"❌ 队列已满({self.task_queue.qsize()}/{self.task_queue.maxsize}),请稍后再试")
# 如果扣除了积分,需要退还
if points_config.get("enabled", False) and not self.is_admin(user_wxid):
# 退还积分(这里简化处理,直接加回去)
try:
with self.get_db_connection() as conn:
with conn.cursor() as cursor:
sql = "UPDATE user_signin SET points = points + %s WHERE wxid = %s"
cursor.execute(sql, (points_config.get("cost", 50), user_wxid))
logger.info(f"队列已满,退还积分给用户 {user_wxid}")
except Exception as e:
logger.error(f"退还积分失败: {e}")
return False
# 创建任务
task = VideoTask(
user_wxid=user_wxid,
from_wxid=from_wxid,
prompt=prompt,
cdnurl=cdnbigimgurl,
aeskey=aeskey,
is_group=is_group,
timestamp=datetime.now()
)
# 添加到队列
try:
await self.task_queue.put((bot, task))
queue_position = self.task_queue.qsize()
if queue_position == 1:
await bot.send_text(from_wxid, "🎥 正在生成视频,请稍候(预计需要几分钟)...")
else:
await bot.send_text(
from_wxid,
f"📋 已加入队列\n🔢 当前排队位置:第 {queue_position}\n⏰ 请耐心等待..."
)
logger.success(f"任务已加入队列: user={user_wxid}, position={queue_position}")
except Exception as e:
logger.error(f"添加任务到队列失败: {e}")
await bot.send_text(from_wxid, "❌ 添加任务失败,请稍后重试")
# 退还积分
if points_config.get("enabled", False) and not self.is_admin(user_wxid):
try:
with self.get_db_connection() as conn:
with conn.cursor() as cursor:
sql = "UPDATE user_signin SET points = points + %s WHERE wxid = %s"
cursor.execute(sql, (points_config.get("cost", 50), user_wxid))
logger.info(f"添加任务失败,退还积分给用户 {user_wxid}")
except Exception as e:
logger.error(f"退还积分失败: {e}")
return False # 不阻止后续处理
async def _download_and_encode_image(self, bot, cdnurl: str, aeskey: str) -> str:
"""下载图片并转换为 base64"""
try:
from datetime import datetime
import uuid
import base64
# 创建临时目录
temp_dir = Path(__file__).parent / "temp"
temp_dir.mkdir(exist_ok=True)
# 生成临时文件名
filename = f"temp_{datetime.now():%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:8]}.jpg"
save_path = str((temp_dir / filename).resolve())
# 使用 CDN 下载 API 下载图片
logger.info(f"正在下载图片: {cdnurl[:50]}...")
success = await bot.cdn_download(cdnurl, aeskey, save_path, file_type=2)
if not success:
# 如果中图下载失败,尝试原图
logger.warning("中图下载失败,尝试下载原图...")
success = await bot.cdn_download(cdnurl, aeskey, save_path, file_type=1)
if not success:
logger.error("图片下载失败")
return ""
# 等待文件写入完成并检查文件是否存在
import asyncio
import os
max_wait = 10 # 最多等待10秒
wait_time = 0
while wait_time < max_wait:
if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
logger.info(f"文件已就绪: {save_path}")
break
await asyncio.sleep(0.5)
wait_time += 0.5
if not os.path.exists(save_path):
logger.error(f"文件下载超时或失败: {save_path}")
return ""
# 读取图片并转换为 base64
with open(save_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode()
# 删除临时文件
try:
Path(save_path).unlink()
except:
pass
return f"data:image/jpeg;base64,{image_data}"
except Exception as e:
logger.error(f"下载图片失败: {e}")
return ""
async def _call_grok_api(self, prompt: str, image_base64: str) -> str:
"""调用 Grok API 生成视频"""
api_key = self.config["api"]["api_key"]
if not api_key:
raise Exception("未配置 API Key")
payload = {
"model": self.config["api"]["model_id"],
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": image_base64}}
]
}
]
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
}
timeout = httpx.Timeout(connect=10.0, read=self.config["api"]["timeout"], write=10.0, pool=10.0)
# 配置代理
connector = None
proxy_config = self.config.get("proxy", {})
if proxy_config.get("enabled", False):
from aiohttp_socks import ProxyConnector
proxy_type = proxy_config.get("type", "socks5").upper()
proxy_host = proxy_config.get("host", "127.0.0.1")
proxy_port = proxy_config.get("port", 7890)
proxy_username = proxy_config.get("username")
proxy_password = proxy_config.get("password")
# 构建代理 URL
if proxy_username and proxy_password:
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
logger.info(f"使用代理: {proxy_type}://{proxy_host}:{proxy_port}")
# httpx 使用不同的代理配置方式
proxy = None
if proxy_config.get("enabled", False):
proxy_type = proxy_config.get("type", "socks5")
proxy_host = proxy_config.get("host", "127.0.0.1")
proxy_port = proxy_config.get("port", 7890)
proxy = f"{proxy_type}://{proxy_host}:{proxy_port}"
logger.info(f"使用代理: {proxy}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy) as client:
response = await client.post(self.api_url, json=payload, headers=headers)
if response.status_code != 200:
raise Exception(f"API 错误: {response.status_code}")
result = response.json()
# 提取视频 URL
content = result["choices"][0]["message"]["content"]
video_url = self._extract_video_url(content)
if not video_url:
raise Exception("未能从响应中提取视频 URL")
logger.info(f"获取到视频 URL: {video_url}")
return video_url
def _extract_video_url(self, content: str) -> str:
"""从响应内容中提取视频 URL"""
# 尝试从 HTML video 标签提取
match = re.search(r'<video[^>]*src=["\']([^"\'>]+)["\']', content, re.IGNORECASE)
if match:
return match.group(1)
# 尝试提取直接的 .mp4 URL
match = re.search(r'(https?://[^\s<>"\')\]]+\.mp4(?:\?[^\s<>"\')\]]*)?)', content, re.IGNORECASE)
if match:
return match.group(1)
return ""
async def _download_video(self, video_url: str) -> str:
"""下载视频到本地"""
from datetime import datetime
import uuid
videos_dir = Path(__file__).parent / "videos"
videos_dir.mkdir(exist_ok=True)
filename = f"grok_{datetime.now():%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:8]}.mp4"
file_path = videos_dir / filename
timeout = httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0)
# 配置代理
proxy = None
proxy_config = self.config.get("proxy", {})
if proxy_config.get("enabled", False):
proxy_type = proxy_config.get("type", "socks5")
proxy_host = proxy_config.get("host", "127.0.0.1")
proxy_port = proxy_config.get("port", 7890)
proxy = f"{proxy_type}://{proxy_host}:{proxy_port}"
logger.debug(f"下载视频使用代理: {proxy}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy) as client:
response = await client.get(video_url)
response.raise_for_status()
with open(file_path, "wb") as f:
f.write(response.content)
return str(file_path.resolve())