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

572
plugins/DeerCheckin/main.py Normal file
View File

@@ -0,0 +1,572 @@
"""
鹿打卡插件
用户通过发送"🦌"表情进行每日打卡,插件会自动记录并生成月度打卡日历图
"""
import asyncio
import sqlite3
import calendar
import re
import time
import os
from datetime import date
from pathlib import Path
from typing import Dict, Optional, List
from loguru import logger
from PIL import Image, ImageDraw, ImageFont
try:
import tomllib
except ImportError:
import tomli as tomllib
from utils.plugin_base import PluginBase
from utils.decorators import on_text_message
from WechatHook import WechatHookClient
class DeerCheckin(PluginBase):
"""鹿打卡插件"""
description = "鹿打卡插件 - 发送🦌表情进行打卡并生成月度日历"
author = "Assistant"
version = "1.0.0"
def __init__(self):
super().__init__()
self.config = None
self.db_path = None
self.font_path = None
self.temp_dir = None
self._initialized = False
logger.info("DeerCheckin插件__init__完成")
async def async_init(self):
"""异步初始化"""
logger.info("鹿打卡插件开始初始化...")
try:
config_path = Path(__file__).parent / "config.toml"
if not config_path.exists():
logger.error(f"鹿打卡插件配置文件不存在: {config_path}")
return
with open(config_path, "rb") as f:
self.config = tomllib.load(f)
logger.info("配置文件加载成功")
# 设置路径
plugin_dir = Path(__file__).parent
self.db_path = plugin_dir / "deer_checkin.db"
self.font_path = plugin_dir / "resources" / "font.ttf"
self.temp_dir = plugin_dir / "tmp"
# 确保目录存在
self.temp_dir.mkdir(exist_ok=True)
# 初始化数据库
await self._init_db()
await self._monthly_cleanup()
self._initialized = True
logger.success("鹿打卡插件已加载")
except Exception as e:
logger.error(f"鹿打卡插件初始化失败: {e}")
self.config = None
async def _init_db(self):
"""初始化数据库"""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS checkin (
user_id TEXT NOT NULL,
checkin_date TEXT NOT NULL,
deer_count INTEGER NOT NULL,
PRIMARY KEY (user_id, checkin_date)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT
)
''')
conn.commit()
conn.close()
logger.info("鹿打卡数据库初始化成功")
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
async def _monthly_cleanup(self):
"""月度数据清理"""
current_month = date.today().strftime("%Y-%m")
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("SELECT value FROM metadata WHERE key = 'last_cleanup_month'")
result = cursor.fetchone()
if not result or result[0] != current_month:
cursor.execute("DELETE FROM checkin WHERE strftime('%Y-%m', checkin_date) != ?", (current_month,))
cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
("last_cleanup_month", current_month))
conn.commit()
logger.info(f"已执行月度清理,现在是 {current_month}")
conn.close()
except Exception as e:
logger.error(f"月度数据清理失败: {e}")
@on_text_message(priority=90)
async def handle_deer_message(self, bot: WechatHookClient, message: dict):
"""处理鹿相关消息"""
content = message.get("Content", "").strip()
if content.startswith("🦌"):
logger.info(f"收到鹿消息: {content}, 初始化状态: {self._initialized}, 配置状态: {self.config is not None}")
if not self._initialized or not self.config:
return True
content = message.get("Content", "").strip()
from_wxid = message.get("FromWxid", "")
sender_wxid = message.get("SenderWxid", "")
is_group = message.get("IsGroup", False)
# 获取用户ID和昵称
user_id = sender_wxid if is_group else from_wxid
nickname = message.get("SenderNickname", "") or "用户"
# 检查是否启用
if not self.config["behavior"]["enabled"]:
logger.info(f"DeerCheckin插件未启用跳过消息: {content}")
return True
# 检查群聊/私聊权限
if is_group and not self.config["behavior"]["enable_group"]:
logger.info(f"DeerCheckin插件群聊未启用跳过消息: {content}")
return True
if not is_group and not self.config["behavior"]["enable_private"]:
logger.info(f"DeerCheckin插件私聊未启用跳过消息: {content}")
return True
# 只处理🦌相关消息
if not content.startswith("🦌"):
return True
logger.info(f"DeerCheckin插件处理消息: {content}")
# 处理不同命令
if re.match(r'^🦌+$', content):
# 打卡命令
await self._handle_checkin(bot, from_wxid, user_id, nickname, content)
return False
elif content == "🦌日历":
# 查看日历
await self._handle_calendar(bot, from_wxid, user_id, nickname)
return False
elif content == "🦌帮助":
# 帮助信息
await self._handle_help(bot, from_wxid)
return False
elif re.match(r'^🦌补签\s+(\d{1,2})\s+(\d+)\s*$', content):
# 补签命令
await self._handle_retro_checkin(bot, from_wxid, user_id, nickname, content)
return False
return True
async def _handle_checkin(self, bot: WechatHookClient, from_wxid: str, user_id: str, nickname: str, content: str):
"""处理打卡"""
# 获取真实昵称
if from_wxid.endswith("@chatroom"):
try:
user_info = await bot.get_user_info_in_chatroom(from_wxid, user_id)
if user_info and user_info.get("nickName", {}).get("string"):
nickname = user_info["nickName"]["string"]
except:
pass
deer_count = content.count("🦌")
today_str = date.today().strftime("%Y-%m-%d")
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO checkin (user_id, checkin_date, deer_count)
VALUES (?, ?, ?)
ON CONFLICT(user_id, checkin_date)
DO UPDATE SET deer_count = deer_count + excluded.deer_count
''', (user_id, today_str, deer_count))
conn.commit()
conn.close()
logger.info(f"用户 {nickname} ({user_id}) 打卡成功,记录了 {deer_count} 个🦌")
# 生成并发送日历
await self._generate_and_send_calendar(bot, from_wxid, user_id, nickname)
except Exception as e:
logger.error(f"记录打卡数据失败: {e}")
await bot.send_text(from_wxid, "打卡失败,数据库出错了 >_<")
async def _handle_calendar(self, bot: WechatHookClient, from_wxid: str, user_id: str, nickname: str):
"""处理查看日历"""
logger.info(f"用户 {nickname} ({user_id}) 查询日历")
await self._generate_and_send_calendar(bot, from_wxid, user_id, nickname)
async def _handle_retro_checkin(self, bot: WechatHookClient, from_wxid: str, user_id: str, nickname: str, content: str):
"""处理补签"""
match = re.match(r'^🦌补签\s+(\d{1,2})\s+(\d+)\s*$', content)
if not match:
await bot.send_text(from_wxid, "命令格式不正确,请使用:🦌补签 日期 次数")
return
try:
day_to_checkin = int(match.group(1))
deer_count = int(match.group(2))
if deer_count <= 0:
await bot.send_text(from_wxid, "补签次数必须是大于0的整数哦")
return
# 验证日期
today = date.today()
days_in_month = calendar.monthrange(today.year, today.month)[1]
if not (1 <= day_to_checkin <= days_in_month):
await bot.send_text(from_wxid, f"日期无效!本月只有 {days_in_month}")
return
if day_to_checkin > today.day:
await bot.send_text(from_wxid, "抱歉,不能对未来进行补签哦!")
return
# 执行补签
target_date = date(today.year, today.month, day_to_checkin)
target_date_str = target_date.strftime("%Y-%m-%d")
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO checkin (user_id, checkin_date, deer_count)
VALUES (?, ?, ?)
ON CONFLICT(user_id, checkin_date)
DO UPDATE SET deer_count = deer_count + excluded.deer_count
''', (user_id, target_date_str, deer_count))
conn.commit()
conn.close()
await bot.send_text(from_wxid, f"补签成功!已为 {today.month}{day_to_checkin}日 增加了 {deer_count} 个鹿")
await self._generate_and_send_calendar(bot, from_wxid, user_id, nickname)
except Exception as e:
logger.error(f"补签失败: {e}")
await bot.send_text(from_wxid, "补签失败,数据库出错了 >_<")
async def _handle_help(self, bot: WechatHookClient, from_wxid: str):
"""处理帮助命令"""
help_text = """--- 🦌打卡帮助菜单 ---
1⃣ **🦌打卡**
▸ 命令: 直接发送 🦌 (可发送多个)
▸ 作用: 记录今天🦌的数量
▸ 示例: 🦌🦌🦌
2⃣ **查看记录**
▸ 命令: 🦌日历
▸ 作用: 查看本月的打卡日历
3⃣ **补签**
▸ 命令: 🦌补签 [日期] [次数]
▸ 作用: 为本月指定日期补上打卡记录
▸ 示例: 🦌补签 1 5
4⃣ **显示此帮助**
▸ 命令: 🦌帮助
祝您一🦌顺畅!"""
await bot.send_text(from_wxid, help_text)
async def _generate_and_send_calendar(self, bot: WechatHookClient, from_wxid: str, user_id: str, nickname: str):
"""生成并发送日历"""
try:
current_year = date.today().year
current_month = date.today().month
current_month_str = date.today().strftime("%Y-%m")
# 查询打卡记录
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute(
"SELECT checkin_date, deer_count FROM checkin WHERE user_id = ? AND strftime('%Y-%m', checkin_date) = ?",
(user_id, current_month_str)
)
rows = cursor.fetchall()
conn.close()
if not rows:
await bot.send_text(from_wxid, "您本月还没有打卡记录哦,发送🦌开始第一次打卡吧!")
return
# 处理数据
checkin_records = {}
total_deer = 0
for row in rows:
day = int(row[0].split('-')[2])
count = row[1]
checkin_records[day] = count
total_deer += count
# 生成图片
image_path = await self._create_calendar_image(
user_id, nickname, current_year, current_month, checkin_records, total_deer
)
if image_path:
# 发送图片
data = {"to_wxid": from_wxid, "file": str(image_path)}
await bot._send_data_async(11040, data)
# 不删除临时文件
else:
# 发送文本版本
total_days = len(checkin_records)
await bot.send_text(from_wxid, f"本月您已打卡{total_days}天,累计{total_deer}个🦌")
except Exception as e:
logger.error(f"生成日历失败: {e}")
await bot.send_text(from_wxid, "生成日历时发生错误 >_<")
async def _create_calendar_image(self, user_id: str, nickname: str, year: int, month: int, checkin_data: Dict, total_deer: int) -> Optional[str]:
"""创建日历图片"""
try:
WIDTH, HEIGHT = 700, 620
BG_COLOR = (255, 255, 255)
HEADER_COLOR = (50, 50, 50)
WEEKDAY_COLOR = (100, 100, 100)
DAY_COLOR = (80, 80, 80)
TODAY_BG_COLOR = (240, 240, 255)
CHECKIN_MARK_COLOR = (0, 150, 50)
DEER_COUNT_COLOR = (139, 69, 19)
# 尝试加载字体
try:
if self.font_path.exists():
font_header = ImageFont.truetype(str(self.font_path), 32)
font_weekday = ImageFont.truetype(str(self.font_path), 18)
font_day = ImageFont.truetype(str(self.font_path), 20)
font_check_mark = ImageFont.truetype(str(self.font_path), 28)
font_deer_count = ImageFont.truetype(str(self.font_path), 16)
font_summary = ImageFont.truetype(str(self.font_path), 18)
else:
raise FileNotFoundError("字体文件不存在")
except:
# 使用默认字体
font_header = ImageFont.load_default()
font_weekday = ImageFont.load_default()
font_day = ImageFont.load_default()
font_check_mark = ImageFont.load_default()
font_deer_count = ImageFont.load_default()
font_summary = ImageFont.load_default()
img = Image.new('RGB', (WIDTH, HEIGHT), BG_COLOR)
draw = ImageDraw.Draw(img)
# 绘制标题
header_text = f"{year}{month}月 - {nickname}的鹿日历"
bbox = draw.textbbox((0, 0), header_text, font=font_header)
text_width = bbox[2] - bbox[0]
draw.text(((WIDTH - text_width) // 2, 20), header_text, font=font_header, fill=HEADER_COLOR)
# 绘制星期标题
weekdays = ["", "", "", "", "", "", ""]
cell_width = WIDTH / 7
for i, day in enumerate(weekdays):
bbox = draw.textbbox((0, 0), day, font=font_weekday)
text_width = bbox[2] - bbox[0]
x = i * cell_width + (cell_width - text_width) // 2
draw.text((x, 90), day, font=font_weekday, fill=WEEKDAY_COLOR)
# 绘制日历
cal = calendar.monthcalendar(year, month)
y_offset = 120
cell_height = 75
today_num = date.today().day if date.today().year == year and date.today().month == month else 0
for week in cal:
for i, day_num in enumerate(week):
if day_num == 0:
continue
x_pos = i * cell_width
# 今天的背景
if day_num == today_num:
draw.rectangle(
[x_pos, y_offset, x_pos + cell_width, y_offset + cell_height],
fill=TODAY_BG_COLOR
)
# 绘制日期
day_text = str(day_num)
bbox = draw.textbbox((0, 0), day_text, font=font_day)
text_width = bbox[2] - bbox[0]
draw.text((x_pos + cell_width - text_width - 10, y_offset + 5), day_text, font=font_day, fill=DAY_COLOR)
# 绘制打卡标记
if day_num in checkin_data:
# 绘制 √
check_text = ""
bbox = draw.textbbox((0, 0), check_text, font=font_check_mark)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
check_x = x_pos + (cell_width - text_width) // 2
check_y = y_offset + cell_height // 2 - text_height - 5
draw.text((check_x, check_y), check_text, font=font_check_mark, fill=CHECKIN_MARK_COLOR)
# 绘制鹿数量
deer_text = f"鹿了 {checkin_data[day_num]}"
bbox = draw.textbbox((0, 0), deer_text, font=font_deer_count)
text_width = bbox[2] - bbox[0]
deer_x = x_pos + (cell_width - text_width) // 2
deer_y = y_offset + cell_height // 2 + 15
draw.text((deer_x, deer_y), deer_text, font=font_deer_count, fill=DEER_COUNT_COLOR)
y_offset += cell_height
# 绘制总结
total_days = len(checkin_data)
summary_text = f"本月总结:累计鹿了 {total_days} 天,共鹿 {total_deer}"
bbox = draw.textbbox((0, 0), summary_text, font=font_summary)
text_width = bbox[2] - bbox[0]
draw.text(((WIDTH - text_width) // 2, HEIGHT - 30), summary_text, font=font_summary, fill=HEADER_COLOR)
# 保存图片
file_path = self.temp_dir / f"checkin_{user_id}_{int(time.time())}.png"
img.save(file_path, format='PNG')
return file_path
except Exception as e:
logger.error(f"创建日历图片失败: {e}")
return None
def get_llm_tools(self) -> List[dict]:
"""返回LLM工具定义供AIChat插件调用"""
return [
{
"type": "function",
"function": {
"name": "deer_checkin",
"description": "鹿打卡,记录今天的鹿数量",
"parameters": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "鹿的数量默认为1",
"default": 1
}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "view_calendar",
"description": "查看本月的鹿打卡日历",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "makeup_checkin",
"description": "补签指定日期的鹿打卡记录",
"parameters": {
"type": "object",
"properties": {
"day": {
"type": "integer",
"description": "要补签的日期1-31"
},
"count": {
"type": "integer",
"description": "补签的鹿数量",
"default": 1
}
},
"required": ["day"]
}
}
}
]
async def execute_llm_tool(self, tool_name: str, arguments: dict, bot, from_wxid: str) -> dict:
"""执行LLM工具调用供AIChat插件调用"""
try:
if not self._initialized or not self.config:
return {"success": False, "message": "鹿打卡插件未初始化"}
is_group = from_wxid.endswith("@chatroom")
# 构造消息对象
message = {
"FromWxid": from_wxid,
"SenderWxid": arguments.get("user_wxid", from_wxid) if is_group else from_wxid,
"IsGroup": is_group,
"Content": "",
"SenderNickname": arguments.get("nickname", "")
}
user_id = message["SenderWxid"] if is_group else from_wxid
nickname = message.get("SenderNickname", "") or "用户"
if tool_name == "deer_checkin":
# 鹿打卡
count = arguments.get("count", 1)
content = "🦌" * count
await self._handle_checkin(bot, from_wxid, user_id, nickname, content)
return {"success": True, "message": f"鹿打卡已处理,记录了{count}个🦌"}
elif tool_name == "view_calendar":
# 查看日历
await self._handle_calendar(bot, from_wxid, user_id, nickname)
return {"success": True, "message": "日历查询已处理"}
elif tool_name == "makeup_checkin":
# 补签
day = arguments.get("day")
count = arguments.get("count", 1)
if not day:
return {"success": False, "message": "缺少日期参数"}
content = f"🦌补签 {day} {count}"
await self._handle_retro_checkin(bot, from_wxid, user_id, nickname, content)
return {"success": True, "message": f"补签请求已处理:{day}{count}个🦌"}
else:
return None # 不是本插件的工具返回None让其他插件处理
except Exception as e:
logger.error(f"LLM工具执行失败: {e}")
return {"success": False, "message": f"执行失败: {str(e)}"}