feat: user-customizable event voice rules (gift/blind box/enter/follow/like/guard/super chat)
- New event_rules.py: load/save event_rules.json, robust template renderer (missing vars -> empty, empty template -> muted), guard level mapping - danmaku_handler.py: custom SEND_GIFT callback to extract blind_gift_name; implements all event callbacks feeding the TTS queue per user rules - server.py: GET/POST /api/rules, load rules at startup, pass to client - Web UI: 'Event Voice Rules' panel (per-event toggle + template + save) - main.py CLI loads rules; build.py bundles event_rules.json - skip_free_gift option to mute silver free gifts - Rebuild portable zip (83.8 MB)
This commit is contained in:
@@ -85,6 +85,41 @@ python main.py --room-id ROOM_ID [--voice VOICE] [--speed SPEED]
|
||||
| `imd1` | imd1 |
|
||||
| `jgr` | jgr |
|
||||
|
||||
## 事件语音规则
|
||||
|
||||
除弹幕外,礼物、盲盒、进房、上舰、醒目留言等事件也可以自定义语音。规则保存在 `event_rules.json`(首次运行自动生成),可在 Web UI 的 "Event Voice Rules" 面板编辑。
|
||||
|
||||
模板为空字符串 = 该事件静音。`enabled` 控制整类事件开关,`skip_free_gift` 跳过银瓜子免费礼物。
|
||||
|
||||
### 事件与变量
|
||||
|
||||
| 事件 | 变量 |
|
||||
|------|------|
|
||||
| `danmaku` 弹幕 | `{uname}` `{msg}` |
|
||||
| `gift` 普通礼物 | `{viewer_name}` `{gift_name}` `{gift_num}` `{gift_price}` `{gift_coin_type}` |
|
||||
| `blind_box` 盲盒礼物 | `{viewer_name}` `{gift_name}` `{gift_num}` `{blind_box_gift}` |
|
||||
| `enter_room` 进房 | `{viewer_name}` `{user_uid}` |
|
||||
| `follow` 关注 | `{viewer_name}` `{user_uid}` |
|
||||
| `like` 点赞 | `{viewer_name}` `{user_uid}` |
|
||||
| `interact_other` 其他互动 | `{viewer_name}` `{user_uid}` `{interact_type}`(分享/特别关注/互粉) |
|
||||
| `buy_guard` 上舰 | `{ships_viewer_name}` `{ships_level}`(舰长/提督/总督) `{ships_num}` `{ships_price}` |
|
||||
| `guard_toast` 上舰提示 | `{ships_viewer_name}` `{ships_level}` `{guard_days}` `{toast_msg}` |
|
||||
| `super_chat` 醒目留言 | `{viewer_name}` `{super_chat_content}` `{super_chat_price}` `{super_chat_duration}` |
|
||||
|
||||
示例模板:
|
||||
|
||||
```json
|
||||
{
|
||||
"templates": {
|
||||
"gift": "感谢由{viewer_name}投喂的{gift_name}",
|
||||
"blind_box": "感谢由{viewer_name}投喂的{gift_name}爆出了{blind_box_gift}",
|
||||
"enter_room": "欢迎{viewer_name}进入直播间",
|
||||
"buy_guard": "恭喜{ships_viewer_name}成为尊贵的{ships_level}",
|
||||
"super_chat": "{viewer_name}的醒目留言:{super_chat_content}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
@@ -94,7 +129,7 @@ Bilibili 弹幕 (blivedm)
|
||||
中文→片假名 (pypinyin + 映射表)
|
||||
│
|
||||
▼
|
||||
英文→片假名 (english-to-kana 49K 词库 + 字母拼读兜底)
|
||||
英文→片假名 (english-to-kana 49K 词库 + phonemize G2P 兜底)
|
||||
│
|
||||
▼
|
||||
AquesTalk TTS (aquestalk.js + v86 WASM 模拟)
|
||||
|
||||
@@ -145,6 +145,8 @@ def main() -> int:
|
||||
shutil.copy2(ROOT / "tts_bridge.js", BUILD_DIR / "tts_bridge.js")
|
||||
shutil.copy2(ROOT / "english-kana-matcher.js", BUILD_DIR / "english-kana-matcher.js")
|
||||
shutil.copy2(ROOT / "package.json", BUILD_DIR / "package.json")
|
||||
if (ROOT / "event_rules.json").exists():
|
||||
shutil.copy2(ROOT / "event_rules.json", BUILD_DIR / "event_rules.json")
|
||||
print(" OK")
|
||||
|
||||
# ── 6. Launcher ────────────────────────────────────────────────────
|
||||
|
||||
+122
-14
@@ -1,7 +1,8 @@
|
||||
"""Bilibili Live Danmaku Handler.
|
||||
|
||||
Connects to a Bilibili live room and puts incoming danmaku messages into an asyncio queue.
|
||||
Supports cookie-based login with browser header emulation.
|
||||
Connects to a Bilibili live room, receives danmaku/gift/guard/super-chat/
|
||||
interact events, and puts user-customizable speech text into an asyncio queue.
|
||||
Rules come from event_rules.json (see event_rules.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -9,24 +10,55 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
import blivedm
|
||||
from blivedm.models.web import DanmakuMessage
|
||||
from blivedm.models.web import DanmakuMessage, GiftMessage, GuardBuyMessage, \
|
||||
SuperChatMessage, InteractWordV2Message, UserToastV2Message
|
||||
|
||||
from bili_login import build_danmaku_session
|
||||
from event_rules import (
|
||||
DEFAULT_RULES, GUARD_LEVEL_NAMES, event_text, is_enabled, load_rules,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DANMAKU_FORMAT = "{uname}\u8bf4\u3001 {msg}"
|
||||
|
||||
INTERACT_TYPE_NAMES = {
|
||||
3: "\u5206\u4eab", 4: "\u7279\u522b\u5173\u6ce8", 5: "\u4e92\u7c89",
|
||||
}
|
||||
|
||||
|
||||
class DanmakuHandler(blivedm.BaseHandler):
|
||||
"""Handles incoming danmaku from a Bilibili live room."""
|
||||
"""Handles incoming danmaku and live events from a Bilibili live room."""
|
||||
|
||||
def __init__(self, queue: asyncio.Queue, message_format: str = DANMAKU_FORMAT):
|
||||
_CMD_CALLBACK_DICT = blivedm.BaseHandler._CMD_CALLBACK_DICT.copy()
|
||||
|
||||
def __init__(self, queue: asyncio.Queue, rules: dict | None = None,
|
||||
message_format: str = DANMAKU_FORMAT):
|
||||
super().__init__()
|
||||
self._queue = queue
|
||||
self._rules = rules or dict(DEFAULT_RULES)
|
||||
self._message_format = message_format
|
||||
self._count = 0
|
||||
|
||||
# ── custom SEND_GIFT callback to capture blind-gift info ──────────
|
||||
def __gift_callback(self, client: blivedm.BLiveClient, command: dict):
|
||||
message = GiftMessage.from_command(command["data"])
|
||||
blind = command.get("data", {}).get("blind_gift") or {}
|
||||
blind_name = blind.get("blind_gift_name", "") if isinstance(blind, dict) else ""
|
||||
self._on_gift_raw(client, message, blind_name)
|
||||
|
||||
_CMD_CALLBACK_DICT["SEND_GIFT"] = __gift_callback
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────
|
||||
def _put(self, text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
self._queue.put_nowait(text)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Danmaku queue full, dropping: %s", text)
|
||||
|
||||
# ── danmaku ────────────────────────────────────────────────────────
|
||||
def _on_danmaku(self, client: blivedm.BLiveClient, message: DanmakuMessage) -> None:
|
||||
if not message.msg.strip():
|
||||
return
|
||||
@@ -40,17 +72,91 @@ class DanmakuHandler(blivedm.BaseHandler):
|
||||
logger.info("Danmaku #%d: %s", self._count, text)
|
||||
else:
|
||||
logger.debug("Danmaku #%d: %s", self._count, text)
|
||||
try:
|
||||
self._queue.put_nowait(text)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("Danmaku queue full, dropping: %s", text)
|
||||
self._put(text)
|
||||
|
||||
def _on_gift(self, client: blivedm.BLiveClient, message) -> None:
|
||||
logger.debug("Gift: %s x%d from %s", message.gift_name, message.num, message.uname)
|
||||
# ── gift ───────────────────────────────────────────────────────────
|
||||
def _on_gift_raw(self, client: blivedm.BLiveClient, message: GiftMessage,
|
||||
blind_box_gift: str) -> None:
|
||||
if self._rules.get("skip_free_gift") and message.coin_type == "silver":
|
||||
logger.debug("Skip free gift: %s x%d from %s",
|
||||
message.gift_name, message.num, message.uname)
|
||||
return
|
||||
|
||||
def _on_interact_word_v2(self, client: blivedm.BLiveClient, message) -> None:
|
||||
logger.debug("Interact: %s (type=%d)", message.uname, message.msg_type)
|
||||
if blind_box_gift:
|
||||
text = event_text(self._rules, "blind_box",
|
||||
viewer_name=message.uname,
|
||||
gift_name=message.gift_name,
|
||||
gift_num=message.num,
|
||||
blind_box_gift=blind_box_gift)
|
||||
else:
|
||||
text = event_text(self._rules, "gift",
|
||||
viewer_name=message.uname,
|
||||
gift_name=message.gift_name,
|
||||
gift_num=message.num,
|
||||
gift_price=message.price,
|
||||
gift_coin_type=message.coin_type)
|
||||
logger.info("Gift: %s x%d from %s (blind=%s)",
|
||||
message.gift_name, message.num, message.uname, blind_box_gift or "-")
|
||||
self._put(text)
|
||||
|
||||
def _on_gift(self, client: blivedm.BLiveClient, message: GiftMessage) -> None:
|
||||
# fallback in case _CMD_CALLBACK_DICT override is bypassed
|
||||
self._on_gift_raw(client, message, "")
|
||||
|
||||
# ── buy guard ──────────────────────────────────────────────────────
|
||||
def _on_buy_guard(self, client: blivedm.BLiveClient, message: GuardBuyMessage) -> None:
|
||||
text = event_text(self._rules, "buy_guard",
|
||||
ships_viewer_name=message.username,
|
||||
ships_level=GUARD_LEVEL_NAMES.get(message.guard_level, str(message.guard_level)),
|
||||
ships_num=message.num,
|
||||
ships_price=message.price)
|
||||
logger.info("Buy guard: %s level=%d", message.username, message.guard_level)
|
||||
self._put(text)
|
||||
|
||||
# ── guard toast ────────────────────────────────────────────────────
|
||||
def _on_user_toast_v2(self, client: blivedm.BLiveClient, message: UserToastV2Message) -> None:
|
||||
text = event_text(self._rules, "guard_toast",
|
||||
ships_viewer_name=message.username,
|
||||
ships_level=GUARD_LEVEL_NAMES.get(message.guard_level, str(message.guard_level)),
|
||||
guard_days=message.num,
|
||||
toast_msg=message.toast_msg)
|
||||
logger.info("Guard toast: %s level=%d", message.username, message.guard_level)
|
||||
self._put(text)
|
||||
|
||||
# ── super chat ─────────────────────────────────────────────────────
|
||||
def _on_super_chat(self, client: blivedm.BLiveClient, message: SuperChatMessage) -> None:
|
||||
text = event_text(self._rules, "super_chat",
|
||||
viewer_name=message.uname,
|
||||
super_chat_content=message.message,
|
||||
super_chat_price=message.price,
|
||||
super_chat_duration=message.time)
|
||||
logger.info("Super chat: %s (%d yuan): %s", message.uname, message.price, message.message)
|
||||
self._put(text)
|
||||
|
||||
# ── interact (enter room / follow / like / share) ──────────────────
|
||||
def _on_interact_word_v2(self, client: blivedm.BLiveClient, message: InteractWordV2Message) -> None:
|
||||
msg_type = message.msg_type
|
||||
if msg_type == 1:
|
||||
text = event_text(self._rules, "enter_room",
|
||||
viewer_name=message.username,
|
||||
user_uid=message.uid)
|
||||
elif msg_type == 2:
|
||||
text = event_text(self._rules, "follow",
|
||||
viewer_name=message.username,
|
||||
user_uid=message.uid)
|
||||
elif msg_type == 6:
|
||||
text = event_text(self._rules, "like",
|
||||
viewer_name=message.username,
|
||||
user_uid=message.uid)
|
||||
else:
|
||||
text = event_text(self._rules, "interact_other",
|
||||
viewer_name=message.username,
|
||||
user_uid=message.uid,
|
||||
interact_type=INTERACT_TYPE_NAMES.get(msg_type, str(msg_type)))
|
||||
logger.debug("Interact: %s (type=%d)", message.username, msg_type)
|
||||
self._put(text)
|
||||
|
||||
# ── client stopped ─────────────────────────────────────────────────
|
||||
def on_client_stopped(self, client, exception: Optional[Exception]) -> None:
|
||||
if exception is not None:
|
||||
logger.error("Client stopped with exception: %s", exception)
|
||||
@@ -66,11 +172,13 @@ class DanmakuClient:
|
||||
room_id: int,
|
||||
queue: asyncio.Queue,
|
||||
cookies: Optional[dict[str, str]] = None,
|
||||
rules: dict | None = None,
|
||||
message_format: str = DANMAKU_FORMAT,
|
||||
):
|
||||
self._room_id = room_id
|
||||
self._queue = queue
|
||||
self._cookies = cookies
|
||||
self._rules = rules or dict(DEFAULT_RULES)
|
||||
self._message_format = message_format
|
||||
self._client: Optional[blivedm.BLiveClient] = None
|
||||
self._session: Optional["aiohttp.ClientSession"] = None
|
||||
@@ -79,7 +187,7 @@ class DanmakuClient:
|
||||
import aiohttp
|
||||
self._session = await build_danmaku_session(self._cookies)
|
||||
self._client = blivedm.BLiveClient(self._room_id, session=self._session)
|
||||
handler = DanmakuHandler(self._queue, self._message_format)
|
||||
handler = DanmakuHandler(self._queue, self._rules, self._message_format)
|
||||
self._client.set_handler(handler)
|
||||
self._client.start()
|
||||
logger.info("Connected to room %d", self._room_id)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"enabled": {
|
||||
"danmaku": true,
|
||||
"gift": true,
|
||||
"blind_box": true,
|
||||
"enter_room": true,
|
||||
"follow": false,
|
||||
"like": false,
|
||||
"interact_other": false,
|
||||
"buy_guard": true,
|
||||
"guard_toast": false,
|
||||
"super_chat": true
|
||||
},
|
||||
"templates": {
|
||||
"danmaku": "{uname}说、 {msg}",
|
||||
"gift": "感谢由{viewer_name}投喂的{gift_name}",
|
||||
"blind_box": "感谢由{viewer_name}投喂的{gift_name}爆出了{blind_box_gift}",
|
||||
"enter_room": "欢迎{viewer_name}进入直播间",
|
||||
"follow": "",
|
||||
"like": "",
|
||||
"interact_other": "",
|
||||
"buy_guard": "恭喜{ships_viewer_name}成为尊贵的{ships_level}",
|
||||
"guard_toast": "",
|
||||
"super_chat": "{viewer_name}的醒目留言:{super_chat_content}"
|
||||
},
|
||||
"skip_free_gift": true
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
"""Event voice rules: load/save event_rules.json and render templates.
|
||||
|
||||
Each live event (danmaku, gift, blind box, enter room, follow, like,
|
||||
interact, buy guard, guard toast, super chat) can be voiced using a
|
||||
user-customizable template. Empty template = event is not spoken.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_RULES = {
|
||||
"enabled": {
|
||||
"danmaku": True,
|
||||
"gift": True,
|
||||
"blind_box": True,
|
||||
"enter_room": True,
|
||||
"follow": False,
|
||||
"like": False,
|
||||
"interact_other": False,
|
||||
"buy_guard": True,
|
||||
"guard_toast": False,
|
||||
"super_chat": True,
|
||||
},
|
||||
"templates": {
|
||||
"danmaku": "{uname}\u8bf4\u3001 {msg}",
|
||||
"gift": "\u611f\u8c22\u7531{viewer_name}\u6295\u5582\u7684{gift_name}",
|
||||
"blind_box": "\u611f\u8c22\u7531{viewer_name}\u6295\u5582\u7684{gift_name}\u7206\u51fa\u4e86{blind_box_gift}",
|
||||
"enter_room": "\u6b22\u8fce{viewer_name}\u8fdb\u5165\u76f4\u64ad\u95f4",
|
||||
"follow": "",
|
||||
"like": "",
|
||||
"interact_other": "",
|
||||
"buy_guard": "\u606d\u559c{ships_viewer_name}\u6210\u4e3a\u5c0a\u8d35\u7684{ships_level}",
|
||||
"guard_toast": "",
|
||||
"super_chat": "{viewer_name}\u7684\u9192\u76ee\u7559\u8a00\uff1a{super_chat_content}",
|
||||
},
|
||||
"skip_free_gift": True,
|
||||
}
|
||||
|
||||
ALL_EVENTS = [
|
||||
"danmaku", "gift", "blind_box", "enter_room", "follow", "like",
|
||||
"interact_other", "buy_guard", "guard_toast", "super_chat",
|
||||
]
|
||||
|
||||
_TEMPLATE_RE = re.compile(r"\{(\w+)\}")
|
||||
|
||||
|
||||
def _base_dir() -> pathlib.Path:
|
||||
if getattr(sys, "frozen", False):
|
||||
return pathlib.Path(sys.executable).parent.parent.parent
|
||||
return pathlib.Path(__file__).parent
|
||||
|
||||
|
||||
def rules_path() -> pathlib.Path:
|
||||
return _base_dir() / "event_rules.json"
|
||||
|
||||
|
||||
def load_rules(path: str | None = None) -> dict:
|
||||
"""Load rules from file; create default file if missing."""
|
||||
p = pathlib.Path(path) if path else rules_path()
|
||||
if not p.exists():
|
||||
try:
|
||||
p.write_text(json.dumps(DEFAULT_RULES, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
logger.info("Created default event_rules.json at %s", p)
|
||||
except OSError:
|
||||
logger.warning("Cannot write default rules to %s", p)
|
||||
return json.loads(json.dumps(DEFAULT_RULES))
|
||||
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error("Failed to load rules from %s: %s", p, e)
|
||||
return json.loads(json.dumps(DEFAULT_RULES))
|
||||
|
||||
# merge with defaults to fill missing keys
|
||||
merged = json.loads(json.dumps(DEFAULT_RULES))
|
||||
for section in ("enabled", "templates"):
|
||||
if isinstance(data.get(section), dict):
|
||||
merged[section].update({k: v for k, v in data[section].items() if k in merged[section]})
|
||||
if isinstance(data.get("skip_free_gift"), bool):
|
||||
merged["skip_free_gift"] = data["skip_free_gift"]
|
||||
return merged
|
||||
|
||||
|
||||
def save_rules(rules: dict, path: str | None = None) -> None:
|
||||
p = pathlib.Path(path) if path else rules_path()
|
||||
p.write_text(json.dumps(rules, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
logger.info("Saved event rules to %s", p)
|
||||
|
||||
|
||||
def render_template(template: str, **vars_dict) -> str:
|
||||
"""Render a template with given variables. Missing variables become empty.
|
||||
Unknown variables are kept as-is (so users can see typos in the UI log)."""
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
def _replace(m: re.Match) -> str:
|
||||
name = m.group(1)
|
||||
return str(vars_dict.get(name, "")) if name in vars_dict else m.group(0)
|
||||
|
||||
return _TEMPLATE_RE.sub(_replace, template)
|
||||
|
||||
|
||||
def is_enabled(rules: dict, event: str) -> bool:
|
||||
return bool(rules.get("enabled", {}).get(event, False))
|
||||
|
||||
|
||||
def get_template(rules: dict, event: str) -> str:
|
||||
return str(rules.get("templates", {}).get(event, ""))
|
||||
|
||||
|
||||
def event_text(rules: dict, event: str, **vars_dict) -> str:
|
||||
"""Build the speech text for an event, or '' if disabled/empty template."""
|
||||
if not is_enabled(rules, event):
|
||||
return ""
|
||||
text = render_template(get_template(rules, event), **vars_dict)
|
||||
return text.strip()
|
||||
|
||||
|
||||
# ── guard level mapping ─────────────────────────────────────────────────
|
||||
GUARD_LEVEL_NAMES = {1: "\u603b\u7763", 2: "\u63d0\u7763", 3: "\u8230\u957f"}
|
||||
@@ -13,6 +13,7 @@ import sys
|
||||
|
||||
from danmaku_handler import DanmakuClient
|
||||
from chinese2kana import chinese_to_kana, filter_kana
|
||||
from event_rules import load_rules
|
||||
from tts import TTSBridge
|
||||
from audio_player import play_wav_async, get_output_devices
|
||||
|
||||
@@ -71,9 +72,11 @@ async def main_async(args: argparse.Namespace) -> int:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
|
||||
shutdown_event = asyncio.Event()
|
||||
bridge = TTSBridge(voice=args.voice, speed=args.speed)
|
||||
rules = load_rules()
|
||||
danmaku_client = DanmakuClient(
|
||||
room_id=args.room_id, queue=queue,
|
||||
cookies={"SESSDATA": args.sessdata} if args.sessdata else None,
|
||||
rules=rules,
|
||||
message_format=args.format,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from chinese2kana import chinese_to_kana, filter_kana
|
||||
from tts import TTSBridge
|
||||
from audio_player import play_wav_async, get_output_devices
|
||||
from bili_login import BiliLoginSession
|
||||
from event_rules import DEFAULT_RULES, load_rules, save_rules, rules_path
|
||||
|
||||
logger = logging.getLogger("bililive-tts-server")
|
||||
|
||||
@@ -27,6 +28,7 @@ def _get_static_dir() -> Path:
|
||||
return Path(__file__).parent / "static"
|
||||
|
||||
STATIC_DIR = _get_static_dir()
|
||||
event_rules = load_rules()
|
||||
|
||||
|
||||
class TTSService:
|
||||
@@ -68,7 +70,8 @@ class TTSService:
|
||||
await self._bridge.start()
|
||||
self._danmaku_client = DanmakuClient(
|
||||
room_id=room_id, queue=self._queue,
|
||||
cookies=self._cookies, message_format=message_format,
|
||||
cookies=self._cookies, rules=event_rules,
|
||||
message_format=message_format,
|
||||
)
|
||||
await self._danmaku_client.start()
|
||||
self.running = True
|
||||
@@ -240,6 +243,42 @@ async def api_logout(request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "logged_out"})
|
||||
|
||||
|
||||
async def api_rules_get(request: web.Request) -> web.Response:
|
||||
return web.json_response(event_rules)
|
||||
|
||||
|
||||
async def api_rules_post(request: web.Request) -> web.Response:
|
||||
global event_rules
|
||||
try:
|
||||
new_rules = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
from event_rules import ALL_EVENTS, DEFAULT_RULES
|
||||
merged = {
|
||||
"enabled": dict(DEFAULT_RULES["enabled"]),
|
||||
"templates": dict(DEFAULT_RULES["templates"]),
|
||||
"skip_free_gift": True,
|
||||
}
|
||||
if isinstance(new_rules.get("enabled"), dict):
|
||||
for k in ALL_EVENTS:
|
||||
if isinstance(new_rules["enabled"].get(k), bool):
|
||||
merged["enabled"][k] = new_rules["enabled"][k]
|
||||
if isinstance(new_rules.get("templates"), dict):
|
||||
for k in ALL_EVENTS:
|
||||
if isinstance(new_rules["templates"].get(k), str):
|
||||
merged["templates"][k] = new_rules["templates"][k]
|
||||
if isinstance(new_rules.get("skip_free_gift"), bool):
|
||||
merged["skip_free_gift"] = new_rules["skip_free_gift"]
|
||||
|
||||
try:
|
||||
save_rules(merged)
|
||||
except OSError as e:
|
||||
return web.json_response({"error": f"Failed to save rules: {e}"}, status=500)
|
||||
event_rules = merged
|
||||
return web.json_response({"status": "saved", "rules": event_rules})
|
||||
|
||||
|
||||
def create_app() -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_get("/", index_handler)
|
||||
@@ -251,6 +290,8 @@ def create_app() -> web.Application:
|
||||
app.router.add_get("/api/qr/poll", api_qr_poll)
|
||||
app.router.add_get("/api/login/status", api_login_status)
|
||||
app.router.add_get("/api/logout", api_logout)
|
||||
app.router.add_get("/api/rules", api_rules_get)
|
||||
app.router.add_post("/api/rules", api_rules_post)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -202,6 +202,27 @@
|
||||
animation: spin 0.8s linear infinite; margin: 0 auto;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Event rules */
|
||||
.rule-row {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rule-row .rule-name {
|
||||
width: 110px; font-size: 0.82rem; color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rule-row input[type="checkbox"] {
|
||||
accent-color: var(--accent); width: 15px; height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rule-row input[type="text"] {
|
||||
flex: 1; padding: 6px 10px; border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg); color: var(--text);
|
||||
font-size: 0.85rem; outline: none;
|
||||
}
|
||||
.rule-row input[type="text"]:focus { border-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -280,6 +301,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Voice Rules Card -->
|
||||
<div class="card" id="rules-card">
|
||||
<h2>Event Voice Rules</h2>
|
||||
<div class="rule-list" id="rule-list"></div>
|
||||
<div class="form-row" style="margin-top:10px; align-items:center">
|
||||
<label class="chk-label"><input type="checkbox" id="rule-skip-free"> Skip free gifts</label>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn btn-login" onclick="saveRules()">Save Rules</button>
|
||||
</div>
|
||||
<div class="rule-hint" style="margin-top:8px; font-size:0.78rem; color:var(--text-dim); line-height:1.6">
|
||||
Variables: {uname} {msg} | {viewer_name} {gift_name} {gift_num} {blind_box_gift} |
|
||||
{ships_viewer_name} {ships_level} {ships_num} {guard_days} | {super_chat_content} {super_chat_price} |
|
||||
{interact_type} {user_uid} — empty template = event muted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Card -->
|
||||
<div class="card">
|
||||
<div class="status-bar">
|
||||
@@ -530,7 +567,81 @@ async function checkExistingLogin() {
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Event voice rules ────────────────────────────────────────────────────
|
||||
|
||||
const RULE_LABELS = {
|
||||
danmaku: 'Danmaku', gift: 'Gift', blind_box: 'Blind Box',
|
||||
enter_room: 'Enter Room', follow: 'Follow', like: 'Like',
|
||||
interact_other: 'Interact', buy_guard: 'Buy Guard',
|
||||
guard_toast: 'Guard Toast', super_chat: 'Super Chat',
|
||||
};
|
||||
|
||||
async function loadRules() {
|
||||
try {
|
||||
const resp = await fetch('/api/rules');
|
||||
const rules = await resp.json();
|
||||
renderRules(rules);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderRules(rules) {
|
||||
const list = document.getElementById('rule-list');
|
||||
list.innerHTML = '';
|
||||
for (const key of Object.keys(RULE_LABELS)) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'rule-row';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'rule-name';
|
||||
name.textContent = RULE_LABELS[key];
|
||||
const chk = document.createElement('input');
|
||||
chk.type = 'checkbox';
|
||||
chk.dataset.key = key;
|
||||
chk.checked = !!(rules.enabled && rules.enabled[key]);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.dataset.key = key;
|
||||
input.placeholder = 'Empty = muted';
|
||||
input.value = (rules.templates && rules.templates[key]) || '';
|
||||
row.appendChild(name);
|
||||
row.appendChild(chk);
|
||||
row.appendChild(input);
|
||||
list.appendChild(row);
|
||||
}
|
||||
const skip = document.getElementById('rule-skip-free');
|
||||
skip.checked = !!rules.skip_free_gift;
|
||||
}
|
||||
|
||||
async function saveRules() {
|
||||
const enabled = {};
|
||||
const templates = {};
|
||||
document.querySelectorAll('#rule-list input[type="checkbox"]').forEach(c => {
|
||||
enabled[c.dataset.key] = c.checked;
|
||||
});
|
||||
document.querySelectorAll('#rule-list input[type="text"]').forEach(i => {
|
||||
templates[i.dataset.key] = i.value;
|
||||
});
|
||||
const payload = {
|
||||
enabled,
|
||||
templates,
|
||||
skip_free_gift: document.getElementById('rule-skip-free').checked,
|
||||
};
|
||||
try {
|
||||
const resp = await fetch('/api/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) { alert('Save failed: ' + data.error); return; }
|
||||
renderRules(data.rules);
|
||||
alert('Rules saved. Restart TTS to apply.');
|
||||
} catch(e) {
|
||||
alert('Save failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
checkExistingLogin();
|
||||
loadRules();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user