Release beta 1.1.2.1019
check / local-gate (push) Failing after 53s

This commit is contained in:
JWeBot Contributors
2026-10-19 16:56:59 +08:00
parent 522e349491
commit ddebe075d7
30 changed files with 1433 additions and 46 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-config"
version = "1.1.1b1+1016"
version = "1.1.2b1+1019"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "1.1.1b1+1016"
__version__ = "1.1.2b1+1019"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "1.1.1b1+1016"
version = "1.1.2b1+1019"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.1.1b1+1016",
"jwebot-kernel==1.1.1b1+1016",
"jwebot-sdk==1.1.2b1+1019",
"jwebot-kernel==1.1.2b1+1019",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "1.1.1b1+1016"
__version__ = "1.1.2b1+1019"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "1.1.1b1+1016"
version = "1.1.2b1+1019"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==1.1.1b1+1016",
"jwebot-sdk==1.1.1b1+1016",
"jwebot-official-extensions==1.1.1b1+1016",
"jwebot-config==1.1.2b1+1019",
"jwebot-sdk==1.1.2b1+1019",
"jwebot-official-extensions==1.1.2b1+1019",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "1.1.1b1+1016"
__version__ = "1.1.2b1+1019"
@@ -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,
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "1.1.1b1+1016"
version = "1.1.2b1+1019"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.1.1b1+1016",
"jwebot-sdk==1.1.2b1+1019",
]
[tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list",
]
__version__ = "1.1.1b1+1016"
__version__ = "1.1.2b1+1019"
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for trivia-quiz."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'trivia-quiz'
AUTHOR = 'jwebot'
DESCRIPTION = '冷门知识问答'
HELP_ENTRIES = ('/回答 A;/answer A',)
COMMANDS = ('/回答', '/answer')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -63,6 +63,8 @@ LOCAL_COMMAND_ALIASES = {
"扫码概率": "scan_boost",
"Taffy发话": "taffy_say",
"taffysay": "taffy_say",
"回答": "trivia_answer",
"answer": "trivia_answer",
"转账": "transfer",
"transfer": "transfer",
"贷款": "loan",
@@ -228,6 +230,13 @@ LOCAL_EXTENSIONS = (
help_entries=("/贷款;/还款",),
command_kinds=("loan", "repay"),
),
LocalExtensionDescriptor(
extension_id="trivia-quiz",
author=JWEBOT_AUTHOR,
description="冷门知识问答",
help_entries=("/回答 A;/answer A",),
command_kinds=("trivia_answer",),
),
LocalExtensionDescriptor(
extension_id="cs-major-autochess",
author=JWEBOT_AUTHOR,
@@ -222,6 +222,8 @@ class LocalCommandEngine:
return self.reply_cleaner(self.deepseek_balance())
if kind == "steam_friends":
return self.reply_cleaner(self.steam_friends(arguments))
if kind == "trivia_answer":
return self.reply_cleaner(self.trivia_answer(row, arguments))
raise LocalCommandError("unsupported local command")
@staticmethod
@@ -261,10 +263,13 @@ class LocalCommandEngine:
for index, entry in enumerate(ranking, start=1)
)
weight_listing = ";".join(
f"{entry['display_name']} {entry['weight']}"
f"{entry['display_name']} {entry['weight']}({entry['share']:.1f}%)"
for entry in weights
)
return f"北京时间今日扫码排行:{listing}\n下一小时扫码权重:{weight_listing}"
return (
f"北京时间今日扫码排行:{listing}\n"
f"下一小时扫码权重:{weight_listing}"
)
def repeat_ranking(self, row: dict[str, Any]) -> str:
self.ledger.bootstrap_repeat_history()
@@ -620,6 +625,17 @@ class LocalCommandEngine:
return "Steam 查询当前不可用"
return self.steam.render(arguments)
def trivia_answer(self, row: dict[str, Any], arguments: str) -> str:
answer = normalize_text(arguments)
if not answer:
return "用法:/回答 A、/回答 B、/回答 C 或 /回答 D"
return self.ledger.answer_trivia(
str(row["group_fingerprint"]),
str(row["sender"]),
str(row["event_id"]),
answer,
)
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
person = self.ledger.resolve_person(
str(row["group_fingerprint"]),
@@ -536,7 +536,7 @@ class PlayerGames:
"同一时间最多一笔未还贷款。\n"
"原石:达成特定成就获得;/原石祈愿 消耗1原石抽20~10000摩拉"
"(理论期望3600摩拉)并可能获得扫码概率券;"
"/扫码加权 人名 提升TA的整点扫码权重;"
"/扫码加权 人名 在24小时内给TA整点扫码+5权重;"
"/Taffy发话 内容 或 /Taffy发话 定时 HH:MM 内容。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "1.1.1b1+1016"
version = "1.1.2b1+1019"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "1.1.1b1+1016"
__version__ = "1.1.2b1+1019"