1046 lines
43 KiB
Python
1046 lines
43 KiB
Python
#!/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 .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})$")
|
||
|
||
|
||
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
|
||
self.players = PlayerGames(ledger, person_display_label)
|
||
|
||
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",
|
||
"prize_draw",
|
||
"lottery",
|
||
"blackjack",
|
||
"game_history",
|
||
"game_rules",
|
||
}:
|
||
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
||
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} 名玩家拿分,下一轮继续"
|