feat(bot): use runtime api for bot data

This commit is contained in:
2026-06-20 18:20:40 +08:00
parent f67f3ca1d6
commit 8d26c46323
16 changed files with 1803 additions and 1491 deletions

View File

@@ -1,5 +1,7 @@
import logging
import asyncio
import logging
import asyncio
from datetime import datetime
from uuid import uuid4
from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, Message, MessageSegment, PrivateMessageEvent
@@ -27,8 +29,8 @@ async def _get_user_name(bot: Bot, scope: str, user_id: str) -> str:
group_id = int(scope.split("_", 1)[1])
info = await bot.get_group_member_info(group_id=group_id, user_id=int(user_id))
return info.get("card") or info.get("nickname") or user_id
except Exception:
pass
except Exception as exc:
logger.debug("获取赛马用户昵称失败 scope=%s user_id=%s error=%s", scope, user_id, exc)
return user_id
@@ -142,13 +144,14 @@ async def _is_admin_or_owner(bot: Bot, event: Event) -> bool:
user_id=int(event.get_user_id()),
)
return member_info.get("role", "") in ("admin", "owner")
except Exception:
return False
except Exception as exc:
logger.debug("检查赛马管理员权限失败 user_id=%s error=%s", getattr(event, "user_id", ""), exc)
return False
def _build_point_changes(room: Room, odds: dict[str, float]) -> tuple[dict[str, int], dict[str, str]]:
point_changes: dict[str, int] = {}
def _build_point_changes(room: Room, odds: dict[str, float]) -> tuple[dict[str, int], dict[str, str]]:
point_changes: dict[str, int] = {}
for horse in room.horses.values():
point_changes[horse.owner_id] = point_changes.get(horse.owner_id, 0) + config.PARTICIPANT_REWARD
@@ -168,8 +171,23 @@ def _build_point_changes(room: Room, odds: dict[str, float]) -> tuple[dict[str,
point_summaries = {
user_id: _describe_points_delta(delta)
for user_id, delta in point_changes.items()
}
return point_changes, point_summaries
}
return point_changes, point_summaries
def _build_participants_snapshot(room: Room) -> list[str]:
"""生成赛果归档所需的参赛马名快照。"""
return [horse.name for horse in _get_horses_in_order(room)]
def _build_bet_distribution(room: Room) -> dict[str, int]:
"""按马名汇总下注分布,供 xapi 原样归档。"""
distribution = {horse.name: 0 for horse in _get_horses_in_order(room)}
for bet in room.bets:
distribution[bet.horse_name] = distribution.get(bet.horse_name, 0) + bet.amount
return distribution
async def _send_to_scope(bot: Bot, scope: str, message: str, message_type: str = "race_update", critical: bool = False):
@@ -243,10 +261,10 @@ async def settle_race(room: Room) -> tuple[RaceResult, dict[str, float]] | None:
for bet in room.bets:
user_ids.add(bet.user_id)
# Record pre-balances
pre_balances: dict[str, int] = {}
for uid in user_ids:
pre_balances[uid] = points_service.get_balance(uid)
# Record pre-balances
pre_balances: dict[str, int] = {}
for uid in user_ids:
pre_balances[uid] = await points_service.get_balance(uid)
# 1. Reward all participants
for horse in room.horses.values():
@@ -271,10 +289,10 @@ async def settle_race(room: Room) -> tuple[RaceResult, dict[str, float]] | None:
except Exception as e:
logger.warning(f"payout_winnings failed for {bet.user_id}: {e}")
# Record post-balances and compute deltas
post_balances: dict[str, int] = {}
for uid in user_ids:
post_balances[uid] = points_service.get_balance(uid)
# Record post-balances and compute deltas
post_balances: dict[str, int] = {}
for uid in user_ids:
post_balances[uid] = await points_service.get_balance(uid)
point_changes: dict[str, int] = {}
for uid in user_ids:
@@ -285,13 +303,20 @@ async def settle_race(room: Room) -> tuple[RaceResult, dict[str, float]] | None:
# Build human-readable summaries
_, point_change_summaries = _build_point_changes(room, odds)
result = RaceResult(
champion_name=room.champion_name,
champion_owner=champion.owner_id,
point_changes=point_changes,
point_change_summaries=point_change_summaries,
)
return result, odds
result = RaceResult(
race_id=str(uuid4()),
scope=room.scope,
champion_name=room.champion_name,
champion_owner=champion.owner_id,
participants=_build_participants_snapshot(room),
bet_distribution=_build_bet_distribution(room),
duration_ticks=room.tick_count,
completed_at=datetime.now(),
point_changes=point_changes,
point_change_summaries=point_change_summaries,
odds_snapshot=odds,
)
return result, odds
async def run_race_with_settlement(bot: Bot, room: Room, scope: str):
@@ -343,12 +368,15 @@ async def run_race_with_settlement(bot: Bot, room: Room, scope: str):
if result:
result_lines.extend(await _format_point_change_lines(room, result.point_changes, result.point_change_summaries, name_map))
await message_service.recall_previous_of_type(bot, scope, "race_update")
await _send_to_scope(bot, scope, "\n".join(result_lines), "race_result")
race_engine.stop_race(scope)
room_store.delete_room(scope)
message_service.clear_pending_recalls(scope)
await message_service.recall_previous_of_type(bot, scope, "race_update")
await _send_to_scope(bot, scope, "\n".join(result_lines), "race_result")
if result:
await room_store.save_race_result(result)
race_engine.stop_race(scope)
room_store.delete_room(scope)
message_service.clear_pending_recalls(scope)
# Import and re-export access functions from access.py (canonical source)

View File

@@ -1,6 +1,7 @@
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import json
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import json
import os
class Config(BaseSettings):
@@ -43,8 +44,34 @@ class Config(BaseSettings):
}
)
# 数据库配置
RACE_DB_FILE: str = "data/group_horse_racing/race.db"
# 数据库配置
RACE_DB_FILE: str = "data/group_horse_racing/race.db"
# xapi /bot/race 运行时 API 配置
RACE_API_HOST: str = os.getenv("DANDING_RACE_API_HOST", "https://api.danding.vip/bot/race")
BOT_USER: str = os.getenv("DANDING_BOT_USER", "1424473282")
BOT_TOKEN: str = os.getenv(
"DANDING_BOT_TOKEN",
os.getenv("DANDING_API_TOKEN", os.getenv("BOT_TOKEN", "")),
)
@field_validator("RACE_API_HOST")
@classmethod
def validate_race_api_host(cls, value):
"""规范化 xapi 赛马运行时 API 地址。"""
if not value:
raise ValueError("RACE_API_HOST cannot be empty")
return value.rstrip("/")
@field_validator("BOT_USER")
@classmethod
def validate_bot_user(cls, value):
"""Bot 鉴权用户不能为空。"""
if not value:
raise ValueError("BOT_USER cannot be empty")
return value
@field_validator("TESTERS", "TEST_GROUPS", "ALLOWED_GROUPS", mode="before")
@classmethod

View File

@@ -54,6 +54,7 @@ class RaceResult:
race_id: str = ""
scope: str = ""
participants: list[str] = field(default_factory=list)
bet_distribution: dict[str, int] = field(default_factory=dict)
duration_ticks: int = 0
completed_at: datetime = field(default_factory=datetime.now)
bet_distribution: dict[str, int] = field(default_factory=dict)
duration_ticks: int = 0
completed_at: datetime = field(default_factory=datetime.now)
odds_snapshot: dict[str, float] = field(default_factory=dict)

View File

@@ -1,15 +1,19 @@
import asyncio
import aiosqlite
import json
from datetime import datetime
from pathlib import Path
from typing import Optional
from .models import Room, RoomState, RaceResult
from .config import Config
class RoomStore:
import asyncio
import aiosqlite
import aiohttp
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from .models import Room, RoomState, RaceResult
from .config import Config
logger = logging.getLogger(__name__)
class RoomStore:
def __init__(self, config: Config):
self.config = config
self.rooms: dict[str, Room] = {}
@@ -37,46 +41,7 @@ class RoomStore:
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS race_history (
race_id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
champion_name TEXT NOT NULL,
champion_owner TEXT NOT NULL,
participants TEXT NOT NULL,
bet_distribution TEXT NOT NULL,
duration_ticks INTEGER NOT NULL,
completed_at TEXT NOT NULL,
point_changes TEXT DEFAULT '{}',
point_change_summaries TEXT DEFAULT '{}',
odds_snapshot TEXT DEFAULT '{}'
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS user_horse_names (
user_id TEXT PRIMARY KEY,
horse_name TEXT NOT NULL
)
""")
# Add missing columns if they don't exist (for existing databases)
try:
await db.execute("SELECT point_changes FROM race_history LIMIT 1")
except aiosqlite.OperationalError:
await db.execute("ALTER TABLE race_history ADD COLUMN point_changes TEXT DEFAULT '{}'")
try:
await db.execute("SELECT point_change_summaries FROM race_history LIMIT 1")
except aiosqlite.OperationalError:
await db.execute("ALTER TABLE race_history ADD COLUMN point_change_summaries TEXT DEFAULT '{}'")
try:
await db.execute("SELECT odds_snapshot FROM race_history LIMIT 1")
except aiosqlite.OperationalError:
await db.execute("ALTER TABLE race_history ADD COLUMN odds_snapshot TEXT DEFAULT '{}'")
await db.commit()
await db.commit()
self._initialized = True
@@ -95,12 +60,69 @@ class RoomStore:
async def close(self):
"""Close database connection on shutdown."""
if self._db is not None:
await self._db.close()
self._db = None
async def load_rooms(self):
"""Restore active rooms from DB snapshots on startup."""
if self._db is not None:
await self._db.close()
self._db = None
def _url(self, path: str) -> str:
"""拼接 /bot/race 端点地址。"""
return f"{self.config.RACE_API_HOST}/{path.lstrip('/')}"
def _auth(self) -> dict[str, str]:
"""生成 xapi Bot 鉴权参数。"""
return {
"user": self.config.BOT_USER,
"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/race并只向上层暴露 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 {})}
if method == "PUT":
async with session.put(request_url, json=request_payload, timeout=timeout) as resp:
return await self._parse_response(resp, path)
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("race api request failed path=%s error=%s", path, exc)
return None
except asyncio.TimeoutError as exc:
logger.error("race 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("race api bad status path=%s status=%s", path, resp.status)
return None
body = await resp.json()
if body.get("code") != 200:
logger.error("race 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 load_rooms(self):
"""Restore active rooms from DB snapshots on startup."""
await self.ensure_initialized()
db = await self._get_db()
cursor = await db.execute(
@@ -167,24 +189,23 @@ class RoomStore:
if scope in self.rooms:
del self.rooms[scope]
async def get_last_horse_name(self, user_id: str) -> Optional[str]:
await self.ensure_initialized()
db = await self._get_db()
cursor = await db.execute(
"SELECT horse_name FROM user_horse_names WHERE user_id = ?",
(user_id,)
)
row = await cursor.fetchone()
return row[0] if row else None
async def set_last_horse_name(self, user_id: str, horse_name: str):
await self.ensure_initialized()
db = await self._get_db()
await db.execute(
"INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)",
(user_id, horse_name),
)
await db.commit()
async def get_last_horse_name(self, user_id: str) -> Optional[str]:
"""从 xapi 读取用户最后使用马名。"""
data = await self._request("GET", "horse-name", params={"user_id": user_id})
if data is None:
return None
horse_name = data.get("horse_name")
return str(horse_name) if horse_name else None
async def set_last_horse_name(self, user_id: str, horse_name: str):
"""将用户最后使用马名写入 xapi。"""
await self._request(
"PUT",
"horse-name",
payload={"user_id": user_id, "horse_name": horse_name},
)
async def _save_snapshot(self, room: Room):
"""Save room snapshot to database."""
@@ -226,31 +247,28 @@ class RoomStore:
))
await db.commit()
async def save_race_result(self, result: RaceResult):
"""Save race result to history."""
await self.ensure_initialized()
db = await self._get_db()
await db.execute("""
INSERT INTO race_history
(race_id, scope, champion_name, champion_owner, participants,
bet_distribution, duration_ticks, completed_at,
point_changes, point_change_summaries, odds_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
result.race_id,
result.scope,
result.champion_name,
result.champion_owner,
json.dumps(result.participants),
json.dumps(result.bet_distribution),
result.duration_ticks,
result.completed_at.isoformat(),
json.dumps(getattr(result, 'point_changes', {})),
json.dumps(getattr(result, 'point_change_summaries', {})),
json.dumps(getattr(result, 'odds_snapshot', {})),
))
await db.commit()
async def save_race_result(self, result: RaceResult):
"""将完整赛果写入 xapi。"""
data = await self._request(
"POST",
"history",
payload={
"race_id": result.race_id,
"scope": result.scope,
"champion_name": result.champion_name,
"champion_owner": result.champion_owner,
"participants": result.participants,
"bet_distribution": result.bet_distribution,
"duration_ticks": result.duration_ticks,
"completed_at": result.completed_at.isoformat(),
"point_changes": result.point_changes,
"point_change_summaries": result.point_change_summaries,
"odds_snapshot": result.odds_snapshot,
},
)
if data is None:
raise RuntimeError(f"赛马赛果写入 xapi 失败: race_id={result.race_id}")
# Module-level singleton instance