release: public alpha 0.6.0 baseline
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Official local command implementations for the packaged Edge runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
|
||||
|
||||
|
||||
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
|
||||
DICE_CHALLENGE_TIMEOUT_SECONDS = 60 * 60
|
||||
MAX_RPS_PLAYERS = 8
|
||||
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
|
||||
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
|
||||
|
||||
|
||||
class LocalCommandError(RuntimeError):
|
||||
"""Controlled local-command failure safe for the Edge response boundary."""
|
||||
|
||||
|
||||
def normalize_text(value: object) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def deterministic_int(seed: str, lower: int, upper: int) -> int:
|
||||
if lower > upper:
|
||||
raise LocalCommandError("invalid deterministic range")
|
||||
value = int.from_bytes(
|
||||
hashlib.sha256(seed.encode("utf-8", "replace")).digest()[:8],
|
||||
"big",
|
||||
)
|
||||
return lower + value % (upper - lower + 1)
|
||||
|
||||
|
||||
def local_command_parts(question: str) -> tuple[str, str]:
|
||||
normalized = question.lstrip()
|
||||
if not normalized.startswith("/"):
|
||||
return "", ""
|
||||
raw = normalized[1:].lstrip()
|
||||
folded = raw.casefold()
|
||||
for command, kind in sorted(
|
||||
LOCAL_COMMAND_ALIASES.items(),
|
||||
key=lambda item: len(item[0]),
|
||||
reverse=True,
|
||||
):
|
||||
command_key = command.casefold()
|
||||
if folded == command_key:
|
||||
return kind, ""
|
||||
if folded.startswith(command_key):
|
||||
return kind, raw[len(command):].strip()
|
||||
command, _, arguments = raw.partition(" ")
|
||||
return LOCAL_COMMAND_ALIASES.get(command.casefold(), ""), arguments.strip()
|
||||
|
||||
|
||||
class LocalCommandEngine:
|
||||
"""Deterministic group mini-games backed by the local edge ledger."""
|
||||
|
||||
LOTS = (
|
||||
"今天适合开一局,别在大厅里演了喵",
|
||||
"抽到普通签,稳住节奏比整活更重要喵",
|
||||
"小吉,喊人失败就先自己排一把喵",
|
||||
"中吉,适合清理积压的小任务喵",
|
||||
"大吉,今天的手感看起来不太像会背刺你喵",
|
||||
"末吉,先喝口水,别急着和队友对线喵",
|
||||
"吉,能做完一点就是赚到一点喵",
|
||||
"平签,今天适合安静发育,少折腾喵",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ledger: Any,
|
||||
config: Any | None = None,
|
||||
*,
|
||||
steam: Any | None = None,
|
||||
balance_provider: Callable[[], list[dict[str, str]]] | None = None,
|
||||
reply_cleaner: Callable[[object], str],
|
||||
person_display_label: Callable[[object], str],
|
||||
profile_fact_is_safe: Callable[[object], bool],
|
||||
profile_expiry: Callable[..., float],
|
||||
) -> None:
|
||||
self.ledger = ledger
|
||||
self.config = config
|
||||
self.steam = steam
|
||||
self.balance_provider = balance_provider
|
||||
self.reply_cleaner = reply_cleaner
|
||||
self.person_display_label = person_display_label
|
||||
self.profile_fact_is_safe = profile_fact_is_safe
|
||||
self.profile_expiry = profile_expiry
|
||||
|
||||
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
question = str(row["question"])
|
||||
kind, arguments = local_command_parts(question)
|
||||
if not kind:
|
||||
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
|
||||
if kind == "feature_list":
|
||||
return self.reply_cleaner(self.feature_list())
|
||||
if kind == "dice":
|
||||
return self.reply_cleaner(self.dice(row, arguments))
|
||||
if kind == "dice_challenge":
|
||||
return self.reply_cleaner(self.dice_challenge(row, arguments))
|
||||
if kind == "coin":
|
||||
return self.reply_cleaner(self.coin(row))
|
||||
if kind == "random_number":
|
||||
return self.reply_cleaner(self.random_number(row, arguments))
|
||||
if kind == "draw_lot":
|
||||
return self.reply_cleaner(self.draw_lot(row))
|
||||
if kind == "daily_fortune":
|
||||
return self.reply_cleaner(self.daily_fortune(row, event))
|
||||
if kind == "guess_number":
|
||||
return self.reply_cleaner(self.guess_number(row, arguments))
|
||||
if kind == "rps":
|
||||
return self.reply_cleaner(self.rps(row, arguments))
|
||||
if kind == "remember":
|
||||
return self.reply_cleaner(self.remember(row, arguments))
|
||||
if kind == "my_memory":
|
||||
return self.reply_cleaner(self.show_memory(row, ""))
|
||||
if kind == "forget_memory":
|
||||
return self.reply_cleaner(self.forget_memory(row, arguments))
|
||||
if kind == "person_memory":
|
||||
return self.reply_cleaner(self.show_memory(row, arguments))
|
||||
if kind == "memory_help":
|
||||
return self.reply_cleaner(self.memory_help())
|
||||
if kind == "scan_ranking":
|
||||
return self.reply_cleaner(self.scan_ranking(row))
|
||||
if kind == "repeat_ranking":
|
||||
return self.reply_cleaner(self.repeat_ranking(row))
|
||||
if kind == "history_search":
|
||||
return self.reply_cleaner(self.history_search(row, arguments))
|
||||
if kind in {"local_status", "server_status"}:
|
||||
return self.reply_cleaner(self.server_status())
|
||||
if kind == "jwebot_version":
|
||||
return self.reply_cleaner(self.jwebot_version())
|
||||
if kind == "telemetry_debug":
|
||||
return self.reply_cleaner(self.telemetry_debug())
|
||||
if kind == "deepseek_balance":
|
||||
return self.reply_cleaner(self.deepseek_balance())
|
||||
if kind == "steam_friends":
|
||||
return self.reply_cleaner(self.steam_friends(arguments))
|
||||
raise LocalCommandError("unsupported local command")
|
||||
|
||||
@staticmethod
|
||||
def feature_list() -> str:
|
||||
return render_feature_list()
|
||||
|
||||
@staticmethod
|
||||
def seed(row: dict[str, Any], label: str) -> str:
|
||||
return f"{row['group_fingerprint']}|{row['event_id']}|{label}"
|
||||
|
||||
@staticmethod
|
||||
def player_key(row: dict[str, Any]) -> str:
|
||||
return sha256(str(row["sender"]))[:16]
|
||||
|
||||
@staticmethod
|
||||
def memory_help() -> str:
|
||||
return (
|
||||
"记忆命令:/记住 内容 会立刻保存,/我的记忆 查看自己,"
|
||||
"/人物记忆 显示名 查看同群非敏感摘要,"
|
||||
"/忘记 关键词 或 /忘记 全部 可以清理自己的记忆"
|
||||
)
|
||||
|
||||
def scan_ranking(self, row: dict[str, Any]) -> str:
|
||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||
ranking = self.ledger.clock_scan_ranking(
|
||||
str(row["group_fingerprint"]),
|
||||
local_date,
|
||||
)
|
||||
if not ranking:
|
||||
return "北京时间今天还没有已确认送达的扫码记录"
|
||||
listing = ";".join(
|
||||
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
||||
for index, entry in enumerate(ranking, start=1)
|
||||
)
|
||||
return f"北京时间今日扫码排行:{listing}"
|
||||
|
||||
def repeat_ranking(self, row: dict[str, Any]) -> str:
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||
ranking = self.ledger.repeat_ranking(
|
||||
str(row["group_fingerprint"]),
|
||||
local_date,
|
||||
)
|
||||
if not ranking:
|
||||
return "北京时间今天还没有可统计的复读"
|
||||
lines = []
|
||||
for index, entry in enumerate(ranking, start=1):
|
||||
repeaters = "、".join(
|
||||
f"{name}×{count}"
|
||||
for name, count in sorted(entry["repeaters"].items())
|
||||
)
|
||||
lines.append(
|
||||
f"{index}. “{entry['text'][:48]}” 首发 {entry['origin']},"
|
||||
f"后续复读 {entry['repeat_count']} 次({repeaters})"
|
||||
)
|
||||
return "北京时间今日复读榜:" + ";".join(lines)
|
||||
|
||||
def history_search(self, row: dict[str, Any], arguments: str) -> str:
|
||||
query = normalize_text(arguments)
|
||||
if len(query) < 2:
|
||||
return "用法:/查记录 关键词,至少两个字"
|
||||
self.ledger.bootstrap_repeat_history()
|
||||
records = self.ledger.search_repeat_history(
|
||||
str(row["group_fingerprint"]),
|
||||
query,
|
||||
)
|
||||
if not records:
|
||||
return f"当前群没有找到“{query}”的本地历史记录"
|
||||
listing = ";".join(
|
||||
f"{record['observed_at'][5:16]} {record['display_name']}:"
|
||||
f"{record['text'][:80]}"
|
||||
for record in records
|
||||
)
|
||||
return f"本地记录命中“{query}”:{listing}"
|
||||
|
||||
@staticmethod
|
||||
def service_state(service_name: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "--user", "is-active", service_name],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unknown"
|
||||
return "active" if result.returncode == 0 else "inactive"
|
||||
|
||||
def local_status(self) -> str:
|
||||
state = self.ledger.status()
|
||||
edge_service = self.service_state("jwebot-edge.service")
|
||||
ocr_service = self.service_state("wechat-ocr-adaptive-recorder.service")
|
||||
direct_api = "已配置" if self.config and self.config.deepseek_key else "未配置"
|
||||
return (
|
||||
f"本地状态:Edge {edge_service},OCR {ocr_service},"
|
||||
f"Flash {direct_api};直聊待处理 {state['direct_chat_pending']},"
|
||||
f"云端待处理 {state['outbox_pending']},"
|
||||
f"本地游戏 {state['local_games']}"
|
||||
)
|
||||
|
||||
def server_status(self) -> str:
|
||||
return self.local_status()
|
||||
|
||||
@staticmethod
|
||||
def package_version(module_name: str) -> str:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return "unknown"
|
||||
return str(getattr(module, "__version__", "unknown"))
|
||||
|
||||
def jwebot_version(self) -> str:
|
||||
return (
|
||||
"JWeBot 版本:"
|
||||
f"kernel {self.package_version('jwebot_kernel')},"
|
||||
f"sdk {self.package_version('jwebot_sdk')},"
|
||||
"official "
|
||||
f"{self.package_version('jwebot_official_extensions')}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def percentile(values: list[float], percent: float) -> float:
|
||||
if not values:
|
||||
raise LocalCommandError("missing telemetry samples")
|
||||
ordered = sorted(values)
|
||||
if len(ordered) == 1:
|
||||
return ordered[0]
|
||||
position = (len(ordered) - 1) * percent / 100
|
||||
lower = int(position)
|
||||
upper = min(lower + 1, len(ordered) - 1)
|
||||
if lower == upper:
|
||||
return ordered[lower]
|
||||
return ordered[lower] + (ordered[upper] - ordered[lower]) * (
|
||||
position - lower
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def telemetry_rows_from_text(text: str) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for line in text.splitlines():
|
||||
if "telemetry " in line:
|
||||
line = line.split("telemetry ", 1)[1]
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
try:
|
||||
elapsed = float(value["elapsed_ms"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
name = str(value.get("name", ""))[:80]
|
||||
if not name:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"elapsed_ms": elapsed,
|
||||
"status": str(value.get("status", "unknown"))[:24],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def format_duration(milliseconds: float) -> str:
|
||||
if milliseconds >= 1000:
|
||||
return f"{milliseconds / 1000:.2f}s"
|
||||
return f"{milliseconds:.1f}ms"
|
||||
|
||||
@classmethod
|
||||
def render_telemetry_summary(cls, rows: list[dict[str, Any]]) -> str:
|
||||
values_by_name: dict[str, list[float]] = {}
|
||||
for row in rows:
|
||||
if row.get("status") != "ok":
|
||||
continue
|
||||
values_by_name.setdefault(str(row["name"]), []).append(
|
||||
float(row["elapsed_ms"])
|
||||
)
|
||||
if not values_by_name:
|
||||
return "调试埋点:最近没有可汇总的耗时样本"
|
||||
ranked = sorted(
|
||||
values_by_name.items(),
|
||||
key=lambda item: cls.percentile(item[1], 95),
|
||||
reverse=True,
|
||||
)[:8]
|
||||
lines = ["调试埋点 p50/p95(最近2小时)"]
|
||||
for name, values in ranked:
|
||||
lines.append(
|
||||
f"- {name}: n={len(values)},"
|
||||
f"p50={cls.format_duration(cls.percentile(values, 50))},"
|
||||
f"p95={cls.format_duration(cls.percentile(values, 95))},"
|
||||
f"max={cls.format_duration(max(values))}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def telemetry_debug(self) -> str:
|
||||
command = [
|
||||
"/usr/bin/journalctl",
|
||||
"--user",
|
||||
"-u",
|
||||
"jwebot-edge.service",
|
||||
"-u",
|
||||
"wechat-ocr-adaptive-recorder.service",
|
||||
"--since",
|
||||
"2 hours ago",
|
||||
"--no-pager",
|
||||
"-o",
|
||||
"cat",
|
||||
"-n",
|
||||
"2000",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=4,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "调试埋点:journal 当前不可用"
|
||||
if result.returncode != 0:
|
||||
return "调试埋点:journal 当前不可用"
|
||||
return self.render_telemetry_summary(
|
||||
self.telemetry_rows_from_text(result.stdout)
|
||||
)
|
||||
|
||||
def deepseek_balance(self) -> str:
|
||||
if self.config is None or self.balance_provider is None:
|
||||
return "余额查询当前不可用"
|
||||
balances = self.balance_provider()
|
||||
if not balances:
|
||||
return "DeepSeek 当前没有可用余额"
|
||||
listing = ";".join(
|
||||
f"{item['currency']} 总 {item['total_balance']},"
|
||||
f"充值 {item['topped_up_balance']},赠金 {item['granted_balance']}"
|
||||
for item in balances
|
||||
)
|
||||
return f"DeepSeek 余额:{listing}"
|
||||
|
||||
def steam_friends(self, arguments: str) -> str:
|
||||
if self.steam is None:
|
||||
return "Steam 查询当前不可用"
|
||||
return self.steam.render(arguments)
|
||||
|
||||
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
person = self.ledger.resolve_person(
|
||||
str(row["group_fingerprint"]),
|
||||
str(row["sender"]),
|
||||
)
|
||||
return self.ledger.person_profile(
|
||||
str(row["group_fingerprint"]),
|
||||
str(person["person_key"]),
|
||||
)
|
||||
|
||||
def remember(self, row: dict[str, Any], arguments: str) -> str:
|
||||
text = normalize_text(arguments)
|
||||
if not text:
|
||||
return "要记什么直接写在后面,例如 /记住 我更喜欢技术细节"
|
||||
if not self.profile_fact_is_safe(text):
|
||||
return "这类内容不适合做个人记忆,换成偏好、话题或未完结事项吧"
|
||||
group = str(row["group_fingerprint"])
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
profile = self.ledger.person_profile(group, person_key)
|
||||
facts = list(profile["facts"]) if profile else []
|
||||
normalized = text.casefold()
|
||||
matched = next(
|
||||
(fact for fact in facts if str(fact["text"]).casefold() == normalized),
|
||||
None,
|
||||
)
|
||||
now = utc_now()
|
||||
if matched is None:
|
||||
facts.append(
|
||||
{
|
||||
"text": text,
|
||||
"kind": "preference",
|
||||
"source": "explicit",
|
||||
"evidence_count": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"expires_at": self.profile_expiry("preference", explicit=True),
|
||||
}
|
||||
)
|
||||
else:
|
||||
matched["source"] = "explicit"
|
||||
matched["updated_at"] = now
|
||||
matched["expires_at"] = self.profile_expiry("preference", explicit=True)
|
||||
self.ledger.save_person_profile(
|
||||
group,
|
||||
person_key,
|
||||
str(person["display_name"]),
|
||||
facts,
|
||||
)
|
||||
return "记下了,这条只在当前群的同一身份作用域里生效"
|
||||
|
||||
def show_memory(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
requested = normalize_text(arguments)
|
||||
if requested:
|
||||
person = self.ledger.resolve_person(group, requested)
|
||||
profile = self.ledger.person_profile(
|
||||
group,
|
||||
str(person["person_key"]),
|
||||
)
|
||||
if profile is None:
|
||||
return "当前群没有这位显示名的可用记忆"
|
||||
else:
|
||||
profile = self.profile_for_row(row)
|
||||
if not profile or not profile["facts"]:
|
||||
return "暂时没有可用记忆,/记住 内容 可以直接补一条"
|
||||
facts = ";".join(
|
||||
f"[{fact.get('kind', 'topic')}] {fact['text']}"
|
||||
for fact in profile["facts"]
|
||||
)
|
||||
label = profile["display_name"]
|
||||
return f"{label} 的群内记忆:{facts}"
|
||||
|
||||
def forget_memory(self, row: dict[str, Any], arguments: str) -> str:
|
||||
query = normalize_text(arguments)
|
||||
if not query:
|
||||
return "用法:/忘记 关键词,或 /忘记 全部"
|
||||
group = str(row["group_fingerprint"])
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
profile = self.ledger.person_profile(group, person_key)
|
||||
if profile is None:
|
||||
return "没有可清理的个人记忆"
|
||||
if query == "全部":
|
||||
self.ledger.clear_person_profile(group, person_key)
|
||||
return "当前群内的个人记忆已清空"
|
||||
kept = [
|
||||
fact
|
||||
for fact in profile["facts"]
|
||||
if query.casefold() not in str(fact["text"]).casefold()
|
||||
]
|
||||
if len(kept) == len(profile["facts"]):
|
||||
return "没有找到匹配的记忆"
|
||||
if kept:
|
||||
self.ledger.save_person_profile(
|
||||
group,
|
||||
person_key,
|
||||
str(person["display_name"]),
|
||||
kept,
|
||||
)
|
||||
else:
|
||||
self.ledger.clear_person_profile(group, person_key)
|
||||
return "匹配的个人记忆已忘掉"
|
||||
|
||||
def dice(self, row: dict[str, Any], arguments: str) -> str:
|
||||
match = DICE_PATTERN.fullmatch(arguments)
|
||||
if not match:
|
||||
return "骰子格式不对,试试 /丢骰子 或 /丢骰子 2d6"
|
||||
count = int(match.group(1) or 1)
|
||||
faces = int(match.group(2) or 6)
|
||||
if count > 10 or not 2 <= faces <= 1_000:
|
||||
return "一次最多 10 个骰子,点数范围 2 到 1000,别拿骰盅砸服务器喵"
|
||||
rolls = [
|
||||
deterministic_int(self.seed(row, f"dice:{index}"), 1, faces)
|
||||
for index in range(count)
|
||||
]
|
||||
label = f"{count}d{faces}"
|
||||
if count == 1:
|
||||
return f"骰子 {label} 掷出了 {rolls[0]}"
|
||||
return f"骰子 {label}:{'、'.join(map(str, rolls))},合计 {sum(rolls)}"
|
||||
|
||||
@staticmethod
|
||||
def dice_challenge_spec(value: str) -> tuple[int, int] | None:
|
||||
match = DICE_PATTERN.fullmatch(value)
|
||||
if not match:
|
||||
return None
|
||||
count = int(match.group(1) or 1)
|
||||
faces = int(match.group(2) or 6)
|
||||
if count != 5 or not 6 <= faces <= 1_000:
|
||||
return None
|
||||
return count, faces
|
||||
|
||||
@staticmethod
|
||||
def is_five_straight(rolls: list[int]) -> bool:
|
||||
values = sorted(set(rolls))
|
||||
return len(values) == 5 and all(
|
||||
values[index] + 1 == values[index + 1]
|
||||
for index in range(4)
|
||||
)
|
||||
|
||||
def dice_challenge(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
state, expired = self.active_game(row, "dice_challenge")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
|
||||
if action == "状态":
|
||||
if state is None:
|
||||
return "现在没有丢骰子挑战,直接 /丢骰子挑战 开一局 5d6"
|
||||
entries = state.get("entries", {})
|
||||
if not isinstance(entries, dict) or not entries:
|
||||
return f"本局是 {state['dice_count']}d{state['faces']},还没人掷骰"
|
||||
ranking = sorted(
|
||||
(
|
||||
entry
|
||||
for entry in entries.values()
|
||||
if isinstance(entry, dict)
|
||||
),
|
||||
key=lambda entry: (-int(entry.get("total", 0)), str(entry.get("display_name", ""))),
|
||||
)
|
||||
listing = ";".join(
|
||||
f"{entry.get('display_name', 'unknown')} {entry.get('total', 0)}"
|
||||
for entry in ranking
|
||||
)
|
||||
return (
|
||||
f"本局 {state['dice_count']}d{state['faces']}:{listing}。"
|
||||
"继续 /丢骰子挑战 掷骰,或 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
if action == "战绩":
|
||||
achievements = self.ledger.dice_challenge_achievements(group)
|
||||
if not achievements:
|
||||
return "本群还没有丢骰子挑战达成记录"
|
||||
listing = ";".join(
|
||||
f"{entry['display_name']} [{entry['achievement']}] {entry['details']}"
|
||||
for entry in achievements
|
||||
)
|
||||
return f"丢骰子挑战战绩:{listing}"
|
||||
|
||||
if action in {"结算", "结束"}:
|
||||
if state is None:
|
||||
if expired:
|
||||
return "本局丢骰子挑战已超时,未结算点数不计战绩,重新开一局吧"
|
||||
return "没有进行中的丢骰子挑战"
|
||||
if action == "结束":
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
return "本局丢骰子挑战已结束,未结算的点数不计战绩"
|
||||
entries = state.get("entries", {})
|
||||
valid_entries = [
|
||||
entry
|
||||
for entry in entries.values()
|
||||
if isinstance(entry, dict)
|
||||
] if isinstance(entries, dict) else []
|
||||
if not valid_entries:
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
return "本局没人掷骰,挑战取消"
|
||||
highest = max(int(entry.get("total", 0)) for entry in valid_entries)
|
||||
winners = [
|
||||
entry
|
||||
for entry in valid_entries
|
||||
if int(entry.get("total", 0)) == highest
|
||||
]
|
||||
for winner in winners:
|
||||
self.ledger.record_dice_challenge_achievement(
|
||||
group_fingerprint=group,
|
||||
event_id=str(row["event_id"]),
|
||||
person_key=str(winner["person_key"]),
|
||||
display_name=str(winner["display_name"]),
|
||||
achievement="总点冠军",
|
||||
details=(
|
||||
f"{state['dice_count']}d{state['faces']} 合计 {highest}"
|
||||
),
|
||||
)
|
||||
ranking = sorted(
|
||||
valid_entries,
|
||||
key=lambda entry: (
|
||||
-int(entry.get("total", 0)),
|
||||
str(entry.get("display_name", "")),
|
||||
),
|
||||
)
|
||||
full_results = ";".join(
|
||||
(
|
||||
f"{index}. {entry['display_name']} {entry['total']} "
|
||||
f"({'、'.join(map(str, entry['rolls']))})"
|
||||
+ (" 五连号" if entry.get("five_straight") else "")
|
||||
)
|
||||
for index, entry in enumerate(ranking, start=1)
|
||||
)
|
||||
winner_names = "、".join(str(winner["display_name"]) for winner in winners)
|
||||
straights = [
|
||||
entry
|
||||
for entry in valid_entries
|
||||
if bool(entry.get("five_straight"))
|
||||
]
|
||||
self.ledger.clear_local_game(group, "dice_challenge")
|
||||
straight_note = (
|
||||
";五连号达成:" + "、".join(
|
||||
str(entry["display_name"]) for entry in straights
|
||||
)
|
||||
if straights
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"本局 {state['dice_count']}d{state['faces']} 结算:"
|
||||
f"{full_results}。冠军 {winner_names},{highest} 点{straight_note}"
|
||||
)
|
||||
|
||||
requested_spec = ""
|
||||
if action == "开始":
|
||||
requested_spec = parts[1] if len(parts) == 2 else "5d6"
|
||||
if state is not None:
|
||||
return (
|
||||
f"本局已经是 {state['dice_count']}d{state['faces']},"
|
||||
"直接 /丢骰子挑战 掷骰"
|
||||
)
|
||||
spec = self.dice_challenge_spec(requested_spec)
|
||||
if spec is None:
|
||||
return "挑战只支持 5d6 到 5d1000,例如 /丢骰子挑战5d20"
|
||||
state = self.touch_challenge(
|
||||
{
|
||||
"dice_count": spec[0],
|
||||
"faces": spec[1],
|
||||
"entries": {},
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "dice_challenge", state)
|
||||
return (
|
||||
f"丢骰子挑战开局:{state['dice_count']}d{state['faces']}。"
|
||||
"每人直接 /丢骰子挑战 掷骰,最后 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
if action in {"", "掷", "骰"}:
|
||||
requested_spec = ""
|
||||
else:
|
||||
requested_spec = action
|
||||
if state is None:
|
||||
spec = self.dice_challenge_spec(requested_spec or "5d6")
|
||||
if spec is None:
|
||||
if expired:
|
||||
return "上一局挑战已超时,直接 /丢骰子挑战 或 /丢骰子挑战5d20 重开"
|
||||
return "挑战只支持 5d6 到 5d1000,例如 /丢骰子挑战5d20"
|
||||
state = self.touch_challenge(
|
||||
{
|
||||
"dice_count": spec[0],
|
||||
"faces": spec[1],
|
||||
"entries": {},
|
||||
}
|
||||
)
|
||||
elif requested_spec:
|
||||
spec = self.dice_challenge_spec(requested_spec)
|
||||
if spec is None or spec != (int(state["dice_count"]), int(state["faces"])):
|
||||
return (
|
||||
f"本局固定为 {state['dice_count']}d{state['faces']},"
|
||||
"先结算或结束再换规则"
|
||||
)
|
||||
|
||||
person = self.ledger.resolve_person(group, str(row["sender"]))
|
||||
person_key = str(person["person_key"])
|
||||
entries = state.setdefault("entries", {})
|
||||
if person_key in entries:
|
||||
return "你这局已经掷过了,等大家掷完后 /丢骰子挑战 结算"
|
||||
rolls = [
|
||||
deterministic_int(
|
||||
self.seed(row, f"dice-challenge:{index}"),
|
||||
1,
|
||||
int(state["faces"]),
|
||||
)
|
||||
for index in range(int(state["dice_count"]))
|
||||
]
|
||||
total = sum(rolls)
|
||||
straight = self.is_five_straight(rolls)
|
||||
entries[person_key] = {
|
||||
"person_key": person_key,
|
||||
"display_name": self.person_display_label(person["display_name"]),
|
||||
"rolls": rolls,
|
||||
"total": total,
|
||||
"five_straight": straight,
|
||||
}
|
||||
self.ledger.save_local_game(
|
||||
group,
|
||||
"dice_challenge",
|
||||
self.touch_challenge(state),
|
||||
)
|
||||
if straight:
|
||||
self.ledger.record_dice_challenge_achievement(
|
||||
group_fingerprint=group,
|
||||
event_id=str(row["event_id"]),
|
||||
person_key=person_key,
|
||||
display_name=self.person_display_label(person["display_name"]),
|
||||
achievement="五连号",
|
||||
details=(
|
||||
f"{state['dice_count']}d{state['faces']} "
|
||||
f"{'-'.join(map(str, sorted(rolls)))}"
|
||||
),
|
||||
)
|
||||
straight_note = ",五连号达成" if straight else ""
|
||||
return (
|
||||
f"{self.person_display_label(person['display_name'])} 挑战掷出 "
|
||||
f"{'、'.join(map(str, rolls))},合计 {total}{straight_note}。"
|
||||
"其他人继续 /丢骰子挑战,最后 /丢骰子挑战 结算"
|
||||
)
|
||||
|
||||
def coin(self, row: dict[str, Any]) -> str:
|
||||
side = ("正面", "反面")[
|
||||
deterministic_int(self.seed(row, "coin"), 0, 1)
|
||||
]
|
||||
return f"硬币落地:{side}"
|
||||
|
||||
def random_number(self, row: dict[str, Any], arguments: str) -> str:
|
||||
match = RANDOM_NUMBER_PATTERN.fullmatch(arguments)
|
||||
if not match:
|
||||
return "用法:/随机数 最小值 最大值,例如 /随机数 1 100"
|
||||
lower, upper = int(match.group(1)), int(match.group(2))
|
||||
if lower > upper or upper - lower > 1_000_000:
|
||||
return "范围不对,最大跨度是 1000000,别让随机数跑马拉松喵"
|
||||
value = deterministic_int(self.seed(row, "random"), lower, upper)
|
||||
return f"随机数:{value}(范围 {lower} 到 {upper})"
|
||||
|
||||
def draw_lot(self, row: dict[str, Any]) -> str:
|
||||
value = self.LOTS[
|
||||
deterministic_int(self.seed(row, "draw-lot"), 0, len(self.LOTS) - 1)
|
||||
]
|
||||
return f"抽签结果:{value}"
|
||||
|
||||
def daily_fortune(self, row: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
observed_at = str(event.get("observed_at") or row["created_at"])
|
||||
day = observed_at[:10]
|
||||
score = deterministic_int(
|
||||
f"{row['group_fingerprint']}|{self.player_key(row)}|{day}|fortune",
|
||||
1,
|
||||
100,
|
||||
)
|
||||
if score >= 90:
|
||||
note = "今天手感在线,想做的事可以大胆开局"
|
||||
elif score >= 70:
|
||||
note = "节奏不错,别被小事带偏"
|
||||
elif score >= 40:
|
||||
note = "平稳发育就行,少和概率较劲"
|
||||
else:
|
||||
note = "先补水再开工,别硬扛"
|
||||
return f"今日运势 {score} 分:{note}"
|
||||
|
||||
def active_game(
|
||||
self,
|
||||
row: dict[str, Any],
|
||||
game_key: str,
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
state = self.ledger.local_game(str(row["group_fingerprint"]), game_key)
|
||||
if state is None:
|
||||
return None, False
|
||||
if float(state.get("expires_at", 0)) <= time.time():
|
||||
self.ledger.clear_local_game(str(row["group_fingerprint"]), game_key)
|
||||
return None, True
|
||||
return state, False
|
||||
|
||||
def touch(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
state["expires_at"] = time.time() + LOCAL_GAME_TIMEOUT_SECONDS
|
||||
return state
|
||||
|
||||
def touch_challenge(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
state["expires_at"] = time.time() + DICE_CHALLENGE_TIMEOUT_SECONDS
|
||||
return state
|
||||
|
||||
def guess_number(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
state, expired = self.active_game(row, "guess_number")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
if action == "开始":
|
||||
if state is not None:
|
||||
return (
|
||||
f"本群已经有一局猜数字,范围 1 到 {state['upper']},"
|
||||
"直接 /猜 数字 就行"
|
||||
)
|
||||
upper = 100
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
upper = int(parts[1])
|
||||
if not 10 <= upper <= 1_000:
|
||||
return "上限请设在 10 到 1000,别把群友扔进无尽数字海喵"
|
||||
state = self.touch(
|
||||
{
|
||||
"target": deterministic_int(
|
||||
self.seed(row, "guess-target"),
|
||||
1,
|
||||
upper,
|
||||
),
|
||||
"upper": upper,
|
||||
"attempts": 0,
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "guess_number", state)
|
||||
return f"猜数字开局,范围 1 到 {upper},用 /猜 数字 试试"
|
||||
if action == "状态":
|
||||
if state is None:
|
||||
return "现在没有猜数字局,先 /猜数字 开始"
|
||||
return f"猜数字还在继续,范围 1 到 {state['upper']},目前猜了 {state['attempts']} 次"
|
||||
if action == "结束":
|
||||
if state is None:
|
||||
return "没有进行中的猜数字局"
|
||||
self.ledger.clear_local_game(group, "guess_number")
|
||||
return f"本局结束,答案是 {state['target']},下次别被数字拿捏喵"
|
||||
|
||||
guess_text = ""
|
||||
if action == "猜" and len(parts) == 2:
|
||||
guess_text = parts[1]
|
||||
elif action.isdigit() and len(parts) == 1:
|
||||
guess_text = action
|
||||
if not guess_text:
|
||||
if expired:
|
||||
return "上一局猜数字已超时,先 /猜数字 开始"
|
||||
return "用 /猜 数字 下注,或者 /猜数字 开始 新开一局"
|
||||
if state is None:
|
||||
return "没有进行中的猜数字局,先 /猜数字 开始"
|
||||
guess = int(guess_text)
|
||||
upper = int(state["upper"])
|
||||
if not 1 <= guess <= upper:
|
||||
return f"只能猜 1 到 {upper},数字别离家出走喵"
|
||||
state["attempts"] = int(state["attempts"]) + 1
|
||||
if guess == int(state["target"]):
|
||||
attempts = state["attempts"]
|
||||
self.ledger.clear_local_game(group, "guess_number")
|
||||
return f"猜中了,答案就是 {guess},你用了 {attempts} 次,确实有点东西喵"
|
||||
self.ledger.save_local_game(group, "guess_number", self.touch(state))
|
||||
direction = "大了" if guess > int(state["target"]) else "小了"
|
||||
return f"{guess} {direction},继续猜"
|
||||
|
||||
def rps(self, row: dict[str, Any], arguments: str) -> str:
|
||||
group = str(row["group_fingerprint"])
|
||||
player = self.player_key(row)
|
||||
state, expired = self.active_game(row, "rps")
|
||||
parts = arguments.split()
|
||||
action = parts[0] if parts else ""
|
||||
if action == "开始":
|
||||
if state is not None:
|
||||
return "石头剪刀布已经开着了,/石头剪刀布 加入 后再出招"
|
||||
state = self.touch(
|
||||
{
|
||||
"round": 1,
|
||||
"players": {player: True},
|
||||
"choices": {},
|
||||
}
|
||||
)
|
||||
self.ledger.save_local_game(group, "rps", state)
|
||||
return "石头剪刀布开局了,你已加入,还差至少一名玩家,/石头剪刀布 加入"
|
||||
if action == "结束":
|
||||
if state is None:
|
||||
return "没有进行中的石头剪刀布"
|
||||
self.ledger.clear_local_game(group, "rps")
|
||||
return "石头剪刀布已收摊,下局再来喵"
|
||||
if state is None:
|
||||
if expired:
|
||||
return "上一局石头剪刀布已超时,重新 /石头剪刀布 开始"
|
||||
return "先 /石头剪刀布 开始 开局"
|
||||
players = state.setdefault("players", {})
|
||||
choices = state.setdefault("choices", {})
|
||||
if action == "加入":
|
||||
if player in players:
|
||||
return "你已经在这局里了,别想卡双号喵"
|
||||
if len(players) >= MAX_RPS_PLAYERS:
|
||||
return f"本局最多 {MAX_RPS_PLAYERS} 人,观战席先坐会儿喵"
|
||||
players[player] = True
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return f"加入成功,目前 {len(players)} 人,/石头剪刀布 出 石头"
|
||||
if action == "状态":
|
||||
missing = max(0, len(players) - len(choices))
|
||||
return (
|
||||
f"第 {state['round']} 回合:{len(players)} 人参战,"
|
||||
f"{len(choices)} 人已出招,还差 {missing} 人"
|
||||
)
|
||||
if action == "结算":
|
||||
self.ledger.clear_local_game(group, "rps")
|
||||
return "这局手动结算为取消,下一局记得别挂机喵"
|
||||
|
||||
choice = ""
|
||||
if action == "出" and len(parts) == 2:
|
||||
choice = parts[1]
|
||||
elif action in {"石头", "剪刀", "布"}:
|
||||
choice = action
|
||||
if choice not in {"石头", "剪刀", "布"}:
|
||||
return "用 /石头剪刀布 出 石头、剪刀或布"
|
||||
if player not in players:
|
||||
return "先 /石头剪刀布 加入,再偷偷出招喵"
|
||||
choices[player] = choice
|
||||
if len(players) < 2:
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return "出招记下了,但还差一名玩家加入"
|
||||
if len(choices) < len(players):
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return f"出招记下了,还差 {len(players) - len(choices)} 人"
|
||||
outcome = self.resolve_rps(choices)
|
||||
state["round"] = int(state["round"]) + 1
|
||||
state["choices"] = {}
|
||||
self.ledger.save_local_game(group, "rps", self.touch(state))
|
||||
return outcome
|
||||
|
||||
@staticmethod
|
||||
def resolve_rps(choices: dict[str, str]) -> str:
|
||||
values = set(choices.values())
|
||||
if len(values) in {1, 3}:
|
||||
return "这一轮平局,大家默契得有点过头了,下一轮继续"
|
||||
winning_choice = next(
|
||||
winner
|
||||
for winner, loser in (("石头", "剪刀"), ("剪刀", "布"), ("布", "石头"))
|
||||
if winner in values and loser in values
|
||||
)
|
||||
winners = sum(choice == winning_choice for choice in choices.values())
|
||||
return f"这一轮 {winning_choice} 赢了,{winners} 名玩家拿分,下一轮继续"
|
||||
Reference in New Issue
Block a user