feat(sign_in): 新增独立每日签到插件,与抽卡签到共用同一份签到数据

- 群聊命令「签到」(别名: 每日签到/打卡),白名单群聊或管理员可用
- 复用 xapi /bot/gacha/sign-in + /bot/points/add,与抽卡/WPF 共用 bot_gacha_daily_sign_in 表
- 积分随机 1-100,流水沿用 gacha_sign/抽卡签到;先到先得,重复签到提示已签到
- 新增 10 个单元测试,ruff 通过;更新 PLUGINS.md / README.md
This commit is contained in:
2026-08-16 09:02:11 +08:00
parent 7f3f744af5
commit 9ee5545cac
12 changed files with 862 additions and 1 deletions

View File

@@ -0,0 +1,66 @@
"""独立签到插件 - 签到编排。
先记录签到再发放积分(与抽卡签到 `onmyoji_gacha/sign_in.py` 同模式),
避免重复签到时重复发积分xapi `POST /bot/gacha/sign-in` 负责判断当天
是否已签到(与抽卡/WPF 共用 `bot_gacha_daily_sign_in` 表去重),只有它
确认首次签到后,才调用 `/bot/points/add` 发积分。
"""
from __future__ import annotations
import logging
import random
from typing import Any, Dict, Optional, Protocol
logger = logging.getLogger(__name__)
class SignInRecorder(Protocol):
async def record_sign_in(self, user_id: str, points_awarded: int) -> Optional[Dict[str, Any]]:
"""记录每日签到,返回 xapi data含 signed_already失败返回 None。"""
class PointsAwarder(Protocol):
async def add_points(self, user_id: str, amount: int, source: str, reason: str) -> tuple[bool, int]:
"""发放积分,返回(是否成功, 新余额)。"""
async def perform_daily_sign_in(
*,
sign_in_api: SignInRecorder,
points_api: PointsAwarder,
user_id: str,
points_min: int,
points_max: int,
source: str,
reason: str,
) -> Dict[str, Any]:
"""执行每日签到。
返回 dict
- {"success": True, "already_signed": False, "points": int, "balance": int} 首次签到成功
- {"success": True, "already_signed": True, "points": int, "sign_date": str} 当天已签到
- {"success": False, "message": str} 失败
"""
low, high = sorted((points_min, points_max))
points = random.randint(low, high)
sign_result = await sign_in_api.record_sign_in(user_id, points)
if sign_result is None:
return {"success": False, "message": "签到服务暂不可用,请稍后再试"}
if sign_result.get("signed_already"):
# 今天已签到(抽卡/WPF/命令任一入口先到先得),不重复发积分
return {
"success": True,
"already_signed": True,
"points": int(sign_result.get("points_awarded", 0) or 0),
"sign_date": str(sign_result.get("sign_date", "")),
}
ok, balance = await points_api.add_points(user_id, points, source, reason)
if not ok:
logger.error("签到记录成功但积分发放失败 user_id=%s points=%s", user_id, points)
return {"success": False, "message": "积分发放失败,请稍后再试"}
return {"success": True, "already_signed": False, "points": points, "balance": balance}