first commit
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
"""
|
||||
BetterGI 弹幕联动主程序
|
||||
========================
|
||||
监听 B 站直播间弹幕 -> 关键词匹配 -> 调用 BetterGI.exe --startGroups 执行配置组
|
||||
|
||||
依赖: pip install websockets brotli
|
||||
运行: python danmu_bettergi.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
if str(APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
|
||||
from core.runtime_paths import APP_ROOT as PROJECT_ROOT, CONFIG_DIR, LOG_DIR, ensure_runtime_dirs, project_path
|
||||
|
||||
try:
|
||||
import websockets
|
||||
import brotli
|
||||
except ImportError:
|
||||
print("缺少依赖,请先运行: pip install websockets brotli")
|
||||
sys.exit(1)
|
||||
|
||||
# ============== B站直播弹幕协议常量 ==============
|
||||
HEADER_LEN = 16
|
||||
OP_HEARTBEAT = 2 # 心跳请求
|
||||
OP_HEARTBEAT_REPLY = 3 # 心跳响应(人气值)
|
||||
OP_MESSAGE = 5 # 业务消息
|
||||
OP_AUTH = 7 # 认证请求
|
||||
OP_AUTH_REPLY = 8 # 认证响应
|
||||
PROTO_JSON = 0 # 明文JSON
|
||||
PROTO_ZLIB = 2 # zlib压缩
|
||||
PROTO_BROTLI = 3 # brotli压缩
|
||||
|
||||
|
||||
# ============== 弹幕服务器发现 ==============
|
||||
def get_danmu_server(room_id: int) -> dict:
|
||||
"""通过B站API获取弹幕服务器地址和token。优先使用host_server_list中的新服务器。"""
|
||||
url = f"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={room_id}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", "Mozilla/5.0")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())["data"]
|
||||
token = data["token"]
|
||||
host_list = data.get("host_server_list", [])
|
||||
if host_list:
|
||||
entry = host_list[0]
|
||||
host = entry["host"]
|
||||
port = entry.get("wss_port", 443)
|
||||
else:
|
||||
host = data["host"]
|
||||
port = data.get("wss_port", 443)
|
||||
return {
|
||||
"ws_url": f"wss://{host}:{port}/sub",
|
||||
"token": token,
|
||||
"host": host,
|
||||
"port": port,
|
||||
}
|
||||
|
||||
|
||||
def get_buvid3(sessdata: str = "") -> str:
|
||||
"""通过B站finger/spi接口获取buvid3 (2024+协议认证必需)。"""
|
||||
url = "https://api.bilibili.com/x/frontend/finger/spi"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", "Mozilla/5.0")
|
||||
if sessdata:
|
||||
req.add_header("Cookie", f"SESSDATA={sessdata}")
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())
|
||||
if data.get("code") == 0:
|
||||
return data["data"]["b_3"]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
# ============== 配置管理 ==============
|
||||
class Config:
|
||||
def __init__(self, path: str):
|
||||
self.path = Path(path)
|
||||
self.data = {}
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
self.data = json.load(f)
|
||||
|
||||
@property
|
||||
def room_id(self) -> int:
|
||||
return int(self.data["bilibili"]["room_id"])
|
||||
|
||||
@property
|
||||
def sessdata(self) -> str:
|
||||
return self.data["bilibili"].get("sessdata", "")
|
||||
|
||||
@property
|
||||
def bettergi_exe(self) -> str:
|
||||
return self.data["bettergi"]["exe_path"]
|
||||
|
||||
@property
|
||||
def bettergi_work_dir(self) -> str:
|
||||
wd = self.data["bettergi"].get("work_dir", "")
|
||||
return wd if wd else str(Path(self.bettergi_exe).parent)
|
||||
|
||||
@property
|
||||
def default_cooldown(self) -> int:
|
||||
return int(self.data["global"].get("default_cooldown", 30))
|
||||
|
||||
@property
|
||||
def admin_uids(self) -> set:
|
||||
return set(int(x) for x in self.data["global"].get("admin_uids", []))
|
||||
|
||||
@property
|
||||
def rules(self) -> list:
|
||||
return self.data.get("rules", [])
|
||||
|
||||
@property
|
||||
def restart_mode(self) -> str:
|
||||
"""重启模式: gentle(温和,跳过) / aggressive(激进,先杀再启)"""
|
||||
return self.data["global"].get("restart_mode", "gentle")
|
||||
|
||||
|
||||
# ============== 冷却管理 ==============
|
||||
class CooldownManager:
|
||||
"""按规则记录最后触发时间,防止同一指令被弹幕刷屏重复触发。"""
|
||||
|
||||
def __init__(self):
|
||||
self._last_fire: dict = {} # keyword -> timestamp
|
||||
|
||||
def can_fire(self, keyword: str, cooldown: int) -> bool:
|
||||
now = time.time()
|
||||
last = self._last_fire.get(keyword, 0)
|
||||
return (now - last) >= cooldown
|
||||
|
||||
def mark_fired(self, keyword: str):
|
||||
self._last_fire[keyword] = time.time()
|
||||
|
||||
|
||||
# ============== BetterGI 调用 ==============
|
||||
class BetterGIRunner:
|
||||
"""封装 BetterGI.exe --startGroups 调用。"""
|
||||
|
||||
def __init__(self, exe_path: str, work_dir: str, logger: logging.Logger,
|
||||
restart_mode: str = "gentle"):
|
||||
self.exe_path = exe_path
|
||||
self.work_dir = work_dir
|
||||
self.logger = logger
|
||||
self.restart_mode = restart_mode # gentle / aggressive
|
||||
self._busy = False # 标记是否正在执行任务
|
||||
self._lock = asyncio.Lock()
|
||||
# 缓存 cancelTaskHotkey (从 BetterGI Config.json 读)
|
||||
self._cancel_hotkey = None
|
||||
self._load_cancel_hotkey()
|
||||
|
||||
def _load_cancel_hotkey(self):
|
||||
"""从 BetterGI 的 Config.json 读取取消任务热键。"""
|
||||
try:
|
||||
import os
|
||||
cfg_path = os.path.join(self.work_dir, "User", "Config.json")
|
||||
if os.path.exists(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
bgi_cfg = json.load(f)
|
||||
hk = bgi_cfg.get("hotKeyConfig", {}).get("cancelTaskHotkey", "")
|
||||
if hk:
|
||||
self._cancel_hotkey = hk
|
||||
self.logger.info(f"已读取 BetterGI 取消任务热键: {hk}")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"读取 cancelTaskHotkey 失败(不影响使用): {e}")
|
||||
|
||||
async def _stop_bgi_gracefully(self):
|
||||
"""优雅停止 BetterGI: 先按取消热键,再 taskkill 兜底。"""
|
||||
# ① 模拟按取消热键 (让 BGI 内部收尾,保存进度)
|
||||
if self._cancel_hotkey:
|
||||
self.logger.info(f"[激进] 按取消热键: {self._cancel_hotkey}")
|
||||
try:
|
||||
await self._send_key(self._cancel_hotkey)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"[激进] 按热键失败: {e}")
|
||||
# 给 BetterGI 5 秒收尾时间
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# ② taskkill 强杀兜底
|
||||
self.logger.info("[激进] taskkill /F /IM BetterGI.exe")
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"taskkill", "/F", "/IM", "BetterGI.exe",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
# CREATE_NO_WINDOW = 0x08000000,避免弹黑窗
|
||||
creationflags=0x08000000,
|
||||
)
|
||||
await proc.communicate()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"[激进] taskkill 执行: {e}")
|
||||
# 再等 1 秒让进程完全退出
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _send_key(self, key: str):
|
||||
"""模拟按键。优先用 pywin32,其次 keyboard 库,都没有则跳过。"""
|
||||
# 简单映射: BetterGI 的热键名通常是 "F9" "Ctrl+P" 这种
|
||||
# 优先尝试 pywin32 (最稳定,不弹窗)
|
||||
try:
|
||||
import win32api
|
||||
import win32con
|
||||
# 简单支持单键 (F1-F24, 字母, 数字)
|
||||
vk_map = {
|
||||
"F1": win32con.VK_F1, "F2": win32con.VK_F2, "F3": win32con.VK_F3,
|
||||
"F4": win32con.VK_F4, "F5": win32con.VK_F5, "F6": win32con.VK_F6,
|
||||
"F7": win32con.VK_F7, "F8": win32con.VK_F8, "F9": win32con.VK_F9,
|
||||
"F10": win32con.VK_F10, "F11": win32con.VK_F11, "F12": win32con.VK_F12,
|
||||
"ESC": win32con.VK_ESCAPE, "ESCAPE": win32con.VK_ESCAPE,
|
||||
}
|
||||
# 处理 Ctrl+X Shift+X 这种组合键
|
||||
parts = key.replace("+", " ").split()
|
||||
main_key = parts[-1].upper()
|
||||
ctrl = "CTRL" in [p.upper() for p in parts[:-1]]
|
||||
shift = "SHIFT" in [p.upper() for p in parts[:-1]]
|
||||
alt = "ALT" in [p.upper() for p in parts[:-1]]
|
||||
|
||||
vk = vk_map.get(main_key)
|
||||
if vk is None and len(main_key) == 1:
|
||||
vk = ord(main_key.upper()) # 字母键
|
||||
|
||||
if vk is None:
|
||||
self.logger.warning(f"[激进] 不支持的热键: {key}, 跳过热键直接 taskkill")
|
||||
return
|
||||
|
||||
if ctrl:
|
||||
win32api.keybd_event(win32con.VK_CONTROL, 0, 0, 0)
|
||||
if shift:
|
||||
win32api.keybd_event(win32con.VK_SHIFT, 0, 0, 0)
|
||||
if alt:
|
||||
win32api.keybd_event(win32con.VK_MENU, 0, 0, 0)
|
||||
|
||||
win32api.keybd_event(vk, 0, 0, 0) # 按下
|
||||
win32api.keybd_event(vk, 0, win32con.KEYEVENTF_KEYUP, 0) # 抬起
|
||||
|
||||
if ctrl:
|
||||
win32api.keybd_event(win32con.VK_CONTROL, 0, win32con.KEYEVENTF_KEYUP, 0)
|
||||
if shift:
|
||||
win32api.keybd_event(win32con.VK_SHIFT, 0, win32con.KEYEVENTF_KEYUP, 0)
|
||||
if alt:
|
||||
win32api.keybd_event(win32con.VK_MENU, 0, win32con.KEYEVENTF_KEYUP, 0)
|
||||
|
||||
self.logger.info(f"[激进] 已模拟按键: {key}")
|
||||
except ImportError:
|
||||
self.logger.warning(
|
||||
"[激进] 未安装 pywin32, 无法模拟热键。"
|
||||
"可运行: pip install pywin32 (可选,不装则直接 taskkill)"
|
||||
)
|
||||
|
||||
async def run_groups(self, groups: list) -> bool:
|
||||
"""异步启动配置组。"""
|
||||
async with self._lock:
|
||||
if self._busy:
|
||||
if self.restart_mode == "aggressive":
|
||||
self.logger.warning(
|
||||
f"[激进模式] BetterGI 正在执行任务,先杀再启: {groups}"
|
||||
)
|
||||
# 释放锁,执行停止 (停止可能耗时,不持有锁)
|
||||
self._busy = False
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"[温和模式] BetterGI 正在执行任务,跳过本次触发: {groups}"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self._busy = True
|
||||
|
||||
# 激进模式: 先停止旧任务
|
||||
if self.restart_mode == "aggressive":
|
||||
await self._stop_bgi_gracefully()
|
||||
async with self._lock:
|
||||
self._busy = True
|
||||
|
||||
try:
|
||||
cmd = [self.exe_path, "--startGroups"] + groups
|
||||
self.logger.info(f"调用 BetterGI: {' '.join(cmd)}")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=self.work_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode in (0, 553):
|
||||
self.logger.info(f"BetterGI 启动成功: {groups} (rc={proc.returncode})")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(
|
||||
f"BetterGI 启动失败 rc={proc.returncode}: "
|
||||
f"{stderr.decode('gbk', errors='replace')}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.exception(f"调用 BetterGI 异常: {e}")
|
||||
return False
|
||||
finally:
|
||||
self._busy = False
|
||||
|
||||
|
||||
# ============== 弹幕匹配引擎 ==============
|
||||
class DanmuMatcher:
|
||||
"""把弹幕文本匹配到对应规则。"""
|
||||
|
||||
def __init__(self, config: Config, cooldown: CooldownManager,
|
||||
runner: BetterGIRunner, logger: logging.Logger):
|
||||
self.config = config
|
||||
self.cooldown = cooldown
|
||||
self.runner = runner
|
||||
self.logger = logger
|
||||
|
||||
async def handle(self, text: str, uid: int, uname: str):
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
admins = self.config.admin_uids
|
||||
is_admin = uid in admins
|
||||
|
||||
for rule in self.config.rules:
|
||||
if not self._match(text, rule):
|
||||
continue
|
||||
|
||||
keyword = rule["keyword"]
|
||||
groups = rule["groups"]
|
||||
cooldown_sec = rule.get("cooldown", self.config.default_cooldown)
|
||||
admin_only = rule.get("admin_only", False)
|
||||
reply = rule.get("reply", "")
|
||||
|
||||
# 权限检查
|
||||
if admin_only and not is_admin:
|
||||
self.logger.info(
|
||||
f"[权限不足] {uname}({uid}) 弹幕'{text}' 命中'{keyword}' 但需管理员"
|
||||
)
|
||||
return # 注意: 这里return了,继续检查下一条规则(如果有)
|
||||
|
||||
# 冷却检查
|
||||
if not self.cooldown.can_fire(keyword, cooldown_sec):
|
||||
remain = int(cooldown_sec - (time.time() - self.cooldown._last_fire.get(keyword, 0)))
|
||||
self.logger.info(
|
||||
f"[冷却中] '{keyword}' 还需 {remain}s, 来自 {uname}({uid})"
|
||||
)
|
||||
return # 冷却不触发,但日志已打印
|
||||
|
||||
# 触发
|
||||
self.cooldown.mark_fired(keyword)
|
||||
self.logger.info(
|
||||
f"[触发] {uname}({uid}) 弹幕'{text}' -> 配置组 {groups}"
|
||||
)
|
||||
if reply:
|
||||
self.logger.info(f"[回复提示] {reply}")
|
||||
await self.runner.run_groups(groups)
|
||||
return # 一条弹幕只触发第一个命中的规则
|
||||
|
||||
@staticmethod
|
||||
def _match(text: str, rule: dict) -> bool:
|
||||
keyword = rule["keyword"]
|
||||
mtype = rule.get("match_type", "contains")
|
||||
if mtype == "exact":
|
||||
return text == keyword
|
||||
elif mtype == "contains":
|
||||
return keyword in text
|
||||
elif mtype == "startswith":
|
||||
return text.startswith(keyword)
|
||||
elif mtype == "regex":
|
||||
import re
|
||||
return re.search(keyword, text) is not None
|
||||
return False
|
||||
|
||||
|
||||
# ============== B站弹幕协议 ==============
|
||||
def make_packet(op: int, body: bytes = b"") -> bytes:
|
||||
"""组装协议包。header 16字节 + body。"""
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
total = HEADER_LEN + len(body)
|
||||
header = struct.pack(">IHHII", total, HEADER_LEN, 1, op, 1)
|
||||
return header + body
|
||||
|
||||
|
||||
def parse_packets(data: bytes):
|
||||
"""一个 WebSocket 帧可能含多个协议包,循环切分。"""
|
||||
offset = 0
|
||||
packets = []
|
||||
while offset < len(data):
|
||||
if offset + HEADER_LEN > len(data):
|
||||
break
|
||||
total, header_len, proto_ver, op, seq = struct.unpack(
|
||||
">IHHII", data[offset:offset + HEADER_LEN]
|
||||
)
|
||||
body = data[offset + header_len:offset + total]
|
||||
packets.append((proto_ver, op, body))
|
||||
offset += total
|
||||
return packets
|
||||
|
||||
|
||||
def decode_body(proto_ver: int, body: bytes) -> bytes:
|
||||
"""按协议版本解压 body。"""
|
||||
if proto_ver == PROTO_JSON:
|
||||
return body
|
||||
if proto_ver == PROTO_ZLIB:
|
||||
return zlib.decompress(body)
|
||||
if proto_ver == PROTO_BROTLI:
|
||||
return brotli.decompress(body)
|
||||
return body
|
||||
|
||||
|
||||
def _clean_uid(value) -> int:
|
||||
"""只接受纯十进制正整数 UID,匿名或含星号的值返回 0。"""
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return value if value > 0 else 0
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
return int(value) if value.isdecimal() and int(value) > 0 else 0
|
||||
return 0
|
||||
|
||||
|
||||
def extract_danmu(body: bytes):
|
||||
"""从消息体中提取弹幕(DANMU_MSG)。返回 [(text, uid, uname), ...]。"""
|
||||
results = []
|
||||
try:
|
||||
decoded = decode_body(0, body) # body 已是解压后的,proto_ver 此处忽略
|
||||
except Exception:
|
||||
decoded = body
|
||||
try:
|
||||
msg = json.loads(decoded.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return results
|
||||
cmd = msg.get("cmd", "")
|
||||
if cmd.startswith("DANMU_MSG"):
|
||||
info = msg.get("info", [])
|
||||
if not isinstance(info, list) or len(info) <= 2:
|
||||
return results
|
||||
text = str(info[1] if len(info) > 1 else "")
|
||||
member = info[2]
|
||||
if isinstance(member, (list, tuple)):
|
||||
uid = _clean_uid(member[0] if len(member) > 0 else 0)
|
||||
uname = str(member[1] if len(member) > 1 and member[1] is not None else "")
|
||||
elif isinstance(member, dict):
|
||||
uid = _clean_uid(member.get("uid_str") or member.get("uid") or member.get("mid"))
|
||||
uname = str(member.get("uname") or member.get("name") or "")
|
||||
else:
|
||||
uid, uname = 0, ""
|
||||
results.append((text, uid, uname))
|
||||
return results
|
||||
|
||||
|
||||
# ============== 弹幕客户端 ==============
|
||||
class BliveClient:
|
||||
"""B站直播弹幕 WebSocket 客户端,带自动重连。"""
|
||||
|
||||
def __init__(self, room_id: int, sessdata: str,
|
||||
matcher: DanmuMatcher, logger: logging.Logger):
|
||||
self.room_id = room_id
|
||||
self.sessdata = sessdata
|
||||
self.matcher = matcher
|
||||
self.logger = logger
|
||||
self._stop = False
|
||||
self._buvid3 = ""
|
||||
|
||||
async def run(self):
|
||||
"""主循环:断线自动重连,间隔递增。"""
|
||||
# 启动时获取buvid3 (2024+协议认证必需)
|
||||
self._buvid3 = get_buvid3(self.sessdata)
|
||||
if self._buvid3:
|
||||
self.logger.info(f"获取buvid3成功: {self._buvid3[:20]}...")
|
||||
else:
|
||||
self.logger.warning("获取buvid3失败,弹幕可能收不到! 将尝试匿名连接")
|
||||
retry = 0
|
||||
while not self._stop:
|
||||
try:
|
||||
# 每次连接前重新获取服务器地址(避免IP变化)
|
||||
server_info = get_danmu_server(self.room_id)
|
||||
ws_url = server_info["ws_url"]
|
||||
token = server_info["token"]
|
||||
self.logger.info(f"弹幕服务器: {server_info['host']}:{server_info['port']}")
|
||||
await self._connect_once(ws_url, token)
|
||||
retry = 0 # 连上后重置
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.warning(f"连接断开: {e}")
|
||||
if self._stop:
|
||||
break
|
||||
retry += 1
|
||||
wait = min(2 ** retry, 60) # 指数退避,最多60秒
|
||||
self.logger.info(f"{wait}秒后重连(第{retry}次)...")
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
|
||||
async def _connect_once(self, ws_url: str, token: str):
|
||||
self.logger.info(f"连接直播间 room_id={self.room_id} ...")
|
||||
# 禁用 websockets 自带 ping/pong (B站有自己的心跳协议)
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
max_size=None,
|
||||
ping_interval=None,
|
||||
ping_timeout=None,
|
||||
close_timeout=5,
|
||||
) as ws:
|
||||
# 1. 发送认证包(使用API返回的token + buvid3)
|
||||
auth = {
|
||||
"uid": 0,
|
||||
"roomid": self.room_id,
|
||||
"protover": PROTO_BROTLI,
|
||||
"platform": "web",
|
||||
"type": 2,
|
||||
"key": token or (self.sessdata or ""),
|
||||
}
|
||||
if self._buvid3:
|
||||
auth["buvid"] = self._buvid3
|
||||
await ws.send(make_packet(OP_AUTH, json.dumps(auth)))
|
||||
self.logger.info("已发送认证包,等待响应...")
|
||||
# 立即发送一个心跳,激活弹幕推送
|
||||
await ws.send(make_packet(OP_HEARTBEAT))
|
||||
|
||||
# 2. 心跳任务
|
||||
async def heartbeat():
|
||||
while True:
|
||||
await asyncio.sleep(15)
|
||||
try:
|
||||
await ws.send(make_packet(OP_HEARTBEAT))
|
||||
except Exception:
|
||||
break
|
||||
|
||||
hb_task = asyncio.create_task(heartbeat())
|
||||
|
||||
# 3. 接收循环
|
||||
msg_counter = 0
|
||||
async for raw in ws:
|
||||
if isinstance(raw, str):
|
||||
continue
|
||||
for proto_ver, op, body in parse_packets(raw):
|
||||
if op == OP_AUTH_REPLY:
|
||||
# 检查认证是否真的成功
|
||||
try:
|
||||
auth_resp = json.loads(body.decode("utf-8", errors="replace"))
|
||||
code = auth_resp.get("code", -1)
|
||||
if code == 0:
|
||||
self.logger.info(f"认证成功,开始监听弹幕 (响应: {auth_resp})")
|
||||
else:
|
||||
self.logger.error(f"认证失败! 响应: {auth_resp}")
|
||||
except Exception:
|
||||
self.logger.info(f"认证响应(原始): {body}")
|
||||
elif op == OP_HEARTBEAT_REPLY:
|
||||
# body 是 4 字节人气值(大端序int32)
|
||||
if len(body) >= 4:
|
||||
popularity = struct.unpack(">I", body[:4])[0]
|
||||
self.logger.debug(f"人气值: {popularity}")
|
||||
elif op == OP_MESSAGE:
|
||||
# body 可能被压缩,按 proto_ver 解压后再切包
|
||||
try:
|
||||
decoded = decode_body(proto_ver, body)
|
||||
except Exception as e:
|
||||
self.logger.error(f"解压失败 proto={proto_ver} len={len(body)}: {e}")
|
||||
continue
|
||||
# 解压后可能内含多个子包
|
||||
for sub_proto, sub_op, sub_body in parse_packets(decoded):
|
||||
if sub_op == OP_MESSAGE:
|
||||
msg_counter += 1
|
||||
# 调试:打印每条消息的cmd类型
|
||||
try:
|
||||
msg_json = json.loads(sub_body.decode("utf-8", errors="replace"))
|
||||
cmd = msg_json.get("cmd", "?")
|
||||
self.logger.debug(f"#{msg_counter} cmd={cmd}")
|
||||
except Exception:
|
||||
self.logger.debug(f"#{msg_counter} 解析JSON失败")
|
||||
continue
|
||||
for text, uid, uname in extract_danmu(sub_body):
|
||||
self.logger.info(f"[弹幕] {uname}({uid}): {text}")
|
||||
await self.matcher.handle(text, uid, uname)
|
||||
|
||||
hb_task.cancel()
|
||||
|
||||
|
||||
# ============== 日志 ==============
|
||||
def setup_logger(config: Config) -> logging.Logger:
|
||||
logger = logging.getLogger("danmu_bettergi")
|
||||
logger.setLevel(getattr(logging, config.data["global"].get("log_level", "INFO")))
|
||||
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S")
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(fmt)
|
||||
logger.addHandler(sh)
|
||||
log_file = config.data["global"].get("log_file")
|
||||
if log_file:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fh = logging.FileHandler(project_path(log_file, base=LOG_DIR), encoding="utf-8")
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
return logger
|
||||
|
||||
|
||||
# ============== 入口 ==============
|
||||
async def main():
|
||||
ensure_runtime_dirs()
|
||||
config_path = CONFIG_DIR / "config.json"
|
||||
if not config_path.exists():
|
||||
legacy_config_path = PROJECT_ROOT / "config.json"
|
||||
if legacy_config_path.exists():
|
||||
config_path = legacy_config_path
|
||||
if not config_path.exists():
|
||||
print("找不到 config.json,请先配置!")
|
||||
sys.exit(1)
|
||||
|
||||
config = Config(str(config_path))
|
||||
logger = setup_logger(config)
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("BetterGI 弹幕联动启动")
|
||||
logger.info(f"直播间: {config.room_id}")
|
||||
logger.info(f"BetterGI: {config.bettergi_exe}")
|
||||
logger.info(f"规则数: {len(config.rules)}")
|
||||
logger.info(f"管理员UID: {config.admin_uids or '无'}")
|
||||
logger.info(f"默认冷却: {config.default_cooldown}s")
|
||||
logger.info(f"重启模式: {config.restart_mode}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
cooldown = CooldownManager()
|
||||
runner = BetterGIRunner(
|
||||
config.bettergi_exe, config.bettergi_work_dir, logger,
|
||||
restart_mode=config.restart_mode,
|
||||
)
|
||||
matcher = DanmuMatcher(config, cooldown, runner, logger)
|
||||
client = BliveClient(config.room_id, config.sessdata, matcher, logger)
|
||||
|
||||
try:
|
||||
await client.run()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到退出信号,正在停止...")
|
||||
client.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Reference in New Issue
Block a user