fix: require @bot in groups, verify at target, ignore non-command and textless messages
This commit is contained in:
1 parent
f26f34cb87
commit
e39d1a0f52
2 files changed
+119
-25
No files matched your search
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user