@@ -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- %d T % 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 ,