@@ -23,6 +23,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -132,6 +133,18 @@ CLOCK_GRACE_MINUTES = 3
|
||||
CLOCK_POLL_SECONDS = 15
|
||||
CLOCK_MIN_GROUP_MEMBERS = 2
|
||||
CLOCK_RANKING_LIMIT = 5
|
||||
AMBIENT_CHAT_START_HOUR = 10
|
||||
AMBIENT_CHAT_END_HOUR = 23
|
||||
AMBIENT_CHAT_ACTIVITY_MINUTES = 45
|
||||
AMBIENT_CHAT_MIN_CONTEXT_MESSAGES = 4
|
||||
AMBIENT_CHAT_MIN_CONTEXT_SPEAKERS = 2
|
||||
AMBIENT_CHAT_COOLDOWN_HOURS = 4
|
||||
AMBIENT_CHAT_PROBABILITY_PERCENT = 35
|
||||
TRIVIA_GAME_KEY = "trivia_quiz"
|
||||
TRIVIA_HISTORY_KEY = "trivia_quiz_history"
|
||||
TRIVIA_ANSWER_WINDOW_MINUTES = 5
|
||||
TRIVIA_QUIZ_PROBABILITY_PERCENT = 75
|
||||
TRIVIA_RECENT_BASE_LIMIT = 24
|
||||
BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
ACKNOWLEDGEMENT = "已收到,正在处理中喵"
|
||||
STEAM_ACKNOWLEDGEMENT = "Steam 查询 [■□□] 正在读取公开数据"
|
||||
@@ -195,9 +208,153 @@ TRIGGER = re.compile(
|
||||
PERSON_ALIAS_TEMPLATES: tuple[dict[str, object], ...] = ()
|
||||
PERSON_DISPLAY_IDS: dict[str, str] = {}
|
||||
CLOCK_SCAN_TARGETS: tuple[tuple[str, str], ...] = ()
|
||||
SCAN_BOOST_PITY_MISS_LIMIT = 2
|
||||
STEAM_GROUP_ROSTER: tuple[dict[str, object], ...] = ()
|
||||
PERSON_ALIAS_MIGRATION_VERSION = "person-aliases-empty"
|
||||
PROFILE_ALIAS_REPAIR_CANONICALS: frozenset[str] = frozenset()
|
||||
TRIVIA_QUESTIONS: tuple[dict[str, object], ...] = (
|
||||
{
|
||||
"id": "cs2-smoke-he",
|
||||
"domain": "CS",
|
||||
"question": "CS2 里哪种投掷物可以把烟雾短暂炸开一个缺口?",
|
||||
"options": ("燃烧弹", "高爆手雷", "诱饵弹", "闪光弹"),
|
||||
"answer": "B",
|
||||
"explanation": "CS2 的体积烟会被 HE 爆炸短暂驱散,子弹也能打出小洞。",
|
||||
},
|
||||
{
|
||||
"id": "csgo-danger-zone",
|
||||
"domain": "CS",
|
||||
"question": "CS:GO 最早的 Danger Zone 大逃杀地图叫什么?",
|
||||
"options": ("Blacksite", "Basalt", "Breach", "Anubis"),
|
||||
"answer": "A",
|
||||
"explanation": "Danger Zone 随 Blacksite 首发,后来才有 Sirocco 等地图。",
|
||||
},
|
||||
{
|
||||
"id": "apex-ash-name",
|
||||
"domain": "Apex",
|
||||
"question": "Apex 里 Ash 变成 Simulacrum 之前的人类名字是?",
|
||||
"options": ("Amelie Paquette", "Ashleigh Reid", "Mary Somers", "Anita Williams"),
|
||||
"answer": "B",
|
||||
"explanation": "Ash 的人类身份是 Dr. Ashleigh Reid,和 Horizon 的过去有关。",
|
||||
},
|
||||
{
|
||||
"id": "apex-horizon-son",
|
||||
"domain": "Apex",
|
||||
"question": "Apex 里 Horizon 一直想回去见到的儿子叫什么?",
|
||||
"options": ("Newton", "Elliott", "Jackson", "D.O.C."),
|
||||
"answer": "A",
|
||||
"explanation": "Horizon 的儿子叫 Newton,名字也呼应了她的科学家设定。",
|
||||
},
|
||||
{
|
||||
"id": "overwatch-sigma-name",
|
||||
"domain": "Overwatch",
|
||||
"question": "Overwatch 里 Sigma 的本名是?",
|
||||
"options": ("Jamison Fawkes", "Siebren de Kuiper", "Tekhartha Mondatta", "Akande Ogundimu"),
|
||||
"answer": "B",
|
||||
"explanation": "Sigma 的本名是 Siebren de Kuiper,事故前是天体物理学家。",
|
||||
},
|
||||
{
|
||||
"id": "overwatch-zen-title",
|
||||
"domain": "Overwatch",
|
||||
"question": "Zenyatta 名字前常见的 Tekhartha 更接近什么?",
|
||||
"options": ("家族姓氏", "僧侣称号", "武器型号", "出生地名"),
|
||||
"answer": "B",
|
||||
"explanation": "Tekhartha 是香巴里僧侣相关称号,不是普通意义的姓。",
|
||||
},
|
||||
{
|
||||
"id": "lol-ivern-cruel",
|
||||
"domain": "LoL",
|
||||
"question": "LoL 里 Ivern 成为绿树朋友前曾被称为什么?",
|
||||
"options": ("Ivern the Cruel", "Ivern the Lost", "Ivern the Silent", "Ivern the First"),
|
||||
"answer": "A",
|
||||
"explanation": "Ivern 过去是 Ivern the Cruel,后来在神树事件后完全转变。",
|
||||
},
|
||||
{
|
||||
"id": "lol-aurelion-crown",
|
||||
"domain": "LoL",
|
||||
"question": "Aurelion Sol 被巨神族控制主要靠什么东西束缚?",
|
||||
"options": ("星界锁链", "一顶冠冕", "暮光长矛", "世界符文"),
|
||||
"answer": "B",
|
||||
"explanation": "巨神族用冠冕诱骗并束缚了 Aurelion Sol,这也是他怨气来源之一。",
|
||||
},
|
||||
)
|
||||
TRIVIA_FACTS: tuple[tuple[str, str, str, str, tuple[str, str, str], str, int], ...] = (
|
||||
("cs-zeus", "CS", "哪件装备是一次性近距离电击武器?", "Zeus x27", ("P250", "XM1014", "Nova"), "Zeus x27 命中后通常只能打一发,是 CS 里很有梗的电击武器。", 3),
|
||||
("cs-defuse-kit", "CS", "哪件道具能缩短 CT 拆包时间?", "Defuse Kit", ("Kevlar", "Rescue Kit", "Tactical Shield"), "拆弹钳能把拆包时间从 10 秒缩短到 5 秒。", 2),
|
||||
("cs-blacksite", "CS", "CS:GO Danger Zone 首发地图是哪张?", "Blacksite", ("Sirocco", "Ember", "Basalt"), "Blacksite 是 Danger Zone 上线时的首发地图。", 4),
|
||||
("cs-he-smoke", "CS", "CS2 里能短暂驱散体积烟的爆炸物是?", "高爆手雷", ("燃烧弹", "诱饵弹", "闪光弹"), "CS2 体积烟会被 HE 爆炸短暂炸开缺口。", 4),
|
||||
("cs-subtick", "CS", "CS2 官方重点宣传的输入结算机制叫?", "Sub-tick", ("SourceTV", "Panorama", "Prime"), "CS2 用 Sub-tick 记录动作发生时刻,和传统 tick 观感不同。", 3),
|
||||
("cs-office", "CS", "Office 在 CS 系列里属于哪类经典模式地图?", "人质救援", ("爆破", "军备竞赛", "死亡竞赛"), "Office 是经典 hostage rescue 地图,不是拆包图。", 2),
|
||||
("cs-cache", "CS", "Cache 的主题环境最接近哪个区域?", "切尔诺贝利周边", ("威尼斯水城", "摩洛哥集市", "月球基地"), "Cache 的视觉主题来自切尔诺贝利/普里皮亚季工业区域。", 4),
|
||||
("apex-ash", "Apex", "Ash 成为 Simulacrum 前的人类名字是?", "Ashleigh Reid", ("Amelie Paquette", "Mary Somers", "Anita Williams"), "Ash 的旧身份是 Dr. Ashleigh Reid。", 4),
|
||||
("apex-horizon", "Apex", "Horizon 一直想回去见到的儿子叫什么?", "Newton", ("Elliott", "Jackson", "Crypto"), "Newton 是 Horizon 的儿子。", 3),
|
||||
("apex-crypto", "Apex", "Crypto 的本名是?", "Tae Joon Park", ("Octavio Silva", "Walter Fitzroy", "Makoa Gibraltar"), "Crypto 的本名是 Tae Joon Park。", 3),
|
||||
("apex-caustic", "Apex", "Caustic 的本名是?", "Alexander Nox", ("Elliott Witt", "Obi Edolasim", "Ajay Che"), "Caustic 是 Alexander Nox,背景里是毒气科学家。", 3),
|
||||
("apex-wattson", "Apex", "Wattson 的本名是?", "Natalie Paquette", ("Loba Andrade", "Mary Somers", "Anita Williams"), "Wattson 是 Natalie Paquette,和竞技场电力系统有关。", 3),
|
||||
("apex-fuse", "Apex", "Fuse 的本名是?", "Walter Fitzroy", ("Kaleb Cross", "Elliott Witt", "Tae Joon Park"), "Fuse 的本名是 Walter Fitzroy。", 3),
|
||||
("apex-seer", "Apex", "Seer 的本名是?", "Obi Edolasim", ("Makoa Gibraltar", "Octavio Silva", "Jackson Williams"), "Seer 的本名是 Obi Edolasim。", 4),
|
||||
("overwatch-sigma", "Overwatch", "Sigma 的本名是?", "Siebren de Kuiper", ("Akande Ogundimu", "Jamison Fawkes", "Mako Rutledge"), "Sigma 是 Siebren de Kuiper,事故前是天体物理学家。", 3),
|
||||
("overwatch-doomfist", "Overwatch", "Doomfist 的本名是?", "Akande Ogundimu", ("Tekhartha Zenyatta", "Gabriel Reyes", "Niran Pruksamanee"), "Doomfist 的本名是 Akande Ogundimu。", 3),
|
||||
("overwatch-junkrat", "Overwatch", "Junkrat 的本名是?", "Jamison Fawkes", ("Mako Rutledge", "Cole Cassidy", "Lúcio Correia dos Santos"), "Junkrat 的本名是 Jamison Fawkes。", 2),
|
||||
("overwatch-roadhog", "Overwatch", "Roadhog 的本名是?", "Mako Rutledge", ("Jamison Fawkes", "Baptiste Augustin", "Siebren de Kuiper"), "Roadhog 的本名是 Mako Rutledge。", 2),
|
||||
("overwatch-bastion", "Overwatch", "Bastion 的机体型号是?", "SST Laboratories Siege Automaton E54", ("MEKA Tokki", "R-7000 Ravager", "OR15 Defense Unit"), "Bastion 属于 E54 堡垒机器人。", 5),
|
||||
("overwatch-ramattra", "Overwatch", "Ramattra 领导的组织是?", "Null Sector", ("Talon", "Overwatch", "Vishkar"), "Ramattra 是 Null Sector 的核心领袖。", 3),
|
||||
("lol-ivern", "LoL", "Ivern 转变前曾被称为什么?", "Ivern the Cruel", ("Ivern the Lost", "Ivern the First", "Ivern the Quiet"), "Ivern 原本是残酷战士,后来因神树事件转变。", 4),
|
||||
("lol-aurelion", "LoL", "Aurelion Sol 被巨神族控制主要靠什么?", "一顶冠冕", ("世界符文", "暮光长矛", "暗裔剑"), "巨神族用冠冕束缚了 Aurelion Sol。", 4),
|
||||
("lol-swain", "LoL", "Swain 体内/身边关联的恶魔名字是?", "Raum", ("Tahm", "Evelynn", "Nocturne"), "Swain 与秘密恶魔 Raum 绑定。", 5),
|
||||
("lol-jhin", "LoL", "Jhin 的全名是?", "Khada Jhin", ("Shieda Kayn", "Jericho Swain", "Tobias Felix"), "Jhin 的名字是 Khada Jhin。", 3),
|
||||
("lol-rengar", "LoL", "Rengar 属于哪个瓦斯塔亚部族?", "Kiilash", ("Marai", "Lhotlan", "Oovi-Kat"), "Rengar 来自 Kiilash 部族。", 5),
|
||||
("lol-zac", "LoL", "Zac 名字常被解释为什么缩写?", "Zaun Amorphous Combatant", ("Zaun Arcane Core", "Z Drive Automaton", "Zaunite Acid Creature"), "Zac 是 Zaun Amorphous Combatant 的缩写梗。", 4),
|
||||
("lol-kindred", "LoL", "Kindred 由哪两个化身组成?", "Lamb 和 Wolf", ("Crow 和 Stag", "Fox 和 Owl", "Sun 和 Moon"), "Kindred 是 Lamb 与 Wolf 两个死亡化身。", 2),
|
||||
("lol-ryze", "LoL", "Ryze 长期收集并封存的危险物是什么?", "世界符文", ("暗裔武器", "海克斯核心", "虚空碎片"), "Ryze 的主线目标是阻止世界符文失控。", 3),
|
||||
)
|
||||
TRIVIA_TEMPLATES = (
|
||||
"【{domain} 冷知识】{prompt}",
|
||||
"Taffy 冷门题:{prompt}",
|
||||
"不查资料猜一下:{prompt}",
|
||||
"这个有点偏:{prompt}",
|
||||
"冷知识选项题:{prompt}",
|
||||
"老玩家小测:{prompt}",
|
||||
"边角料问题:{prompt}",
|
||||
"五分钟抢答:{prompt}",
|
||||
)
|
||||
TRIVIA_OPTION_LABELS = ("A", "B", "C", "D")
|
||||
|
||||
|
||||
def trivia_reward(rarity: int) -> int:
|
||||
return 20 + max(1, min(5, int(rarity))) * 30
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def trivia_question_bank() -> tuple[dict[str, object], ...]:
|
||||
questions: list[dict[str, object]] = [
|
||||
{
|
||||
**question,
|
||||
"base_id": str(question["id"]),
|
||||
"rarity": int(question.get("rarity", 4)),
|
||||
"reward": trivia_reward(int(question.get("rarity", 4))),
|
||||
}
|
||||
for question in TRIVIA_QUESTIONS
|
||||
]
|
||||
for base_id, domain, prompt, answer, distractors, explanation, rarity in TRIVIA_FACTS:
|
||||
choices = (answer, *distractors)
|
||||
for variant, template in enumerate(TRIVIA_TEMPLATES):
|
||||
rotated = choices[variant % len(choices) :] + choices[: variant % len(choices)]
|
||||
answer_label = TRIVIA_OPTION_LABELS[rotated.index(answer)]
|
||||
questions.append(
|
||||
{
|
||||
"id": f"{base_id}-{variant + 1}",
|
||||
"base_id": base_id,
|
||||
"domain": domain,
|
||||
"question": template.format(domain=domain, prompt=prompt),
|
||||
"options": rotated,
|
||||
"answer": answer_label,
|
||||
"explanation": explanation,
|
||||
"rarity": rarity,
|
||||
"reward": trivia_reward(rarity),
|
||||
}
|
||||
)
|
||||
return tuple(questions)
|
||||
ALIAS_IDENTITY_QUESTION = re.compile(
|
||||
r"(?:谁(?:啊|呀)?|哪(?:位|个|个人)|什么人|叫(?:什么|啥)|全名)",
|
||||
re.IGNORECASE,
|
||||
@@ -2799,6 +2956,55 @@ class Ledger:
|
||||
).fetchone()
|
||||
return str(row["value"]) if row else ""
|
||||
|
||||
def recent_clock_broadcast(
|
||||
self,
|
||||
group_fingerprint: str,
|
||||
*,
|
||||
kind: str,
|
||||
since: datetime,
|
||||
) -> dict[str, Any] | None:
|
||||
group = bounded_text(group_fingerprint, limit=256)
|
||||
if not group:
|
||||
return None
|
||||
cutoff = since.astimezone(timezone.utc).isoformat(timespec="milliseconds")
|
||||
with self.lock:
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT group_fingerprint, slot_key, local_date, kind, state,
|
||||
updated_at
|
||||
FROM clock_broadcasts
|
||||
WHERE group_fingerprint = ?
|
||||
AND kind = ?
|
||||
AND state IN ('pending', 'delivered')
|
||||
AND updated_at >= ?
|
||||
ORDER BY updated_at DESC, slot_key DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(group, kind, cutoff),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def clock_broadcast_slot(
|
||||
self,
|
||||
group_fingerprint: str,
|
||||
slot_key: str,
|
||||
) -> dict[str, Any] | None:
|
||||
group = bounded_text(group_fingerprint, limit=256)
|
||||
slot = bounded_text(slot_key, limit=256)
|
||||
if not group or not slot:
|
||||
return None
|
||||
with self.lock:
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT group_fingerprint, slot_key, local_date, kind, state,
|
||||
message
|
||||
FROM clock_broadcasts
|
||||
WHERE group_fingerprint = ? AND slot_key = ?
|
||||
""",
|
||||
(group, slot),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def memory_events(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with self.lock:
|
||||
rows = self.connection.execute(
|
||||
@@ -2876,8 +3082,48 @@ class Ledger:
|
||||
slot_key,
|
||||
),
|
||||
)
|
||||
elif cursor.rowcount == 0:
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT state
|
||||
FROM clock_broadcasts
|
||||
WHERE group_fingerprint = ? AND slot_key = ?
|
||||
AND kind = ? AND state = 'pending'
|
||||
""",
|
||||
(group_fingerprint, slot_key, kind),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return True
|
||||
return cursor.rowcount == 1
|
||||
|
||||
def due_pending_clock_broadcast(
|
||||
self,
|
||||
group_fingerprint: str,
|
||||
local_now: datetime,
|
||||
) -> dict[str, Any] | None:
|
||||
group = bounded_text(group_fingerprint, limit=256)
|
||||
if not group:
|
||||
return None
|
||||
slot_keys = [
|
||||
f"hourly:{local_now.date().isoformat()}T{local_now.hour:02d}",
|
||||
f"daily:{local_now.date().isoformat()}",
|
||||
]
|
||||
with self.lock:
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT group_fingerprint, slot_key, local_date, kind, message,
|
||||
scan_person_key, scan_label
|
||||
FROM clock_broadcasts
|
||||
WHERE group_fingerprint = ?
|
||||
AND slot_key IN (?, ?)
|
||||
AND state = 'pending'
|
||||
ORDER BY CASE kind WHEN 'hourly' THEN 0 ELSE 1 END, slot_key
|
||||
LIMIT 1
|
||||
""",
|
||||
(group, slot_keys[0], slot_keys[1]),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def due_scheduled_taffy_speech(
|
||||
self,
|
||||
group_fingerprint: str,
|
||||
@@ -3064,6 +3310,9 @@ class Ledger:
|
||||
)
|
||||
entry["weight"] += int(boost.get("extra_weight", 0))
|
||||
weight_list = list(weights.values())
|
||||
total_weight = sum(int(item["weight"]) for item in weight_list) or 1
|
||||
for item in weight_list:
|
||||
item["share"] = int(item["weight"]) * 100 / total_weight
|
||||
weight_list.sort(
|
||||
key=lambda item: (
|
||||
-int(item["weight"]),
|
||||
@@ -3410,6 +3659,109 @@ class Ledger:
|
||||
(group, event_id, person_key, command, response, timestamp),
|
||||
)
|
||||
|
||||
def answer_trivia(
|
||||
self,
|
||||
group: str,
|
||||
sender: str,
|
||||
event_id: str,
|
||||
answer: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> str:
|
||||
normalized_answer = normalize_text(answer).strip().upper()[:1]
|
||||
if normalized_answer not in TRIVIA_OPTION_LABELS:
|
||||
return "用法:/回答 A、/回答 B、/回答 C 或 /回答 D"
|
||||
timestamp = utc_now()
|
||||
current = now or datetime.now(timezone.utc)
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=timezone.utc)
|
||||
current_ts = current.timestamp()
|
||||
command = f"trivia_answer:{normalized_answer}"
|
||||
person = self._player_identity(group, sender)
|
||||
person_key = str(person["person_key"])
|
||||
display_name = str(person["display_name"])
|
||||
with self.lock, self.connection:
|
||||
replay = self._player_action_replay(group, event_id, person_key, command)
|
||||
if replay is not None:
|
||||
return replay
|
||||
stored = self.connection.execute(
|
||||
"""
|
||||
SELECT state_json FROM local_games
|
||||
WHERE group_fingerprint = ? AND game_key = ?
|
||||
""",
|
||||
(group, TRIVIA_GAME_KEY),
|
||||
).fetchone()
|
||||
if stored is None:
|
||||
response = "当前没有正在进行的冷门问答,等 Taffy 下一题喵"
|
||||
self._store_player_action(
|
||||
group, event_id, person_key, command, response, timestamp,
|
||||
)
|
||||
return response
|
||||
try:
|
||||
state = json.loads(str(stored["state_json"]))
|
||||
except json.JSONDecodeError as error:
|
||||
raise LocalCommandError("冷门问答状态无效") from error
|
||||
if not isinstance(state, dict):
|
||||
raise LocalCommandError("冷门问答状态无效")
|
||||
try:
|
||||
expires_at = float(state.get("expires_at", 0))
|
||||
except (TypeError, ValueError):
|
||||
expires_at = 0.0
|
||||
correct_answer = str(state.get("answer", "")).upper()
|
||||
explanation = bounded_text(state.get("explanation"), limit=180)
|
||||
if current_ts > expires_at:
|
||||
self.connection.execute(
|
||||
"""
|
||||
DELETE FROM local_games
|
||||
WHERE group_fingerprint = ? AND game_key = ?
|
||||
""",
|
||||
(group, TRIVIA_GAME_KEY),
|
||||
)
|
||||
response = (
|
||||
f"这题已经超时了,正确答案是 {correct_answer}。"
|
||||
f"{explanation}"
|
||||
)
|
||||
self._store_player_action(
|
||||
group, event_id, person_key, command, response, timestamp,
|
||||
)
|
||||
return response
|
||||
if normalized_answer != correct_answer:
|
||||
response = (
|
||||
f"{display_name}:不对喵,5分钟内还能继续猜,"
|
||||
"用 /回答 A/B/C/D。"
|
||||
)
|
||||
self._store_player_action(
|
||||
group, event_id, person_key, command, response, timestamp,
|
||||
)
|
||||
return response
|
||||
reward = max(1, min(500, int(state.get("reward", 50))))
|
||||
account = self._load_player_account(group, person_key)
|
||||
account["balance"] += reward
|
||||
if account["balance"] > 1_000_000_000:
|
||||
raise LocalCommandError("摩拉结算超出允许范围")
|
||||
append_coin_event(
|
||||
account,
|
||||
reward,
|
||||
f"冷门问答答对:{state.get('question_id', '')}",
|
||||
)
|
||||
self._save_player_account(group, person_key, account, timestamp)
|
||||
self.connection.execute(
|
||||
"""
|
||||
DELETE FROM local_games
|
||||
WHERE group_fingerprint = ? AND game_key = ?
|
||||
""",
|
||||
(group, TRIVIA_GAME_KEY),
|
||||
)
|
||||
response = (
|
||||
f"{display_name} 答对了:{correct_answer}。"
|
||||
f"奖励摩拉 +{reward},当前摩拉 {account['balance']}。"
|
||||
f"{explanation}"
|
||||
)
|
||||
self._store_player_action(
|
||||
group, event_id, person_key, command, response, timestamp,
|
||||
)
|
||||
return response
|
||||
|
||||
def _open_group_lottery(self, group: str) -> sqlite3.Row | None:
|
||||
return self.connection.execute(
|
||||
"""
|
||||
@@ -5231,17 +5583,138 @@ class Ledger:
|
||||
normalize_player_account(account)
|
||||
for boost in account["scan_boosts"]:
|
||||
target_key = str(boost["target_person_key"])
|
||||
created_at = float(boost.get("created_at", 0) or 0)
|
||||
current = boosts.setdefault(
|
||||
target_key,
|
||||
{
|
||||
"person_key": target_key,
|
||||
"display_name": str(boost["target_display_name"]),
|
||||
"extra_weight": 0,
|
||||
"active_boosts": 0,
|
||||
"first_boosted_at": created_at,
|
||||
},
|
||||
)
|
||||
current["extra_weight"] += int(boost["weight"])
|
||||
current["active_boosts"] += 1
|
||||
current["first_boosted_at"] = min(
|
||||
float(current["first_boosted_at"]),
|
||||
created_at,
|
||||
)
|
||||
return boosts
|
||||
|
||||
@staticmethod
|
||||
def _clock_slot_started_at(slot_key: str) -> float:
|
||||
if not slot_key.startswith("hourly:"):
|
||||
return 0.0
|
||||
try:
|
||||
local_time = datetime.strptime(
|
||||
slot_key.removeprefix("hourly:"),
|
||||
"%Y-%m-%dT%H",
|
||||
).replace(tzinfo=BEIJING_TIMEZONE)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return local_time.timestamp()
|
||||
|
||||
def scan_boost_pity_status(
|
||||
self,
|
||||
group: str,
|
||||
*,
|
||||
local_now: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
group = bounded_text(group, limit=256)
|
||||
boosts = self.active_scan_boost_targets(group)
|
||||
if not group or not boosts:
|
||||
return []
|
||||
if local_now is None:
|
||||
local_now = datetime.now(BEIJING_TIMEZONE)
|
||||
if local_now.tzinfo is None:
|
||||
local_now = local_now.replace(tzinfo=BEIJING_TIMEZONE)
|
||||
local_now = local_now.astimezone(BEIJING_TIMEZONE)
|
||||
current_slot_started_at = (
|
||||
local_now.replace(minute=0, second=0, microsecond=0).timestamp()
|
||||
)
|
||||
earliest_boost = min(
|
||||
float(boost.get("first_boosted_at", 0) or 0)
|
||||
for boost in boosts.values()
|
||||
)
|
||||
with self.lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT slot_key, scan_person_key, scan_label
|
||||
FROM clock_broadcasts
|
||||
WHERE group_fingerprint = ?
|
||||
AND kind = 'hourly'
|
||||
AND state = 'delivered'
|
||||
ORDER BY slot_key
|
||||
""",
|
||||
(group,),
|
||||
).fetchall()
|
||||
delivered: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
slot_started_at = self._clock_slot_started_at(str(row["slot_key"]))
|
||||
if (
|
||||
slot_started_at
|
||||
and slot_started_at >= earliest_boost
|
||||
and slot_started_at < current_slot_started_at
|
||||
):
|
||||
delivered.append(
|
||||
{
|
||||
"slot_started_at": slot_started_at,
|
||||
"scan_person_key": str(row["scan_person_key"]),
|
||||
"scan_label": str(row["scan_label"]),
|
||||
}
|
||||
)
|
||||
statuses: list[dict[str, Any]] = []
|
||||
for target_key, boost in boosts.items():
|
||||
first_boosted_at = float(boost.get("first_boosted_at", 0) or 0)
|
||||
misses = 0
|
||||
last_hit_at = 0.0
|
||||
for row in delivered:
|
||||
if row["slot_started_at"] < first_boosted_at:
|
||||
continue
|
||||
if (
|
||||
row["scan_person_key"] == target_key
|
||||
or row["scan_label"] == str(boost["display_name"])
|
||||
):
|
||||
misses = 0
|
||||
last_hit_at = float(row["slot_started_at"])
|
||||
else:
|
||||
misses += 1
|
||||
statuses.append(
|
||||
{
|
||||
"person_key": target_key,
|
||||
"display_name": str(boost["display_name"]),
|
||||
"extra_weight": int(boost.get("extra_weight", 0)),
|
||||
"active_boosts": int(boost.get("active_boosts", 0)),
|
||||
"first_boosted_at": first_boosted_at,
|
||||
"last_hit_at": last_hit_at,
|
||||
"misses": misses,
|
||||
"threshold": SCAN_BOOST_PITY_MISS_LIMIT,
|
||||
"guaranteed": misses >= SCAN_BOOST_PITY_MISS_LIMIT,
|
||||
}
|
||||
)
|
||||
statuses.sort(
|
||||
key=lambda item: (
|
||||
not bool(item["guaranteed"]),
|
||||
-int(item["misses"]),
|
||||
-int(item["extra_weight"]),
|
||||
float(item["first_boosted_at"]),
|
||||
str(item["display_name"]).casefold(),
|
||||
)
|
||||
)
|
||||
return statuses
|
||||
|
||||
def scan_boost_pity_candidate(
|
||||
self,
|
||||
group: str,
|
||||
*,
|
||||
local_now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
for status in self.scan_boost_pity_status(group, local_now=local_now):
|
||||
if bool(status["guaranteed"]):
|
||||
return status
|
||||
return None
|
||||
|
||||
def consume_scan_boost(
|
||||
self,
|
||||
group: str,
|
||||
@@ -6013,6 +6486,220 @@ class HourlyBroadcastEngine:
|
||||
return now.replace(tzinfo=BEIJING_TIMEZONE)
|
||||
return now.astimezone(BEIJING_TIMEZONE)
|
||||
|
||||
@staticmethod
|
||||
def _observed_at(value: object) -> datetime | None:
|
||||
try:
|
||||
observed = datetime.fromisoformat(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if observed.tzinfo is None:
|
||||
observed = observed.replace(tzinfo=BEIJING_TIMEZONE)
|
||||
return observed.astimezone(BEIJING_TIMEZONE)
|
||||
|
||||
@staticmethod
|
||||
def _ambient_context_text(value: object) -> str:
|
||||
text = normalize_text(value)
|
||||
if not text or len(text) < 2:
|
||||
return ""
|
||||
if text.startswith("/") or f"@{BOT_MENTION}" in text or f"@{BOT_MENTION}" in text:
|
||||
return ""
|
||||
if TOOL_REQUEST.search(text) or SENSITIVE_VALUE.search(text):
|
||||
return ""
|
||||
return bounded_text(text, limit=120)
|
||||
|
||||
def recent_ambient_context(
|
||||
self,
|
||||
group: str,
|
||||
local_now: datetime,
|
||||
) -> list[dict[str, str]]:
|
||||
cutoff = local_now - timedelta(minutes=AMBIENT_CHAT_ACTIVITY_MINUTES)
|
||||
candidates: list[dict[str, str]] = []
|
||||
try:
|
||||
rows = self.ledger.recent_context_messages(group, 100)
|
||||
except LocalCommandError:
|
||||
return []
|
||||
for row in rows:
|
||||
observed = self._observed_at(row.get("observed_at"))
|
||||
if observed is None or observed < cutoff or observed > local_now:
|
||||
continue
|
||||
text = self._ambient_context_text(row.get("text"))
|
||||
if not text:
|
||||
continue
|
||||
display = bounded_text(row.get("display_name"), limit=40) or "群友"
|
||||
candidates.append({"display_name": display, "text": text})
|
||||
return candidates[-12:]
|
||||
|
||||
@staticmethod
|
||||
def render_ambient_message(
|
||||
context: list[dict[str, str]],
|
||||
*,
|
||||
seed: str,
|
||||
) -> str:
|
||||
if not context:
|
||||
return ""
|
||||
joined = " ".join(item["text"] for item in context)
|
||||
latest = context[-1]["text"]
|
||||
snippet = re.sub(r"[\\r\\n]+", " ", latest).strip(" ,。!?!?")
|
||||
snippet = bounded_text(snippet, limit=24)
|
||||
if re.search(r"加班|上班|工作|开会|写代码|修 bug|修bug", joined, re.I):
|
||||
return "我路过看一眼,你们这个加班味有点太浓了喵"
|
||||
if re.search(r"困|睡|晚安|熬夜|起床", joined, re.I):
|
||||
return "我冒个泡,困了就别硬撑,嘴硬也要休息喵"
|
||||
if re.search(r"游戏|CS|开黑|Steam|自走棋|21点|彩票|原石|摩拉", joined, re.I):
|
||||
return "刚刚这个游戏味我闻到了,先假装没看到你们又上头了喵"
|
||||
templates = [
|
||||
f"路过一下,你们刚刚聊到“{snippet}”,感觉这话题要开始发散了喵",
|
||||
f"我冒个泡,刚刚这句“{snippet}”有点值得被记一笔喵",
|
||||
"我路过看一眼,你们继续聊,我先假装自己很忙喵",
|
||||
]
|
||||
return templates[deterministic_int(seed, 0, len(templates) - 1)]
|
||||
|
||||
def due_ambient_broadcast(
|
||||
self,
|
||||
*,
|
||||
group: str,
|
||||
local_now: datetime,
|
||||
) -> ClockBroadcast | None:
|
||||
if not (AMBIENT_CHAT_START_HOUR <= local_now.hour <= AMBIENT_CHAT_END_HOUR):
|
||||
return None
|
||||
cooldown_since = local_now - timedelta(hours=AMBIENT_CHAT_COOLDOWN_HOURS)
|
||||
if self.ledger.recent_clock_broadcast(
|
||||
group,
|
||||
kind="ambient_chat",
|
||||
since=cooldown_since,
|
||||
):
|
||||
return None
|
||||
local_date = local_now.date().isoformat()
|
||||
seed = f"{group}|{local_date}|{local_now.hour:02d}|ambient-chat"
|
||||
if deterministic_int(seed, 0, 99) >= AMBIENT_CHAT_PROBABILITY_PERCENT:
|
||||
return None
|
||||
context = self.recent_ambient_context(group, local_now)
|
||||
speakers = {item["display_name"] for item in context}
|
||||
if (
|
||||
len(context) < AMBIENT_CHAT_MIN_CONTEXT_MESSAGES
|
||||
or len(speakers) < AMBIENT_CHAT_MIN_CONTEXT_SPEAKERS
|
||||
):
|
||||
return None
|
||||
message = self.render_ambient_message(context, seed=seed)
|
||||
if not message:
|
||||
return None
|
||||
return ClockBroadcast(
|
||||
group_fingerprint=group,
|
||||
slot_key=f"hourly:{local_date}T{local_now.hour:02d}",
|
||||
local_date=local_date,
|
||||
kind="ambient_chat",
|
||||
message=message,
|
||||
)
|
||||
|
||||
def trivia_recent_base_ids(self, group: str) -> list[str]:
|
||||
state = self.ledger.local_game(group, TRIVIA_HISTORY_KEY)
|
||||
if not state:
|
||||
return []
|
||||
raw = state.get("recent_base_ids", [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [bounded_text(item, limit=80) for item in raw if item][-TRIVIA_RECENT_BASE_LIMIT:]
|
||||
|
||||
def remember_trivia_question(self, group: str, question: dict[str, object]) -> None:
|
||||
base_id = bounded_text(question.get("base_id") or question.get("id"), limit=80)
|
||||
if not base_id:
|
||||
return
|
||||
recent = [item for item in self.trivia_recent_base_ids(group) if item != base_id]
|
||||
recent.append(base_id)
|
||||
self.ledger.save_local_game(
|
||||
group,
|
||||
TRIVIA_HISTORY_KEY,
|
||||
{"recent_base_ids": recent[-TRIVIA_RECENT_BASE_LIMIT:]},
|
||||
)
|
||||
|
||||
def select_trivia_question(
|
||||
self,
|
||||
*,
|
||||
group: str,
|
||||
local_now: datetime,
|
||||
) -> dict[str, object]:
|
||||
bank = trivia_question_bank()
|
||||
recent = set(self.trivia_recent_base_ids(group))
|
||||
seed = f"{group}|{local_now.date().isoformat()}|{local_now.hour:02d}|trivia"
|
||||
start = deterministic_int(seed, 0, len(bank) - 1)
|
||||
for offset in range(len(bank)):
|
||||
candidate = bank[(start + offset) % len(bank)]
|
||||
base_id = str(candidate.get("base_id") or candidate.get("id"))
|
||||
if base_id not in recent:
|
||||
return candidate
|
||||
return bank[start]
|
||||
|
||||
@staticmethod
|
||||
def render_trivia_message(question: dict[str, object]) -> str:
|
||||
options = tuple(str(item) for item in question.get("options", ()))
|
||||
if len(options) != 4:
|
||||
raise LocalCommandError("冷门问答题库选项无效")
|
||||
rarity = max(1, min(5, int(question.get("rarity", 3))))
|
||||
reward = max(1, min(500, int(question.get("reward", trivia_reward(rarity)))))
|
||||
lines = [
|
||||
f"冷门知识问答(冷门度 {rarity}/5,限时5分钟,答对 +{reward} 摩拉)",
|
||||
str(question.get("question", "")),
|
||||
]
|
||||
lines.extend(
|
||||
f"{label}. {option}"
|
||||
for label, option in zip(TRIVIA_OPTION_LABELS, options)
|
||||
)
|
||||
lines.append("回答格式:/回答 A")
|
||||
return "\n".join(lines)
|
||||
|
||||
def due_trivia_broadcast(
|
||||
self,
|
||||
*,
|
||||
group: str,
|
||||
local_now: datetime,
|
||||
) -> ClockBroadcast | None:
|
||||
if not (AMBIENT_CHAT_START_HOUR <= local_now.hour <= AMBIENT_CHAT_END_HOUR):
|
||||
return None
|
||||
local_date = local_now.date().isoformat()
|
||||
slot_key = f"hourly:{local_date}T{local_now.hour:02d}"
|
||||
if self.ledger.clock_broadcast_slot(group, slot_key):
|
||||
return None
|
||||
active = self.ledger.local_game(group, TRIVIA_GAME_KEY)
|
||||
if active:
|
||||
try:
|
||||
if float(active.get("expires_at", 0)) > local_now.timestamp():
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
self.ledger.clear_local_game(group, TRIVIA_GAME_KEY)
|
||||
seed = f"{group}|{local_date}|{local_now.hour:02d}|trivia-probability"
|
||||
if deterministic_int(seed, 0, 99) >= TRIVIA_QUIZ_PROBABILITY_PERCENT:
|
||||
return None
|
||||
question = self.select_trivia_question(group=group, local_now=local_now)
|
||||
message = self.render_trivia_message(question)
|
||||
expires_at = (
|
||||
local_now + timedelta(minutes=TRIVIA_ANSWER_WINDOW_MINUTES)
|
||||
).timestamp()
|
||||
self.ledger.save_local_game(
|
||||
group,
|
||||
TRIVIA_GAME_KEY,
|
||||
{
|
||||
"question_id": str(question["id"]),
|
||||
"base_id": str(question.get("base_id") or question["id"]),
|
||||
"question": str(question["question"]),
|
||||
"options": list(question["options"]),
|
||||
"answer": str(question["answer"]),
|
||||
"explanation": str(question["explanation"]),
|
||||
"rarity": int(question.get("rarity", 3)),
|
||||
"reward": int(question.get("reward", 50)),
|
||||
"created_at": local_now.timestamp(),
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
self.remember_trivia_question(group, question)
|
||||
return ClockBroadcast(
|
||||
group_fingerprint=group,
|
||||
slot_key=slot_key,
|
||||
local_date=local_date,
|
||||
kind="trivia_quiz",
|
||||
message=message,
|
||||
)
|
||||
|
||||
def due_broadcast(
|
||||
self,
|
||||
*,
|
||||
@@ -6036,6 +6723,17 @@ class HourlyBroadcastEngine:
|
||||
)
|
||||
if not self.ledger.is_clock_broadcast_group(group):
|
||||
return None
|
||||
pending = self.ledger.due_pending_clock_broadcast(group, local_now)
|
||||
if pending is not None:
|
||||
return ClockBroadcast(
|
||||
group_fingerprint=str(pending["group_fingerprint"]),
|
||||
slot_key=str(pending["slot_key"]),
|
||||
local_date=str(pending["local_date"]),
|
||||
kind=str(pending["kind"]),
|
||||
message=str(pending["message"]),
|
||||
scan_person_key=str(pending["scan_person_key"]),
|
||||
scan_label=str(pending["scan_label"]),
|
||||
)
|
||||
if local_now.hour == 0:
|
||||
previous_day = (local_now - timedelta(days=1)).date()
|
||||
local_date = previous_day.isoformat()
|
||||
@@ -6060,6 +6758,14 @@ class HourlyBroadcastEngine:
|
||||
message=message,
|
||||
)
|
||||
|
||||
trivia = self.due_trivia_broadcast(group=group, local_now=local_now)
|
||||
if trivia is not None:
|
||||
return trivia
|
||||
|
||||
ambient = self.due_ambient_broadcast(group=group, local_now=local_now)
|
||||
if ambient is not None:
|
||||
return ambient
|
||||
|
||||
local_date = local_now.date().isoformat()
|
||||
weighted_targets: list[dict[str, str]] = [
|
||||
{
|
||||
@@ -6081,12 +6787,22 @@ class HourlyBroadcastEngine:
|
||||
)
|
||||
if not weighted_targets:
|
||||
return None
|
||||
target_index = deterministic_int(
|
||||
f"{group}|{local_date}|{local_now.hour:02d}|clock-scan",
|
||||
0,
|
||||
len(weighted_targets) - 1,
|
||||
pity_target = self.ledger.scan_boost_pity_candidate(
|
||||
group,
|
||||
local_now=local_now,
|
||||
)
|
||||
target = weighted_targets[target_index]
|
||||
if pity_target is None:
|
||||
target_index = deterministic_int(
|
||||
f"{group}|{local_date}|{local_now.hour:02d}|clock-scan",
|
||||
0,
|
||||
len(weighted_targets) - 1,
|
||||
)
|
||||
target = weighted_targets[target_index]
|
||||
else:
|
||||
target = {
|
||||
"person_key": str(pity_target["person_key"]),
|
||||
"label": str(pity_target["display_name"]),
|
||||
}
|
||||
scan_label = target["label"]
|
||||
return ClockBroadcast(
|
||||
group_fingerprint=group,
|
||||
|
||||
Reference in New Issue
Block a user