feat(bot): use runtime api for bot data
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user