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,72 @@
"""独立每日签到插件。
用户发送「签到」即可完成每日签到并领取积分(每天一次)。
与 onmyoji_gacha 抽卡签到、WPF 客户端签到共用 xapi `bot_gacha_daily_sign_in`
签到数据:每天每用户仅一次,任一入口先到先得,后到者提示已签到。
"""
from nonebot import on_command, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from nonebot.plugin import PluginMetadata
from .config import Config
from .api import SignInAPI
from .rules import check_permission
from .service import perform_daily_sign_in
require("danding_bot.plugins.danding_points")
from danding_bot.plugins.danding_points import points_api # noqa: E402
__plugin_meta__ = PluginMetadata(
name="每日签到",
description="独立每日签到命令,与抽卡签到共用同一份签到数据",
usage="发送「签到」即可完成每日签到并领取积分(每天一次)",
type="application",
config=Config,
extra={
"required_plugins": ["danding_bot.plugins.danding_points"],
},
)
config = Config()
sign_in_api = SignInAPI(config)
sign_in_matcher = on_command("签到", aliases={"每日签到", "打卡"}, priority=5, rule=check_permission())
@sign_in_matcher.handle()
async def handle_sign_in(bot: Bot, event: MessageEvent):
user_id = str(event.user_id)
sender = event.sender
user_name = ""
if sender:
user_name = sender.card if isinstance(event, GroupMessageEvent) else sender.nickname
user_name = user_name or "用户"
result = await perform_daily_sign_in(
sign_in_api=sign_in_api,
points_api=points_api,
user_id=user_id,
points_min=config.SIGN_IN_POINTS_MIN,
points_max=config.SIGN_IN_POINTS_MAX,
source=config.SIGN_IN_SOURCE,
reason=config.SIGN_IN_REASON,
)
if not result["success"]:
await sign_in_matcher.finish(f"{user_name}{result['message']}")
if result["already_signed"]:
await sign_in_matcher.finish(
f"{user_name} 📅 今天已经签到过啦\n"
f"🎁 今日签到积分:{result['points']}\n"
f"(抽卡/WPF 签到同样计入每日一次)\n"
f"明天再来吧~"
)
return
await sign_in_matcher.finish(
f"{user_name} 📅 每日签到成功!\n"
f"🎁 获得积分:{result['points']}\n"
f"💰 当前积分:{result['balance']}"
)

View File

@@ -0,0 +1,88 @@
"""独立签到插件 - xapi 签到记录客户端。
只封装 `POST /bot/gacha/sign-in`,与 onmyoji_gacha 抽卡插件调用同一端点、
写入同一张 `bot_gacha_daily_sign_in` 表,保证两个入口共用一份签到数据。
积分发放由共享的 danding_points.points_api/bot/points/add完成。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, Optional
import aiohttp
from .config import Config
logger = logging.getLogger(__name__)
class SignInAPI:
"""签到记录 API封装 xapi /bot/gacha/sign-in。"""
def __init__(self, config: Config):
self.config = config
def _url(self, path: str) -> str:
"""拼接 /bot/gacha 端点地址。"""
return f"{self.config.GACHA_API_HOST}/{path.lstrip('/')}"
def _auth(self) -> Dict[str, str]:
"""生成 xapi Bot 鉴权参数。"""
return {
"user": self.config.BOT_USER_ID,
"token": self.config.BOT_TOKEN,
}
async def _request(
self,
method: str,
path: str,
*,
payload: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""调用 xapi /bot/gacha并只向上层暴露 data。"""
request_url = self._url(path)
timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession() as session:
if method == "GET":
request_params = {**self._auth(), **(params or {})}
async with session.get(request_url, params=request_params, timeout=timeout) as resp:
return await self._parse_response(resp, path)
request_payload = {**self._auth(), **(payload or {})}
async with session.post(request_url, json=request_payload, timeout=timeout) as resp:
return await self._parse_response(resp, path)
except aiohttp.ClientError as exc:
logger.error("sign-in api request failed path=%s error=%s", path, exc)
return None
except asyncio.TimeoutError as exc:
logger.error("sign-in api request timeout path=%s error=%s", path, exc)
return None
async def _parse_response(self, resp: aiohttp.ClientResponse, path: str) -> Optional[Dict[str, Any]]:
"""解析 xapi 统一响应,失败时返回 None 维持旧调用方失败语义。"""
if resp.status != 200:
logger.error("sign-in api bad status path=%s status=%s", path, resp.status)
return None
body = await resp.json()
if body.get("code") != 200:
logger.error("sign-in api fail path=%s code=%s message=%s", path, body.get("code"), body.get("message"))
return None
data = body.get("data")
return data if isinstance(data, dict) else None
async def record_sign_in(self, user_id: str, points_awarded: int) -> Optional[Dict[str, Any]]:
"""记录每日签到;返回 xapi data含 signed_already失败返回 None。"""
return await self._request(
"POST",
"sign-in",
payload={"user_id": user_id, "points_awarded": points_awarded},
)

View File

@@ -0,0 +1,78 @@
"""独立每日签到插件 - 配置。
与 onmyoji_gacha 抽卡插件共用同一 xapi 服务(/bot/gacha与鉴权配置
签到数据天然共用 xapi `bot_gacha_daily_sign_in` 表。
"""
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import os
def _read_nonebot_config(name: str) -> str:
"""从 NoneBot driver.config 读取配置;测试或独立加载模块时静默回退。"""
try:
from nonebot import get_driver
driver_config = get_driver().config
except Exception:
return ""
value = getattr(driver_config, name.lower(), "")
return str(value) if value is not None else ""
def _read_setting(name: str) -> str:
"""按单个配置名读取系统环境变量,再读取 NoneBot 配置对象。"""
return os.getenv(name, "") or _read_nonebot_config(name)
def _first_setting(*names: str, default: str = "") -> str:
"""按业务优先级读取多个兼容配置名。"""
for name in names:
value = _read_setting(name)
if value:
return value
return default
class Config(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
# xapi /bot/gacha 运行时 API 配置(与 onmyoji_gacha 共用同一服务与鉴权)
GACHA_API_HOST: str = Field(
default_factory=lambda: _first_setting(
"DANDING_GACHA_API_HOST",
default="https://api.danding.vip/bot/gacha",
)
)
BOT_TOKEN: str = Field(
default_factory=lambda: _first_setting(
"DANDING_BOT_TOKEN",
"ONMYOJI_BOT_TOKEN",
"DANDING_API_TOKEN",
"BOT_TOKEN",
)
)
BOT_USER_ID: str = Field(default_factory=lambda: _first_setting("DANDING_BOT_USER", default="1424473282"))
# 权限:白名单群聊 + 管理员
ALLOWED_GROUP_ID: int = 621016172
ALLOWED_USER_ID: int = 1424473282
# 签到积分区间(与抽卡签到一致:随机 1-100
SIGN_IN_POINTS_MIN: int = 1
SIGN_IN_POINTS_MAX: int = 100
# 积分流水标记(与抽卡/WPF 签到共用同一事件语义,保持流水一致)
SIGN_IN_SOURCE: str = "gacha_sign"
SIGN_IN_REASON: str = "抽卡签到"
@field_validator("GACHA_API_HOST")
@classmethod
def validate_gacha_api_host(cls, value):
if not value:
raise ValueError("GACHA_API_HOST cannot be empty")
return value.rstrip("/")

View File

@@ -0,0 +1,59 @@
"""独立签到插件 - 权限校验。
签到命令仅在白名单群聊(或管理员)中可用;私聊非管理员不可用。
与 onmyoji_gacha 的规则不同:抽卡规则私聊放行,签到规则更严格(群聊白名单)。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from nonebot.rule import Rule
def is_allowed(
user_id: int,
group_id: Optional[int],
*,
allowed_group_id: int,
allowed_user_id: int,
) -> bool:
"""签到权限纯函数:管理员任意场景可用;其他用户仅白名单群聊可用。
Args:
user_id: 消息发送者 QQ
group_id: 群聊 ID非群聊场景为 None
allowed_group_id: 白名单群聊
allowed_user_id: 管理员 QQ
"""
if user_id == allowed_user_id:
return True
return group_id is not None and group_id == allowed_group_id
def check_permission() -> Rule:
"""生成签到命令权限 Rule白名单群聊 + 管理员)。
延迟导入 nonebot与 config.py 同模式),保证纯函数在无 nonebot 的
测试环境中可加载。
"""
from nonebot.rule import Rule
from nonebot.adapters.onebot.v11 import GroupMessageEvent, MessageEvent
from .config import Config
config = Config()
async def _checker(event: MessageEvent) -> bool:
group_id = event.group_id if isinstance(event, GroupMessageEvent) else None
return is_allowed(
event.user_id,
group_id,
allowed_group_id=config.ALLOWED_GROUP_ID,
allowed_user_id=config.ALLOWED_USER_ID,
)
return Rule(_checker)

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}