Files
JWeBot/packages/jwebot-official-extensions/src/jwebot_official_extensions/local_commands.py
T
JWeBot Contributors bbddb5be6f
check / local-gate (push) Failing after 42s
Add virtual coin loans
2026-10-15 18:43:49 +08:00

1088 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 collections import Counter
from datetime import datetime, timezone
from typing import Any, Callable
from zoneinfo import ZoneInfo
from .cs_autochess import CsMajorAutochess
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
from .player_games import PlayerGames
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})$")
MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e"
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 pack_reply_parts(parts: list[str] | tuple[str, ...]) -> str:
cleaned = [str(part).strip() for part in parts if str(part).strip()]
if not cleaned:
raise LocalCommandError("本地命令没有可发送回复")
if len(cleaned) == 1:
return cleaned[0]
return MULTI_REPLY_MARKER + json.dumps(
cleaned,
ensure_ascii=False,
separators=(",", ":"),
)
def unpack_reply_parts(value: object) -> list[str] | None:
text = str(value or "")
if not text.startswith(MULTI_REPLY_MARKER):
return None
try:
payload = json.loads(text[len(MULTI_REPLY_MARKER) :])
except json.JSONDecodeError:
return None
if (
not isinstance(payload, list)
or not payload
or any(not isinstance(item, str) or not item.strip() for item in payload)
):
return None
return [item.strip() for item in payload]
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
self.players = PlayerGames(ledger, person_display_label)
self.autochess = CsMajorAutochess(ledger)
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 == "context_summary":
return self.reply_cleaner(self.context_summary(row, arguments))
if kind in {"person_directory", "person_info"}:
return self.reply_cleaner(self.person_index(row, kind, arguments))
if kind in {
"wallet",
"transfer",
"loan",
"repay",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
}:
return self.reply_cleaner(self.players.handle(row, kind, arguments))
if kind == "cs_autochess":
parts = [
self.reply_cleaner(part)
for part in self.autochess.handle_parts(row)
]
return pack_reply_parts(parts)
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}"
def context_summary(self, row: dict[str, Any], arguments: str) -> str:
arguments = arguments.strip()
if arguments.strip() not in {"", "100", "200", "300"}:
return "用法:/上下文 100|200|300(/context),默认100条"
limit = int(arguments or 100)
self.ledger.bootstrap_repeat_history()
records = self.ledger.recent_context_messages(
str(row["group_fingerprint"]), limit,
exclude_event_id=str(row["event_id"]))
if not records:
return "当前群还没有可总结的本地文字记录"
# Local extractive summary: rank representative messages, retaining chronology.
stop = {"这个", "那个", "就是", "然后", "现在", "我们", "你们", "可以",
"不是", "一个", "什么", "怎么", "感觉", "还是", "已经", "没有",
"the", "and", "you", "that", "this", "jwebot"}
texts = [re.sub(r"^@\S+\s+", "", normalize_text(record["text"])) for record in records]
tokens = [set(re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}|[\u4e00-\u9fff]{2}", text.casefold())) - stop
for text in texts]
frequency = Counter(token for message in tokens for token in message)
topics = [token for token, count in frequency.most_common(6) if count > 1]
scored = sorted(range(len(records)), key=lambda i: (
-sum(frequency[token] for token in tokens[i]),
-int(bool(re.search(r"决定|计划|问题|需要|建议|修复|明天|[??]", texts[i]))),
-i))
selected: list[int] = []
seen: set[str] = set()
for index in scored:
if texts[index] in seen or texts[index].lstrip().startswith("/"):
continue
selected.append(index)
seen.add(texts[index])
if len(selected) == 5:
break
if not selected:
selected = list(range(max(0, len(records) - 3), len(records)))
people = len({record["display_name"] for record in records})
lines = [f"上下文总结(本地提取):请求最近{limit}条,实际{len(records)}条,{people}人。",
f"范围 {records[0]['observed_at'][:16]} ~ {records[-1]['observed_at'][:16]}。",
"主要话题:" + ("、".join(topics) if topics else "话题分散,以下为代表性内容。")]
for index in sorted(selected):
record = records[index]
lines.append(f"• {record['display_name']}:{texts[index][:100]}")
lines.append("仅覆盖本机已记录的当前群文字;问题或计划按原话提取。")
return "\n".join(lines)
def person_index(self, row: dict[str, Any], kind: str, arguments: str) -> str:
group = str(row["group_fingerprint"])
directory = self.ledger.person_directory(group)
if kind == "person_directory":
if arguments and not re.fullmatch(r"[1-9]\d{0,2}", arguments):
return "用法:/人物列表 [页码](/people),每页5人"
page = int(arguments or 1)
pages = max(1, (len(directory) + 4) // 5)
if page > pages:
return f"只有 {pages} 页;/人物列表 1"
lines = [f"当前群人物与别名({page}/{pages},共{len(directory)}人)"]
for person in directory[(page - 1) * 5:page * 5]:
aliases = "、".join(person["aliases"])[:80] or "未配置"
prefixes = "、".join(person["prefixes"])[:40]
lines.append(f"{person['display_name']}:{aliases}" + (f";前缀 {prefixes}*" if prefixes else ""))
lines.append("/人物 [名称或别名](/person)查看基本属性;/人物列表 页码 翻页")
return "\n".join(lines)
requested = normalize_text(arguments) or str(row["sender"])
person = self.ledger.resolve_person(group, requested)
entry = next((item for item in directory if item["person_key"] == person["person_key"]), None)
if arguments and entry is None:
return "当前群没有这个已配置人物或别名;/人物列表 查看索引"
facts = str(person.get("identity_fact", ""))
if facts and not self.profile_fact_is_safe(facts):
facts = ""
aliases = "、".join(entry["aliases"])[:160] if entry else "未配置"
return (f"人物:{self.person_display_label(person['display_name'])};"
f"群内索引 {person['person_key'][:12]};别名:{aliases or '未配置'}。\n"
f"基本属性:{facts or '暂无配置'}。\n"
"本人金币 /金币;个人记忆 /我的记忆。身份由本地昵称映射解析,无法读取微信原生账号ID。")
@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} 名玩家拿分,下一轮继续"