Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import asyncio
|
||||
import time
|
||||
import pytz
|
||||
import httpx
|
||||
import datetime
|
||||
import calendar
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .models import Player
|
||||
from .steam import (
|
||||
_query_game_icon_hashes,
|
||||
_upsert_game_icon_hashes,
|
||||
fetch_clienticon_hash_from_store,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_avatar(avatar_url: str, proxy: str = None) -> Image.Image:
|
||||
default_avatar = Image.open(Path(__file__).parent / "res/unknown_avatar.jpg")
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=proxy, timeout=10) as client:
|
||||
response = await client.get(avatar_url)
|
||||
if response.status_code != 200:
|
||||
return default_avatar
|
||||
return Image.open(BytesIO(response.content))
|
||||
except Exception:
|
||||
# 下载失败(连接超时/网络异常),降级默认头像,不中断命令
|
||||
return default_avatar
|
||||
|
||||
|
||||
# 游戏图标缓存目录(插件目录下 data/game_icons/)
|
||||
GAME_ICON_DIR = Path(__file__).parent / "data" / "game_icons"
|
||||
|
||||
|
||||
async def _download_steam_icon(
|
||||
url: str, appid: int, proxy: str, cache_suffix: str
|
||||
) -> Optional[Image.Image]:
|
||||
"""从 steam CDN 下载图标并缓存到 data/game_icons/{appid}_{cache_suffix}.jpg"""
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=proxy, timeout=10) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
content = resp.content
|
||||
GAME_ICON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_path = GAME_ICON_DIR / f"{appid}_{cache_suffix}.jpg"
|
||||
await asyncio.to_thread(cache_path.write_bytes, content)
|
||||
return Image.open(BytesIO(content))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def get_game_icon(
|
||||
appid: int, proxy: str = None, db_path: Optional[Path] = None
|
||||
) -> Optional[Image.Image]:
|
||||
"""获取游戏图标(Steam clienticon .jpg),按 appid 缓存到插件 data/game_icons/ 目录
|
||||
|
||||
- 优先用 game_names 表里的 clienticon hash 下载 32x32 小图标
|
||||
- DB 缺失时从商店页抓 hash
|
||||
- 取不到就返回 None,绘制时留空占位(不再回退 header.jpg)
|
||||
"""
|
||||
if not appid:
|
||||
return None
|
||||
|
||||
icon_path = GAME_ICON_DIR / f"{appid}_icon.jpg"
|
||||
if icon_path.is_file():
|
||||
try:
|
||||
return Image.open(icon_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
icon_hash = None
|
||||
if db_path is not None:
|
||||
icon_hash = _query_game_icon_hashes(db_path, [appid]).get(appid)
|
||||
if not icon_hash:
|
||||
# DB 缺失时,从商店页 apphub_AppIcon 稳定抓取 clienticon hash
|
||||
icon_hash = await fetch_clienticon_hash_from_store(appid)
|
||||
if icon_hash and db_path is not None:
|
||||
_upsert_game_icon_hashes(db_path, {appid: icon_hash})
|
||||
if icon_hash:
|
||||
icon = await _download_steam_icon(
|
||||
f"https://shared.akamai.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg",
|
||||
appid,
|
||||
proxy,
|
||||
"icon",
|
||||
)
|
||||
if icon is None:
|
||||
icon = await _download_steam_icon(
|
||||
f"https://shared.fastly.steamstatic.com/community_assets/images/apps/{appid}/{icon_hash}.jpg",
|
||||
appid,
|
||||
proxy,
|
||||
"icon",
|
||||
)
|
||||
if icon is None:
|
||||
# 备用:旧社区 CDN 路径(部分早期游戏仍在此处)
|
||||
icon = await _download_steam_icon(
|
||||
f"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{appid}/{icon_hash}.jpg",
|
||||
appid,
|
||||
proxy,
|
||||
"icon",
|
||||
)
|
||||
if icon is not None:
|
||||
return icon
|
||||
|
||||
# 没有 clienticon 就留空占位,不回退 header
|
||||
return None
|
||||
|
||||
|
||||
async def get_game_icons(
|
||||
appids: list[int], proxy: str = None, db_path: Optional[Path] = None
|
||||
) -> dict[int, Image.Image]:
|
||||
"""批量获取游戏图标;失败自动跳过,返回 {appid: Image.Image}"""
|
||||
if not appids:
|
||||
return {}
|
||||
results = await asyncio.gather(*(get_game_icon(a, proxy, db_path) for a in appids))
|
||||
return {a: icon for a, icon in zip(appids, results) if icon is not None}
|
||||
|
||||
|
||||
async def fetch_avatar(
|
||||
player: Player, avatar_dir: Optional[Path], proxy: str = None
|
||||
) -> Image.Image:
|
||||
if avatar_dir is not None:
|
||||
avatar_path = (
|
||||
avatar_dir / f"avatar_{player['steamid']}_{player['avatarhash']}.png"
|
||||
)
|
||||
|
||||
if avatar_path.exists():
|
||||
avatar = Image.open(avatar_path)
|
||||
else:
|
||||
avatar = await _fetch_avatar(player["avatarfull"], proxy)
|
||||
|
||||
avatar.save(avatar_path)
|
||||
else:
|
||||
avatar = await _fetch_avatar(player["avatarfull"], proxy)
|
||||
|
||||
return avatar
|
||||
|
||||
|
||||
async def simplize_steam_player_data(
|
||||
player: Player,
|
||||
proxy: str = None,
|
||||
avatar_dir: Path = None,
|
||||
game_names: Optional[Dict[int, str]] = None,
|
||||
) -> Dict[str, str]:
|
||||
avatar = await fetch_avatar(player, avatar_dir, proxy)
|
||||
gameid = int(player["gameid"]) if player.get("gameid") else None
|
||||
game_name = game_names.get(gameid) if (gameid is not None and game_names) else None
|
||||
if not game_name:
|
||||
game_name = player.get("gameextrainfo")
|
||||
|
||||
if player["personastate"] == 0:
|
||||
if not player.get("lastlogoff"):
|
||||
status = "离线"
|
||||
else:
|
||||
time_logged_off = player["lastlogoff"] # Unix timestamp
|
||||
time_to_now = calendar.timegm(time.gmtime()) - time_logged_off
|
||||
|
||||
# 将时间转换为自然语言
|
||||
if time_to_now < 60:
|
||||
status = "上次在线 刚刚"
|
||||
elif time_to_now < 3600:
|
||||
status = f"上次在线 {time_to_now // 60} 分钟前"
|
||||
elif time_to_now < 86400:
|
||||
status = f"上次在线 {time_to_now // 3600} 小时前"
|
||||
elif time_to_now < 2592000:
|
||||
status = f"上次在线 {time_to_now // 86400} 天前"
|
||||
elif time_to_now < 31536000:
|
||||
status = f"上次在线 {time_to_now // 2592000} 个月前"
|
||||
else:
|
||||
status = f"上次在线 {time_to_now // 31536000} 年前"
|
||||
elif player["personastate"] in [1, 2, 4]:
|
||||
status = (
|
||||
"在线" if player.get("gameextrainfo") is None else player["gameextrainfo"]
|
||||
)
|
||||
elif player["personastate"] == 3:
|
||||
status = (
|
||||
"离开" if player.get("gameextrainfo") is None else player["gameextrainfo"]
|
||||
)
|
||||
elif player["personastate"] in [5, 6]:
|
||||
status = "在线"
|
||||
else:
|
||||
status = "未知"
|
||||
|
||||
return {
|
||||
"steamid": player["steamid"],
|
||||
"avatar": avatar,
|
||||
"name": player["personaname"],
|
||||
"status": status,
|
||||
"personastate": player["personastate"],
|
||||
"gameid": gameid,
|
||||
"game_name": game_name,
|
||||
}
|
||||
|
||||
|
||||
def image_to_bytes(image: Image.Image) -> bytes:
|
||||
with BytesIO() as bio:
|
||||
image.save(bio, format="PNG")
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def hex_to_rgb(hex_color: str):
|
||||
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def convert_timestamp_to_beijing_time(timestamp: int) -> str:
|
||||
beijing_timezone = pytz.timezone("Asia/Shanghai")
|
||||
date_utc = datetime.datetime.fromtimestamp(timestamp, pytz.utc)
|
||||
date_beijing = date_utc.astimezone(beijing_timezone)
|
||||
return date_beijing.strftime("%Y-%m-%d %H:%M:%S")
|
||||
# example: 2021-09-06 21:00:00
|
||||
Reference in New Issue
Block a user