1085 lines
47 KiB
Python
1085 lines
47 KiB
Python
"""
|
||
签到插件
|
||
|
||
用户发送签到关键词即可进行签到,随机获得3-10积分
|
||
每天只能签到一次,支持连续签到奖励
|
||
"""
|
||
|
||
import asyncio
|
||
import random
|
||
import tomllib
|
||
from datetime import date, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Optional, Tuple, Dict, List
|
||
import os
|
||
import aiohttp
|
||
from io import BytesIO
|
||
|
||
import pymysql
|
||
from loguru import logger
|
||
from utils.plugin_base import PluginBase
|
||
from utils.decorators import on_text_message
|
||
from utils.redis_cache import get_cache
|
||
from WechatHook import WechatHookClient
|
||
|
||
try:
|
||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||
PIL_AVAILABLE = True
|
||
except ImportError:
|
||
PIL_AVAILABLE = False
|
||
logger.warning("PIL库未安装,将使用文本模式")
|
||
|
||
|
||
class SignInPlugin(PluginBase):
|
||
"""签到插件"""
|
||
|
||
description = "签到插件 - 每日签到获取积分"
|
||
author = "ShiHao"
|
||
version = "1.0.0"
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.config = None
|
||
self.db_config = 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.db_config = self.config["database"]
|
||
|
||
# 创建临时文件夹
|
||
self.temp_dir = Path(__file__).parent / "temp"
|
||
self.temp_dir.mkdir(exist_ok=True)
|
||
|
||
# 图片文件夹
|
||
self.images_dir = Path(__file__).parent / "images"
|
||
|
||
logger.success("签到插件初始化完成")
|
||
|
||
def get_db_connection(self):
|
||
"""获取数据库连接"""
|
||
return pymysql.connect(
|
||
host=self.db_config["host"],
|
||
port=self.db_config["port"],
|
||
user=self.db_config["user"],
|
||
password=self.db_config["password"],
|
||
database=self.db_config["database"],
|
||
charset=self.db_config["charset"],
|
||
autocommit=True
|
||
)
|
||
|
||
def get_user_info(self, wxid: str) -> Optional[dict]:
|
||
"""获取用户信息"""
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
|
||
sql = """
|
||
SELECT wxid, nickname, city, points, last_signin_date,
|
||
signin_streak, total_signin_days
|
||
FROM user_signin
|
||
WHERE wxid = %s
|
||
"""
|
||
cursor.execute(sql, (wxid,))
|
||
return cursor.fetchone()
|
||
except Exception as e:
|
||
logger.error(f"获取用户信息失败: {e}")
|
||
return None
|
||
|
||
def create_or_update_user(self, wxid: str, nickname: str = "", city: str = "") -> bool:
|
||
"""创建或更新用户信息"""
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
with conn.cursor() as cursor:
|
||
# 使用 ON DUPLICATE KEY UPDATE 处理用户存在的情况
|
||
sql = """
|
||
INSERT INTO user_signin (wxid, nickname, city, points, last_signin_date,
|
||
signin_streak, total_signin_days)
|
||
VALUES (%s, %s, %s, 0, NULL, 0, 0)
|
||
ON DUPLICATE KEY UPDATE
|
||
nickname = CASE
|
||
WHEN VALUES(nickname) != '' THEN VALUES(nickname)
|
||
ELSE nickname
|
||
END,
|
||
city = CASE
|
||
WHEN VALUES(city) != '' THEN VALUES(city)
|
||
ELSE city
|
||
END
|
||
"""
|
||
cursor.execute(sql, (wxid, nickname, city))
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"创建/更新用户失败: {e}")
|
||
return False
|
||
|
||
def update_user_city(self, wxid: str, city: str) -> bool:
|
||
"""更新用户城市信息(支持覆盖更新)"""
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
with conn.cursor() as cursor:
|
||
sql = """
|
||
INSERT INTO user_signin (wxid, city, points, last_signin_date,
|
||
signin_streak, total_signin_days)
|
||
VALUES (%s, %s, 0, NULL, 0, 0)
|
||
ON DUPLICATE KEY UPDATE
|
||
city = VALUES(city),
|
||
updated_at = NOW()
|
||
"""
|
||
cursor.execute(sql, (wxid, city))
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"更新用户城市失败: {e}")
|
||
return False
|
||
|
||
def update_user_nickname(self, wxid: str, nickname: str) -> bool:
|
||
"""更新用户昵称"""
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
with conn.cursor() as cursor:
|
||
sql = """
|
||
UPDATE user_signin
|
||
SET nickname = %s, updated_at = NOW()
|
||
WHERE wxid = %s
|
||
"""
|
||
cursor.execute(sql, (nickname, wxid))
|
||
return cursor.rowcount > 0
|
||
except Exception as e:
|
||
logger.error(f"更新用户昵称失败: {e}")
|
||
return False
|
||
|
||
async def get_user_nickname_from_group(self, client: WechatHookClient,
|
||
group_wxid: str, user_wxid: str) -> str:
|
||
"""从群聊中获取用户昵称(优先使用缓存)"""
|
||
try:
|
||
# 动态获取缓存实例(由 MessageLogger 初始化)
|
||
redis_cache = get_cache()
|
||
|
||
# 1. 先尝试从 Redis 缓存获取
|
||
if redis_cache and redis_cache.enabled:
|
||
cached_info = redis_cache.get_user_basic_info(group_wxid, user_wxid)
|
||
if cached_info and cached_info.get("nickname"):
|
||
logger.debug(f"[缓存命中] {user_wxid}: {cached_info['nickname']}")
|
||
return cached_info["nickname"]
|
||
|
||
# 2. 缓存未命中,调用 API 获取
|
||
logger.debug(f"[缓存未命中] 调用API获取用户昵称: {user_wxid}")
|
||
user_info = await client.get_user_info_in_chatroom(group_wxid, user_wxid)
|
||
|
||
if user_info:
|
||
# 从返回的详细信息中提取昵称
|
||
nickname = user_info.get("nickName", {}).get("string", "")
|
||
|
||
if nickname:
|
||
logger.success(f"API获取用户昵称成功: {user_wxid} -> {nickname}")
|
||
# 3. 将用户信息存入缓存
|
||
if redis_cache and redis_cache.enabled:
|
||
redis_cache.set_user_info(group_wxid, user_wxid, user_info)
|
||
logger.debug(f"[已缓存] {user_wxid}: {nickname}")
|
||
return nickname
|
||
else:
|
||
logger.warning(f"用户 {user_wxid} 的昵称字段为空")
|
||
else:
|
||
logger.warning(f"未找到用户 {user_wxid} 在群 {group_wxid} 中的信息")
|
||
|
||
return ""
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取群成员昵称失败: {e}")
|
||
return ""
|
||
|
||
async def download_avatar(self, avatar_url: str, user_wxid: str) -> Optional[str]:
|
||
"""下载用户头像"""
|
||
if not avatar_url:
|
||
return None
|
||
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(avatar_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||
if resp.status == 200:
|
||
avatar_data = await resp.read()
|
||
avatar_path = self.temp_dir / f"avatar_{user_wxid}.jpg"
|
||
with open(avatar_path, "wb") as f:
|
||
f.write(avatar_data)
|
||
return str(avatar_path)
|
||
except Exception as e:
|
||
logger.error(f"下载头像失败: {e}")
|
||
return None
|
||
|
||
def get_random_background(self) -> Optional[str]:
|
||
"""随机选择背景图片"""
|
||
if not self.images_dir.exists():
|
||
return None
|
||
|
||
image_files = list(self.images_dir.glob("*.jpg")) + list(self.images_dir.glob("*.png"))
|
||
if not image_files:
|
||
return None
|
||
|
||
return str(random.choice(image_files))
|
||
|
||
async def generate_signin_card(self, user_info: dict, points_earned: int,
|
||
streak: int, avatar_url: str = None) -> Optional[str]:
|
||
"""生成签到卡片(现代化横屏设计)"""
|
||
if not PIL_AVAILABLE:
|
||
return None
|
||
|
||
try:
|
||
# 创建横屏画布 (16:9 比例)
|
||
canvas_width, canvas_height = 800, 450
|
||
|
||
# 获取背景图片
|
||
bg_path = self.get_random_background()
|
||
if not bg_path:
|
||
# 创建默认渐变背景
|
||
img = Image.new('RGB', (canvas_width, canvas_height), (135, 206, 235))
|
||
else:
|
||
bg_img = Image.open(bg_path)
|
||
|
||
# 不拉伸图片,使用裁剪居中的方式
|
||
bg_width, bg_height = bg_img.size
|
||
|
||
# 计算缩放比例,保持宽高比
|
||
scale = max(canvas_width / bg_width, canvas_height / bg_height)
|
||
new_width = int(bg_width * scale)
|
||
new_height = int(bg_height * scale)
|
||
|
||
# 缩放图片
|
||
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
# 居中裁剪
|
||
left = (new_width - canvas_width) // 2
|
||
top = (new_height - canvas_height) // 2
|
||
img = bg_img.crop((left, top, left + canvas_width, top + canvas_height))
|
||
|
||
# 轻微模糊背景
|
||
img = img.filter(ImageFilter.GaussianBlur(radius=1.5))
|
||
|
||
img = img.convert('RGBA')
|
||
|
||
# 创建白色卡片区域(现代化设计)
|
||
card_x, card_y = 50, 50
|
||
card_width, card_height = canvas_width - 100, canvas_height - 100
|
||
|
||
# 绘制白色半透明卡片背景
|
||
card_overlay = Image.new('RGBA', (canvas_width, canvas_height), (0, 0, 0, 0))
|
||
card_draw = ImageDraw.Draw(card_overlay)
|
||
|
||
# 圆角白色卡片
|
||
card_draw.rounded_rectangle(
|
||
(card_x, card_y, card_x + card_width, card_y + card_height),
|
||
radius=20, fill=(255, 255, 255, 240)
|
||
)
|
||
|
||
img = Image.alpha_composite(img, card_overlay)
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# 加载字体
|
||
try:
|
||
title_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 32)
|
||
text_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 20)
|
||
small_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 16)
|
||
big_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 24)
|
||
except:
|
||
title_font = ImageFont.load_default()
|
||
text_font = ImageFont.load_default()
|
||
small_font = ImageFont.load_default()
|
||
big_font = ImageFont.load_default()
|
||
|
||
# 下载并处理头像
|
||
avatar_img = None
|
||
if avatar_url:
|
||
avatar_path = await self.download_avatar(avatar_url, user_info.get('wxid', ''))
|
||
if avatar_path and os.path.exists(avatar_path):
|
||
try:
|
||
avatar_img = Image.open(avatar_path)
|
||
avatar_img = avatar_img.resize((80, 80))
|
||
# 创建圆形头像
|
||
mask = Image.new('L', (80, 80), 0)
|
||
mask_draw = ImageDraw.Draw(mask)
|
||
mask_draw.ellipse((0, 0, 80, 80), fill=255)
|
||
avatar_img.putalpha(mask)
|
||
except Exception as e:
|
||
logger.error(f"处理头像失败: {e}")
|
||
avatar_img = None
|
||
|
||
# 绘制头像
|
||
avatar_x, avatar_y = 80, 80
|
||
if avatar_img:
|
||
img.paste(avatar_img, (avatar_x, avatar_y), avatar_img)
|
||
else:
|
||
# 默认头像
|
||
draw.ellipse((avatar_x, avatar_y, avatar_x + 80, avatar_y + 80),
|
||
fill=(200, 200, 200), outline=(150, 150, 150), width=2)
|
||
draw.text((avatar_x + 40, avatar_y + 40), "头像", font=text_font,
|
||
fill=(100, 100, 100), anchor="mm")
|
||
|
||
# 用户名和日期
|
||
nickname = user_info.get('nickname', '用户')
|
||
today = datetime.now().strftime("%m/%d")
|
||
|
||
# 用户名
|
||
draw.text((avatar_x + 100, avatar_y + 10), nickname, font=title_font, fill=(50, 50, 50))
|
||
|
||
# 日期(右上角,调整位置避免超出边界)
|
||
date_bbox = draw.textbbox((0, 0), today, font=big_font)
|
||
date_width = date_bbox[2] - date_bbox[0]
|
||
draw.text((canvas_width - date_width - 70, 70), today, font=big_font, fill=(100, 100, 100))
|
||
|
||
# 签到成功信息
|
||
draw.text((avatar_x + 100, avatar_y + 50), "签到成功!", font=text_font, fill=(76, 175, 80))
|
||
draw.text((avatar_x + 100, avatar_y + 80), f"+{points_earned} 积分", font=text_font, fill=(255, 152, 0))
|
||
|
||
# 积分信息区域
|
||
total_points = user_info.get('points', 0)
|
||
info_y = 220
|
||
|
||
draw.text((100, info_y), f"总积分: {total_points}", font=text_font, fill=(50, 50, 50))
|
||
draw.text((350, info_y), f"连续签到: {streak}天", font=text_font, fill=(50, 50, 50))
|
||
|
||
# 进度条
|
||
progress_x, progress_y = 100, 270
|
||
progress_width, progress_height = 600, 20
|
||
|
||
# 进度条背景
|
||
draw.rounded_rectangle(
|
||
(progress_x, progress_y, progress_x + progress_width, progress_y + progress_height),
|
||
radius=10, fill=(230, 230, 230)
|
||
)
|
||
|
||
# 进度条填充
|
||
progress = min(streak / 30, 1.0)
|
||
fill_width = int(progress_width * progress)
|
||
if fill_width > 0:
|
||
draw.rounded_rectangle(
|
||
(progress_x, progress_y, progress_x + fill_width, progress_y + progress_height),
|
||
radius=10, fill=(76, 175, 80)
|
||
)
|
||
|
||
# 进度条文字
|
||
progress_text = f"{streak}/30 天"
|
||
draw.text((progress_x + progress_width//2, progress_y + progress_height + 15),
|
||
progress_text, font=small_font, fill=(100, 100, 100), anchor="mm")
|
||
|
||
# 底部信息
|
||
city = user_info.get('city', '未设置')
|
||
draw.text((100, 340), f"城市: {city}", font=small_font, fill=(120, 120, 120))
|
||
|
||
# 保存图片
|
||
output_path = self.temp_dir / f"signin_{user_info.get('wxid', 'unknown')}_{int(datetime.now().timestamp())}.jpg"
|
||
img = img.convert('RGB')
|
||
img.save(output_path, 'JPEG', quality=95)
|
||
|
||
return str(output_path)
|
||
|
||
except Exception as e:
|
||
logger.error(f"生成签到卡片失败: {e}")
|
||
return None
|
||
|
||
async def generate_profile_card(self, user_info: dict, avatar_url: str = None) -> Optional[str]:
|
||
"""生成个人信息卡片(现代化横屏设计,与签到卡片保持一致)"""
|
||
if not PIL_AVAILABLE:
|
||
return None
|
||
|
||
try:
|
||
# 创建横屏画布 (16:9 比例) - 与签到卡片一致
|
||
canvas_width, canvas_height = 800, 450
|
||
|
||
# 获取背景图片
|
||
bg_path = self.get_random_background()
|
||
if not bg_path:
|
||
# 创建默认渐变背景
|
||
img = Image.new('RGB', (canvas_width, canvas_height), (135, 206, 235))
|
||
else:
|
||
bg_img = Image.open(bg_path)
|
||
|
||
# 不拉伸图片,使用裁剪居中的方式
|
||
bg_width, bg_height = bg_img.size
|
||
|
||
# 计算缩放比例,保持宽高比
|
||
scale = max(canvas_width / bg_width, canvas_height / bg_height)
|
||
new_width = int(bg_width * scale)
|
||
new_height = int(bg_height * scale)
|
||
|
||
# 缩放图片
|
||
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
# 居中裁剪
|
||
left = (new_width - canvas_width) // 2
|
||
top = (new_height - canvas_height) // 2
|
||
img = bg_img.crop((left, top, left + canvas_width, top + canvas_height))
|
||
|
||
# 轻微模糊背景
|
||
img = img.filter(ImageFilter.GaussianBlur(radius=1.5))
|
||
|
||
img = img.convert('RGBA')
|
||
|
||
# 创建白色卡片区域(现代化设计)
|
||
card_x, card_y = 50, 50
|
||
card_width, card_height = canvas_width - 100, canvas_height - 100
|
||
|
||
# 绘制白色半透明卡片背景
|
||
card_overlay = Image.new('RGBA', (canvas_width, canvas_height), (0, 0, 0, 0))
|
||
card_draw = ImageDraw.Draw(card_overlay)
|
||
|
||
# 圆角白色卡片
|
||
card_draw.rounded_rectangle(
|
||
(card_x, card_y, card_x + card_width, card_y + card_height),
|
||
radius=20, fill=(255, 255, 255, 240)
|
||
)
|
||
|
||
img = Image.alpha_composite(img, card_overlay)
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# 加载字体
|
||
try:
|
||
title_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 32)
|
||
text_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 20)
|
||
small_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 16)
|
||
big_font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 24)
|
||
except:
|
||
title_font = ImageFont.load_default()
|
||
text_font = ImageFont.load_default()
|
||
small_font = ImageFont.load_default()
|
||
big_font = ImageFont.load_default()
|
||
|
||
# 下载并处理头像
|
||
avatar_img = None
|
||
if avatar_url:
|
||
avatar_path = await self.download_avatar(avatar_url, user_info.get('wxid', ''))
|
||
if avatar_path and os.path.exists(avatar_path):
|
||
try:
|
||
avatar_img = Image.open(avatar_path)
|
||
avatar_img = avatar_img.resize((80, 80))
|
||
# 创建圆形头像
|
||
mask = Image.new('L', (80, 80), 0)
|
||
mask_draw = ImageDraw.Draw(mask)
|
||
mask_draw.ellipse((0, 0, 80, 80), fill=255)
|
||
avatar_img.putalpha(mask)
|
||
except Exception as e:
|
||
logger.error(f"处理头像失败: {e}")
|
||
avatar_img = None
|
||
|
||
# 绘制头像
|
||
avatar_x, avatar_y = 80, 80
|
||
if avatar_img:
|
||
img.paste(avatar_img, (avatar_x, avatar_y), avatar_img)
|
||
else:
|
||
# 默认头像
|
||
draw.ellipse((avatar_x, avatar_y, avatar_x + 80, avatar_y + 80),
|
||
fill=(200, 200, 200), outline=(150, 150, 150), width=2)
|
||
draw.text((avatar_x + 40, avatar_y + 40), "头像", font=text_font,
|
||
fill=(100, 100, 100), anchor="mm")
|
||
|
||
# 用户名和日期
|
||
nickname = user_info.get('nickname', '用户')
|
||
today = datetime.now().strftime("%m/%d")
|
||
|
||
# 用户名
|
||
draw.text((avatar_x + 100, avatar_y + 10), nickname, font=title_font, fill=(50, 50, 50))
|
||
|
||
# 日期(右上角,调整位置避免超出边界)
|
||
date_bbox = draw.textbbox((0, 0), today, font=big_font)
|
||
date_width = date_bbox[2] - date_bbox[0]
|
||
draw.text((canvas_width - date_width - 70, 70), today, font=big_font, fill=(100, 100, 100))
|
||
|
||
# 个人信息标题
|
||
draw.text((avatar_x + 100, avatar_y + 50), "个人信息", font=text_font, fill=(33, 150, 243))
|
||
|
||
# 积分信息区域
|
||
total_points = user_info.get('points', 0)
|
||
streak = user_info.get('signin_streak', 0)
|
||
total_days = user_info.get('total_signin_days', 0)
|
||
info_y = 220
|
||
|
||
draw.text((100, info_y), f"总积分: {total_points}", font=text_font, fill=(50, 50, 50))
|
||
draw.text((350, info_y), f"连续签到: {streak}天", font=text_font, fill=(50, 50, 50))
|
||
draw.text((100, info_y + 30), f"累计签到: {total_days}天", font=text_font, fill=(50, 50, 50))
|
||
|
||
# 进度条
|
||
progress_x, progress_y = 100, 290
|
||
progress_width, progress_height = 600, 20
|
||
|
||
# 进度条背景
|
||
draw.rounded_rectangle(
|
||
(progress_x, progress_y, progress_x + progress_width, progress_y + progress_height),
|
||
radius=10, fill=(230, 230, 230)
|
||
)
|
||
|
||
# 进度条填充
|
||
progress = min(streak / 30, 1.0)
|
||
fill_width = int(progress_width * progress)
|
||
if fill_width > 0:
|
||
draw.rounded_rectangle(
|
||
(progress_x, progress_y, progress_x + fill_width, progress_y + progress_height),
|
||
radius=10, fill=(33, 150, 243)
|
||
)
|
||
|
||
# 进度条文字
|
||
progress_text = f"{streak}/30 天"
|
||
draw.text((progress_x + progress_width//2, progress_y + progress_height + 15),
|
||
progress_text, font=small_font, fill=(100, 100, 100), anchor="mm")
|
||
|
||
# 底部信息
|
||
city = user_info.get('city', '未设置')
|
||
draw.text((100, 360), f"城市: {city}", font=small_font, fill=(120, 120, 120))
|
||
|
||
# 保存图片
|
||
output_path = self.temp_dir / f"profile_{user_info.get('wxid', 'unknown')}_{int(datetime.now().timestamp())}.jpg"
|
||
img = img.convert('RGB')
|
||
img.save(output_path, 'JPEG', quality=95)
|
||
|
||
return str(output_path)
|
||
|
||
except Exception as e:
|
||
logger.error(f"生成个人信息卡片失败: {e}")
|
||
return None
|
||
|
||
async def send_image_file(self, client, to_wxid: str, image_path: str) -> bool:
|
||
"""发送图片文件(使用API文档中的正确格式)"""
|
||
try:
|
||
import os
|
||
|
||
# 检查文件是否存在
|
||
if not os.path.exists(image_path):
|
||
logger.error(f"图片文件不存在: {image_path}")
|
||
return False
|
||
|
||
# 获取文件大小
|
||
file_size = os.path.getsize(image_path)
|
||
logger.info(f"准备发送图片文件: {image_path}, 大小: {file_size} bytes")
|
||
|
||
# 直接使用API文档中的格式发送图片 (type=11040)
|
||
data = {
|
||
"to_wxid": to_wxid,
|
||
"file": image_path
|
||
}
|
||
|
||
logger.info(f"使用API文档格式发送图片: type=11040, data={data}")
|
||
result = await client._send_data_async(11040, data)
|
||
|
||
if result:
|
||
logger.success(f"图片发送成功: {image_path}")
|
||
return True
|
||
else:
|
||
logger.error(f"图片发送失败: {image_path}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"发送图片文件失败: {e}")
|
||
import traceback
|
||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||
return False
|
||
|
||
def is_admin(self, wxid: str) -> bool:
|
||
"""检查用户是否是管理员"""
|
||
admins = self.config["signin"].get("admins", [])
|
||
return wxid in admins
|
||
|
||
def check_can_signin(self, wxid: str) -> Tuple[bool, Optional[dict]]:
|
||
"""检查用户是否可以签到"""
|
||
# 检查是否是管理员且允许无限制签到
|
||
if self.config["signin"].get("admin_unlimited_signin", False) and self.is_admin(wxid):
|
||
user_info = self.get_user_info(wxid)
|
||
return True, user_info # 管理员可以无限制签到
|
||
|
||
user_info = self.get_user_info(wxid)
|
||
if not user_info:
|
||
return True, None # 新用户可以签到
|
||
|
||
last_signin = user_info.get("last_signin_date")
|
||
if not last_signin:
|
||
return True, user_info # 从未签到过
|
||
|
||
today = date.today()
|
||
if isinstance(last_signin, str):
|
||
last_signin = datetime.strptime(last_signin, "%Y-%m-%d").date()
|
||
|
||
return last_signin < today, user_info
|
||
|
||
def calculate_signin_reward(self, current_streak: int) -> Tuple[int, int]:
|
||
"""计算签到奖励"""
|
||
# 基础随机积分
|
||
base_points = random.randint(
|
||
self.config["signin"]["min_points"],
|
||
self.config["signin"]["max_points"]
|
||
)
|
||
|
||
# 连续签到奖励
|
||
bonus_points = 0
|
||
bonus_streak_days = self.config["signin"]["bonus_streak_days"]
|
||
if current_streak > 0 and (current_streak + 1) % bonus_streak_days == 0:
|
||
bonus_points = self.config["signin"]["bonus_points"]
|
||
|
||
return base_points, bonus_points
|
||
|
||
def update_signin_record(self, wxid: str, nickname: str, points_earned: int,
|
||
new_streak: int, user_info: Optional[dict] = None) -> bool:
|
||
"""更新签到记录"""
|
||
try:
|
||
with self.get_db_connection() as conn:
|
||
with conn.cursor() as cursor:
|
||
today = date.today()
|
||
|
||
# 更新用户签到表
|
||
if user_info:
|
||
# 更新现有用户
|
||
new_points = user_info["points"] + points_earned
|
||
new_total_days = user_info["total_signin_days"] + 1
|
||
|
||
sql_update = """
|
||
UPDATE user_signin
|
||
SET nickname = CASE WHEN %s != '' THEN %s ELSE nickname END,
|
||
points = %s,
|
||
last_signin_date = %s,
|
||
signin_streak = %s,
|
||
total_signin_days = %s,
|
||
updated_at = NOW()
|
||
WHERE wxid = %s
|
||
"""
|
||
cursor.execute(sql_update, (
|
||
nickname, nickname, new_points, today,
|
||
new_streak, new_total_days, wxid
|
||
))
|
||
else:
|
||
# 新用户
|
||
sql_insert = """
|
||
INSERT INTO user_signin
|
||
(wxid, nickname, points, last_signin_date, signin_streak, total_signin_days)
|
||
VALUES (%s, %s, %s, %s, %s, 1)
|
||
"""
|
||
cursor.execute(sql_insert, (
|
||
wxid, nickname, points_earned, today, new_streak
|
||
))
|
||
|
||
# 插入签到记录(管理员无限制签到时使用 ON DUPLICATE KEY UPDATE)
|
||
if self.config["signin"].get("admin_unlimited_signin", False) and self.is_admin(wxid):
|
||
# 管理员无限制签到,更新现有记录而不是插入新记录
|
||
sql_record = """
|
||
INSERT INTO signin_records
|
||
(wxid, nickname, signin_date, points_earned, signin_streak)
|
||
VALUES (%s, %s, %s, %s, %s)
|
||
ON DUPLICATE KEY UPDATE
|
||
points_earned = points_earned + VALUES(points_earned),
|
||
signin_streak = VALUES(signin_streak)
|
||
"""
|
||
else:
|
||
# 普通用户,正常插入
|
||
sql_record = """
|
||
INSERT INTO signin_records
|
||
(wxid, nickname, signin_date, points_earned, signin_streak)
|
||
VALUES (%s, %s, %s, %s, %s)
|
||
"""
|
||
|
||
cursor.execute(sql_record, (
|
||
wxid, nickname, today, points_earned, new_streak
|
||
))
|
||
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"更新签到记录失败: {e}")
|
||
return False
|
||
|
||
def calculate_new_streak(self, user_info: Optional[dict]) -> int:
|
||
"""计算新的连续签到天数"""
|
||
if not user_info or not user_info.get("last_signin_date"):
|
||
return 1 # 首次签到
|
||
|
||
last_signin = user_info["last_signin_date"]
|
||
if isinstance(last_signin, str):
|
||
last_signin = datetime.strptime(last_signin, "%Y-%m-%d").date()
|
||
|
||
yesterday = date.today() - timedelta(days=1)
|
||
|
||
if last_signin == yesterday:
|
||
# 连续签到
|
||
return user_info["signin_streak"] + 1
|
||
else:
|
||
# 中断了,重新开始
|
||
return 1
|
||
|
||
@on_text_message(priority=60)
|
||
async def handle_messages(self, client: WechatHookClient, 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
|
||
|
||
# 检查是否是签到关键词(精确匹配)
|
||
signin_keywords = self.config["signin"]["keywords"]
|
||
if content in signin_keywords:
|
||
await self.handle_signin(client, message, user_wxid, from_wxid, is_group)
|
||
return False
|
||
|
||
# 检查是否是个人信息查询(精确匹配)
|
||
profile_keywords = self.config["signin"]["profile_keywords"]
|
||
logger.info(f"检查个人信息关键词: content='{content}', keywords={profile_keywords}")
|
||
if content in profile_keywords:
|
||
logger.info(f"匹配到个人信息查询关键词: {content}")
|
||
await self.handle_profile_query(client, message, user_wxid, from_wxid, is_group)
|
||
return False
|
||
|
||
# 检查是否是城市注册
|
||
register_keywords = self.config["signin"]["register_keywords"]
|
||
if any(content.startswith(keyword) for keyword in register_keywords):
|
||
await self.handle_city_register(client, message, user_wxid, from_wxid, content)
|
||
return False
|
||
|
||
|
||
return True # 不是相关消息,继续处理
|
||
|
||
async def handle_signin(self, client: WechatHookClient, message: dict,
|
||
user_wxid: str, from_wxid: str, is_group: bool):
|
||
"""处理签到消息"""
|
||
logger.info(f"用户 {user_wxid} 尝试签到")
|
||
|
||
try:
|
||
# 获取用户昵称
|
||
nickname = ""
|
||
if is_group:
|
||
nickname = await self.get_user_nickname_from_group(client, from_wxid, user_wxid)
|
||
|
||
# 检查是否可以签到
|
||
can_signin, user_info = self.check_can_signin(user_wxid)
|
||
|
||
# 更新昵称(如果获取到了新昵称)
|
||
if nickname and user_info and user_info.get("nickname") != nickname:
|
||
self.update_user_nickname(user_wxid, nickname)
|
||
|
||
if not can_signin:
|
||
# 今天已经签到过了
|
||
current_points = user_info["points"] if user_info else 0
|
||
current_streak = user_info["signin_streak"] if user_info else 0
|
||
|
||
reply = self.config["messages"]["already_signed"].format(
|
||
total_points=current_points,
|
||
streak=current_streak
|
||
)
|
||
await client.send_text(from_wxid, reply)
|
||
return
|
||
|
||
# 确保用户存在
|
||
if not user_info:
|
||
self.create_or_update_user(user_wxid, nickname)
|
||
user_info = self.get_user_info(user_wxid)
|
||
|
||
# 计算连续签到天数
|
||
new_streak = self.calculate_new_streak(user_info)
|
||
|
||
# 计算奖励积分
|
||
base_points, bonus_points = self.calculate_signin_reward(
|
||
user_info["signin_streak"] if user_info else 0
|
||
)
|
||
total_earned = base_points + bonus_points
|
||
|
||
# 更新签到记录
|
||
if self.update_signin_record(user_wxid, nickname, total_earned, new_streak, user_info):
|
||
# 获取更新后的积分
|
||
updated_user = self.get_user_info(user_wxid)
|
||
current_points = updated_user["points"] if updated_user else total_earned
|
||
updated_user["points"] = current_points
|
||
|
||
# 尝试获取用户头像(优先使用缓存)
|
||
avatar_url = None
|
||
if is_group:
|
||
try:
|
||
redis_cache = get_cache()
|
||
# 先从缓存获取
|
||
if redis_cache and redis_cache.enabled:
|
||
cached_info = redis_cache.get_user_basic_info(from_wxid, user_wxid)
|
||
if cached_info:
|
||
avatar_url = cached_info.get("avatar_url", "")
|
||
# 缓存未命中则调用 API
|
||
if not avatar_url:
|
||
user_detail = await client.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||
if user_detail:
|
||
avatar_url = user_detail.get("bigHeadImgUrl", "")
|
||
# 存入缓存
|
||
if redis_cache and redis_cache.enabled:
|
||
redis_cache.set_user_info(from_wxid, user_wxid, user_detail)
|
||
except Exception as e:
|
||
logger.warning(f"获取用户头像失败: {e}")
|
||
|
||
# 尝试生成图片卡片
|
||
logger.info(f"开始生成签到卡片: user={updated_user}, earned={total_earned}, streak={new_streak}")
|
||
card_path = await self.generate_signin_card(updated_user, total_earned, new_streak, avatar_url)
|
||
logger.info(f"签到卡片生成结果: {card_path}")
|
||
|
||
if card_path and os.path.exists(card_path):
|
||
logger.info(f"签到卡片文件存在,准备发送: {card_path}")
|
||
# 发送图片卡片
|
||
try:
|
||
success = await self.send_image_file(client, from_wxid, card_path)
|
||
if success:
|
||
logger.success(f"用户 {user_wxid} 签到成功,发送图片卡片")
|
||
else:
|
||
raise Exception("CDN图片发送失败")
|
||
|
||
# 暂时不删除临时文件,便于调试
|
||
logger.info(f"签到卡片已保存: {card_path}")
|
||
# try:
|
||
# os.remove(card_path)
|
||
# except:
|
||
# pass
|
||
except Exception as e:
|
||
logger.error(f"发送图片卡片失败: {e}")
|
||
# 发送文本消息作为备用
|
||
reply = self.config["messages"]["success"].format(
|
||
points=base_points,
|
||
total_points=current_points,
|
||
streak=new_streak
|
||
)
|
||
if bonus_points > 0:
|
||
bonus_msg = self.config["messages"]["bonus_message"].format(
|
||
days=self.config["signin"]["bonus_streak_days"],
|
||
bonus=bonus_points
|
||
)
|
||
reply += bonus_msg
|
||
await client.send_text(from_wxid, reply)
|
||
else:
|
||
# 生成图片失败,发送文本消息
|
||
reply = self.config["messages"]["success"].format(
|
||
points=base_points,
|
||
total_points=current_points,
|
||
streak=new_streak
|
||
)
|
||
if bonus_points > 0:
|
||
bonus_msg = self.config["messages"]["bonus_message"].format(
|
||
days=self.config["signin"]["bonus_streak_days"],
|
||
bonus=bonus_points
|
||
)
|
||
reply += bonus_msg
|
||
await client.send_text(from_wxid, reply)
|
||
logger.success(f"用户 {user_wxid} 签到成功,发送文本消息")
|
||
else:
|
||
# 签到失败
|
||
await client.send_text(from_wxid, self.config["messages"]["error"])
|
||
logger.error(f"用户 {user_wxid} 签到失败")
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理签到消息失败: {e}")
|
||
await client.send_text(from_wxid, self.config["messages"]["error"])
|
||
|
||
async def handle_profile_query(self, client: WechatHookClient, message: dict,
|
||
user_wxid: str, from_wxid: str, is_group: bool):
|
||
"""处理个人信息查询"""
|
||
logger.info(f"用户 {user_wxid} 查询个人信息")
|
||
|
||
try:
|
||
# 获取用户昵称
|
||
nickname = ""
|
||
if is_group:
|
||
nickname = await self.get_user_nickname_from_group(client, from_wxid, user_wxid)
|
||
|
||
# 获取用户信息
|
||
user_info = self.get_user_info(user_wxid)
|
||
|
||
if not user_info:
|
||
# 用户不存在,创建新用户
|
||
self.create_or_update_user(user_wxid, nickname)
|
||
user_info = self.get_user_info(user_wxid)
|
||
else:
|
||
# 更新昵称(如果获取到了新昵称)
|
||
if nickname and user_info.get("nickname") != nickname:
|
||
self.update_user_nickname(user_wxid, nickname)
|
||
user_info["nickname"] = nickname
|
||
|
||
# 尝试获取用户头像(优先使用缓存)
|
||
avatar_url = None
|
||
if is_group:
|
||
try:
|
||
redis_cache = get_cache()
|
||
# 先从缓存获取
|
||
if redis_cache and redis_cache.enabled:
|
||
cached_info = redis_cache.get_user_basic_info(from_wxid, user_wxid)
|
||
if cached_info:
|
||
avatar_url = cached_info.get("avatar_url", "")
|
||
# 缓存未命中则调用 API
|
||
if not avatar_url:
|
||
user_detail = await client.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||
if user_detail:
|
||
avatar_url = user_detail.get("bigHeadImgUrl", "")
|
||
# 存入缓存
|
||
if redis_cache and redis_cache.enabled:
|
||
redis_cache.set_user_info(from_wxid, user_wxid, user_detail)
|
||
except Exception as e:
|
||
logger.warning(f"获取用户头像失败: {e}")
|
||
|
||
# 尝试生成个人信息卡片
|
||
logger.info(f"PIL_AVAILABLE: {PIL_AVAILABLE}")
|
||
card_path = await self.generate_profile_card(user_info, avatar_url)
|
||
logger.info(f"生成的卡片路径: {card_path}")
|
||
|
||
if card_path and os.path.exists(card_path):
|
||
# 发送图片卡片
|
||
try:
|
||
success = await self.send_image_file(client, from_wxid, card_path)
|
||
if success:
|
||
logger.success(f"用户 {user_wxid} 个人信息查询成功,发送图片卡片")
|
||
else:
|
||
raise Exception("CDN图片发送失败")
|
||
|
||
# 暂时不删除临时文件,便于调试
|
||
logger.info(f"个人信息卡片已保存: {card_path}")
|
||
# try:
|
||
# os.remove(card_path)
|
||
# except:
|
||
# pass
|
||
except Exception as e:
|
||
logger.error(f"发送个人信息图片卡片失败: {e}")
|
||
# 发送文本消息作为备用
|
||
display_nickname = user_info.get("nickname") or "未设置"
|
||
display_city = user_info.get("city") or "未设置"
|
||
reply = self.config["messages"]["profile"].format(
|
||
nickname=display_nickname,
|
||
points=user_info.get("points", 0),
|
||
streak=user_info.get("signin_streak", 0),
|
||
city=display_city
|
||
)
|
||
await client.send_text(from_wxid, reply)
|
||
else:
|
||
# 生成图片失败,发送文本消息
|
||
display_nickname = user_info.get("nickname") or "未设置"
|
||
display_city = user_info.get("city") or "未设置"
|
||
reply = self.config["messages"]["profile"].format(
|
||
nickname=display_nickname,
|
||
points=user_info.get("points", 0),
|
||
streak=user_info.get("signin_streak", 0),
|
||
city=display_city
|
||
)
|
||
await client.send_text(from_wxid, reply)
|
||
logger.success(f"用户 {user_wxid} 个人信息查询成功,发送文本消息")
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理个人信息查询失败: {e}")
|
||
await client.send_text(from_wxid, self.config["messages"]["error"])
|
||
|
||
async def handle_city_register(self, client: WechatHookClient, message: dict,
|
||
user_wxid: str, from_wxid: str, content: str):
|
||
"""处理城市注册"""
|
||
logger.info(f"用户 {user_wxid} 尝试注册城市")
|
||
|
||
try:
|
||
# 解析城市名称
|
||
parts = content.split()
|
||
if len(parts) < 2:
|
||
# 格式错误
|
||
await client.send_text(from_wxid, self.config["messages"]["register_format"])
|
||
return
|
||
|
||
city = parts[1].strip()
|
||
if not city:
|
||
await client.send_text(from_wxid, self.config["messages"]["register_format"])
|
||
return
|
||
|
||
# 更新用户城市信息
|
||
if self.update_user_city(user_wxid, city):
|
||
reply = self.config["messages"]["register_success"].format(city=city)
|
||
await client.send_text(from_wxid, reply)
|
||
logger.success(f"用户 {user_wxid} 城市注册成功: {city}")
|
||
else:
|
||
await client.send_text(from_wxid, self.config["messages"]["error"])
|
||
logger.error(f"用户 {user_wxid} 城市注册失败")
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理城市注册失败: {e}")
|
||
await client.send_text(from_wxid, self.config["messages"]["error"])
|
||
|
||
def get_llm_tools(self) -> List[dict]:
|
||
"""返回LLM工具定义,供AIChat插件调用"""
|
||
return [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "user_signin",
|
||
"description": "用户签到,获取积分奖励",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": []
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "check_profile",
|
||
"description": "查看用户个人信息,包括积分、连续签到天数等",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": []
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "register_city",
|
||
"description": "注册或更新用户城市信息",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"city": {
|
||
"type": "string",
|
||
"description": "城市名称"
|
||
}
|
||
},
|
||
"required": ["city"]
|
||
}
|
||
}
|
||
}
|
||
]
|
||
|
||
async def execute_llm_tool(self, tool_name: str, arguments: dict, bot, from_wxid: str) -> dict:
|
||
"""执行LLM工具调用,供AIChat插件调用"""
|
||
try:
|
||
# 从 arguments 中获取用户信息(AIChat 插件已经传递)
|
||
user_wxid = arguments.get("user_wxid", from_wxid)
|
||
is_group = arguments.get("is_group", from_wxid.endswith("@chatroom"))
|
||
|
||
# 构造消息对象
|
||
message = {
|
||
"FromWxid": from_wxid,
|
||
"SenderWxid": user_wxid if is_group else from_wxid,
|
||
"IsGroup": is_group,
|
||
"Content": ""
|
||
}
|
||
|
||
# 确保使用正确的用户 wxid
|
||
if not is_group:
|
||
user_wxid = from_wxid
|
||
|
||
if tool_name == "user_signin":
|
||
# 执行签到
|
||
await self.handle_signin(bot, message, user_wxid, from_wxid, is_group)
|
||
return {"success": True, "message": "签到请求已处理"}
|
||
|
||
elif tool_name == "check_profile":
|
||
# 查看个人信息
|
||
await self.handle_profile_query(bot, message, user_wxid, from_wxid, is_group)
|
||
return {"success": True, "message": "个人信息查询已处理"}
|
||
|
||
elif tool_name == "register_city":
|
||
# 注册城市
|
||
city = arguments.get("city")
|
||
if not city:
|
||
return {"success": False, "message": "缺少城市参数"}
|
||
|
||
content = f"注册城市 {city}"
|
||
await self.handle_city_register(bot, message, user_wxid, from_wxid, content)
|
||
return {"success": True, "message": f"城市注册请求已处理: {city}"}
|
||
|
||
else:
|
||
return {"success": False, "message": "未知的工具名称"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"LLM工具执行失败: {e}")
|
||
return {"success": False, "message": f"执行失败: {str(e)}"}
|
||
|