fix: require @bot in groups, verify at target, ignore non-command and textless messages

This commit is contained in:
snowy-yang committed 2026-09-12 20:12:51 +08:00
1 parent f26f34cb87
commit e39d1a0f52
2 files changed
+119 -25

No files matched your search

+70 -23
View File
@@ -1,6 +1,7 @@
"""LinuxAwesomeBot 入口:启动 OneBot 反向 WS 服务端 + 命令处理 + 高负载告警。""" """LinuxAwesomeBot 入口:启动 OneBot 反向 WS 服务端 + 命令处理 + 高负载告警。"""
import asyncio import asyncio
import logging import logging
import re
import sys import sys
from pathlib import Path from pathlib import Path
@@ -128,6 +129,17 @@ def fmt_net(net: dict) -> str:
# ---------- 命令处理 ---------- # ---------- 命令处理 ----------
# 命令别名 -> 动作;只有此表内的词会触发,其他消息一律忽略
COMMANDS = {
"status": "status", "状态": "status",
"power": "power", "功耗": "power",
"top": "top", "进程": "top",
"temp": "temp", "温度": "temp",
"net": "net", "网络": "net",
"help": "help", "帮助": "help",
}
class Bot: class Bot:
def __init__(self, cfg: dict): def __init__(self, cfg: dict):
self.cfg = cfg self.cfg = cfg
@@ -135,24 +147,28 @@ class Bot:
cfg["server"]["host"], cfg["server"]["port"], cfg["server"]["host"], cfg["server"]["port"],
cfg["server"].get("access_token", ""), cfg["server"].get("access_token", ""),
) )
self._self_id: int | None = None # 机器人自己的 QQ 号,群聊 @ 校验用
async def on_event(self, ev: dict): async def on_event(self, ev: dict):
if ev.get("post_type") != "message": if ev.get("post_type") != "message":
return return
if ev.get("message_type") == "group": msg_type = ev.get("message_type")
text = self._extract_text(ev.get("message")) if msg_type == "group":
# 群聊需 @ 或带前缀 # 群聊必须 @ 机器人本人才响应
if not self._is_command(text) and not self._at_me(ev.get("message")): if not await self._at_me(ev.get("message")):
return return
elif ev.get("message_type") == "private": text = self._text_after_at(ev.get("message"))
elif msg_type == "private":
text = self._extract_text(ev.get("message")) text = self._extract_text(ev.get("message"))
if not text: # 纯图片/表情等无文本消息不响应
return
else: else:
return return
reply = await self.handle_command(text) reply = await self.handle_command(text)
if reply is None: if reply is None:
return return
if ev.get("message_type") == "group": if msg_type == "group":
await self.ob.send_group_msg(ev["group_id"], reply) await self.ob.send_group_msg(ev["group_id"], reply)
else: else:
await self.ob.send_private_msg(ev["user_id"], reply) await self.ob.send_private_msg(ev["user_id"], reply)
@@ -168,38 +184,69 @@ class Bot:
).strip() ).strip()
return "" return ""
@staticmethod async def _get_self_id(self) -> int | None:
def _at_me(message) -> bool: """获取并缓存机器人自己的 QQ 号。"""
if isinstance(message, list): if self._self_id is None:
return any( try:
seg.get("type") == "at" for seg in message if isinstance(seg, dict) info = await self.ob.call_api("get_login_info")
) self._self_id = int(info.get("user_id", 0)) or None
return "[CQ:at" in (message or "") except Exception:
log.exception("获取机器人登录信息失败")
return self._self_id
@staticmethod async def _at_me(self, message) -> bool:
def _is_command(text: str) -> bool: """校验消息中 @ 的是机器人本人(@其他人/全体不算)。"""
return text.startswith("/") or text.startswith("!") if self._self_id is None:
await self._get_self_id()
ats: list[str] = []
if isinstance(message, list):
ats = [str(seg.get("data", {}).get("qq", ""))
for seg in message if isinstance(seg, dict) and seg.get("type") == "at"]
else:
ats = re.findall(r"\[CQ:at,qq=(\d+)", message or "")
return str(self._self_id or "") in ats
def _text_after_at(self, message) -> str:
"""取 @ 之后剩余的文本。"""
if isinstance(message, list):
after = False
parts = []
for seg in message:
if not isinstance(seg, dict):
continue
if seg.get("type") == "at":
after = True
continue
if after and seg.get("type") == "text":
parts.append(seg.get("data", {}).get("text", ""))
return "".join(parts).strip()
if isinstance(message, str) and "[CQ:at" in message:
return message.split("]", 1)[-1].strip()
return self._extract_text(message)
async def handle_command(self, text: str) -> str | None: async def handle_command(self, text: str) -> str | None:
cmd = text.lstrip("/!").strip().lower() cmd = text.lstrip("/!").strip().lower()
cmd = cmd.split()[0] if cmd else "" cmd = cmd.split()[0] if cmd else ""
action = COMMANDS.get(cmd)
if action is None: # 非命令消息一律忽略
return None
try: try:
if cmd in ("status", "状态", ""): if action == "status":
return fmt_status(monitor.get_status()) return fmt_status(monitor.get_status())
if cmd in ("power", "功耗"): if action == "power":
return fmt_power({"power": monitor.get_power()}) return fmt_power({"power": monitor.get_power()})
if cmd in ("top", "进程"): if action == "top":
return fmt_top({"top": monitor.get_top_processes(10)}) return fmt_top({"top": monitor.get_top_processes(10)})
if cmd in ("temp", "温度"): if action == "temp":
return fmt_temps({"temps": monitor.get_temps()}) return fmt_temps({"temps": monitor.get_temps()})
if cmd in ("net", "网络"): if action == "net":
return fmt_net(monitor.get_net(interval=1.0)) return fmt_net(monitor.get_net(interval=1.0))
if cmd in ("help", "帮助"): if action == "help":
return templates.render("help") return templates.render("help")
return None return None
except Exception: except Exception:
log.exception("处理命令失败: %s", text) log.exception("处理命令失败: %s", text)
return "❌ 命令执行出错,请查看服务端日志。" return "[错误] 命令执行出错,请查看服务端日志。"
async def run(self): async def run(self):
await self.ob.start(self.on_event) await self.ob.start(self.on_event)
+49 -2
View File
@@ -27,10 +27,15 @@ class FakeOneBotClient:
async def _read(self): async def _read(self):
async for msg in self.ws: async for msg in self.ws:
data = json.loads(msg.data) data = json.loads(msg.data)
if data.get("action"): # API 请求:记录消息内容并回执 if data.get("action"):
if data["action"] == "get_login_info":
result = {"user_id": 88888, "nickname": "bot"}
else:
result = None
if data["action"].startswith("send_"):
self.replies.append(data["params"].get("message", "")) self.replies.append(data["params"].get("message", ""))
await self.ws.send_str(json.dumps( await self.ws.send_str(json.dumps(
{"status": "ok", "retcode": 0, "data": None, "echo": data.get("echo")})) {"status": "ok", "retcode": 0, "data": result, "echo": data.get("echo")}))
async def send_message(self, text: str): async def send_message(self, text: str):
await self.ws.send_str(json.dumps({ await self.ws.send_str(json.dumps({
@@ -38,6 +43,13 @@ class FakeOneBotClient:
"user_id": 10000, "message": text, "user_id": 10000, "message": text,
})) }))
async def send_group_message(self, message, group_id: int = 999):
"""message 可为字符串或 CQ 段数组。"""
await self.ws.send_str(json.dumps({
"post_type": "message", "message_type": "group",
"group_id": group_id, "user_id": 10000, "message": message,
}))
async def close(self): async def close(self):
self._reader.cancel() self._reader.cancel()
await self.ws.close() await self.ws.close()
@@ -90,3 +102,38 @@ async def test_status_cpu_display(client):
reply = await wait_reply(client) reply = await wait_reply(client)
# Linux 风格总占用 + 核心 # Linux 风格总占用 + 核心
assert "核心" in reply and "/" in reply assert "核心" in reply and "/" in reply
async def _expect_no_reply(client, send, timeout: float = 3.0):
"""发送后确认机器人在超时内没有回复。"""
before = len(client.replies)
await send()
for _ in range(int(timeout * 10)):
if len(client.replies) > before:
raise AssertionError(f"不应回复但收到了: {client.replies[-1]}")
await asyncio.sleep(0.1)
@pytest.mark.asyncio
async def test_private_non_command_ignored(client):
# 私聊发普通消息/无文本消息不触发
await _expect_no_reply(client, lambda: client.send_message("今天天气怎么样"))
await _expect_no_reply(
client, lambda: client.send_message(
[{"type": "image", "data": {"file": "abc.jpg"}}]))
@pytest.mark.asyncio
async def test_group_requires_at_me(client):
# 群聊不 @ 不响应
await _expect_no_reply(client, lambda: client.send_group_message("/status"))
# @ 别人不响应
await _expect_no_reply(client, lambda: client.send_group_message(
[{"type": "at", "data": {"qq": "66666"}},
{"type": "text", "data": {"text": " /status"}}]))
# @ 本人才响应
await client.send_group_message(
[{"type": "at", "data": {"qq": "88888"}},
{"type": "text", "data": {"text": " /status"}}])
reply = await wait_reply(client)
assert "服务器状态" in reply