Files
WechatHookBot/plugins/SignIn/main.py
2025-12-05 18:06:13 +08:00

1085 lines
47 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.
"""
签到插件
用户发送签到关键词即可进行签到随机获得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)}"}