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:
72
danding_bot/plugins/danding_sign_in/__init__.py
Normal file
72
danding_bot/plugins/danding_sign_in/__init__.py
Normal 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']}"
|
||||
)
|
||||
88
danding_bot/plugins/danding_sign_in/api.py
Normal file
88
danding_bot/plugins/danding_sign_in/api.py
Normal 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},
|
||||
)
|
||||
78
danding_bot/plugins/danding_sign_in/config.py
Normal file
78
danding_bot/plugins/danding_sign_in/config.py
Normal 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("/")
|
||||
59
danding_bot/plugins/danding_sign_in/rules.py
Normal file
59
danding_bot/plugins/danding_sign_in/rules.py
Normal 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)
|
||||
66
danding_bot/plugins/danding_sign_in/service.py
Normal file
66
danding_bot/plugins/danding_sign_in/service.py
Normal 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}
|
||||
Reference in New Issue
Block a user