Initial import of Shirobana bot backend

Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
# for_admin_catch_log.py
# 管理员日志查看:读取 bot-utility 的 systemd 日志或指定日志文件。
import asyncio
import os
import subprocess
LOG_PATH = os.environ.get("BOT_LOG_PATH", "")
LOG_UNIT = os.environ.get("BOT_LOG_UNIT", "bot-utility.service")
DEFAULT_LINES = 5
MAX_LINES = 100
LOG_ENCODING = os.environ.get("BOT_LOG_ENCODING", "utf-8")
def _admin_list():
from channel import admin_list
return [x for n in ("qq", "tg") for x in admin_list(n)]
def _tail_log(lines):
"""读取最后 lines 行;未指定 BOT_LOG_PATH 时使用 journal,返回 (ok, text)。"""
if not LOG_PATH:
return _tail_journal(lines)
if not os.path.exists(LOG_PATH):
return False, f"日志文件不存在: {LOG_PATH}"
try:
# 从文件尾部倒着读块, 避免一次性载入大文件
with open(LOG_PATH, "rb") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
block = 8192
pos = size
buf = b""
while pos > 0 and buf.count(b"\n") < lines + 1:
read_len = min(block, pos)
pos -= read_len
f.seek(pos)
buf = f.read(read_len) + buf
tail_bytes = buf
text = tail_bytes.decode(LOG_ENCODING, errors="replace")
rows = text.splitlines()
if len(rows) > lines:
rows = rows[-lines:]
body = "\n".join(rows).strip()
if not body:
return False, "日志为空"
return True, body
except Exception as e:
return False, f"读取日志异常: {str(e)[:200]}"
def _tail_journal(lines):
try:
result = subprocess.run(
["journalctl", "--unit", LOG_UNIT, "--lines", str(lines),
"--no-pager", "--output=cat", "--quiet"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=10, check=False,
)
if result.returncode or "permission" in result.stderr.lower():
return False, f"读取 systemd 日志失败: {result.stderr.strip()[:200]}"
body = result.stdout.strip()
return (True, body) if body else (False, "日志为空")
except (OSError, subprocess.TimeoutExpired) as e:
return False, f"读取 systemd 日志异常: {str(e)[:200]}"
def _dedupe_merge(text):
"""合并连续重复行: 相同行连续出现 N 次时只保留一行并标注 (xN)"""
rows = text.splitlines()
if not rows:
return ""
merged = []
prev = rows[0]
count = 1
for row in rows[1:]:
if row == prev:
count += 1
else:
merged.append(f"{prev} (x{count})" if count > 1 else prev)
prev = row
count = 1
merged.append(f"{prev} (x{count})" if count > 1 else prev)
return "\n".join(merged)
def _clip(text):
"""限制输出长度, 避免刷屏 (QQ 单条消息上限约 4000 字符)"""
if len(text) > 3800:
text = text[-3800:]
return text
HELP_TEXT = (
"日志查看指令\n"
"━━━━━━━━━━━━\n"
"• .log help 显示本帮助\n"
"• .log <int> 获取最近的 N 条日志 (最大 100, 默认 5)\n"
)
async def handle_command(ws, msg_type, target_id, raw_text):
from channel import send_message
parts = raw_text.split(maxsplit=1)
arg = parts[1].strip() if len(parts) >= 2 else ""
if arg.lower() == "help":
await send_message(ws, msg_type, target_id, HELP_TEXT)
return
if arg == "":
lines = DEFAULT_LINES
else:
try:
lines = int(arg)
except ValueError:
await send_message(ws, msg_type, target_id,
"参数必须为整数, 例如 .log 10 或 .log help")
return
if lines < 1:
await send_message(ws, msg_type, target_id, "日志条数至少为 1")
return
if lines > MAX_LINES:
lines = MAX_LINES
ok, text = await asyncio.to_thread(_tail_log, lines)
if not ok:
await send_message(ws, msg_type, target_id, text)
return
body = _clip(_dedupe_merge(text))
await send_message(ws, msg_type, target_id,
f"最近 {lines} 条日志:\n━━━━━━━━━━━━\n{body}")