fix: restrict message commands to channel admins

This commit is contained in:
jxxghp
2026-05-24 08:00:54 +08:00
parent 0b7854a0af
commit ec588037a0
11 changed files with 929 additions and 31 deletions

View File

@@ -102,6 +102,52 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def _get_admins(config: Optional[dict]) -> List[str]:
"""
解析 Discord 管理员配置,兼容逗号分隔和首尾空白。
"""
return [
admin.strip()
for admin in str((config or {}).get("DISCORD_ADMINS") or "").split(",")
if admin.strip()
]
@classmethod
def _should_reject_admin_command(
cls,
config: Optional[dict],
*user_ids: Optional[Union[str, int]],
) -> bool:
"""
判断 Discord 命令或命令型按钮回调是否应因非管理员身份被拒绝。
"""
admins = cls._get_admins(config)
if not admins:
return False
candidates = [
str(user_id).strip()
for user_id in user_ids
if user_id is not None and str(user_id).strip()
]
return not any(candidate in admins for candidate in candidates)
@staticmethod
def _send_admin_denied(
client: Optional[Discord],
userid: Optional[Union[str, int]],
chat_id: Optional[Union[str, int]] = None,
) -> None:
"""
向 Discord 非管理员用户发送命令拒绝提示。
"""
if client and userid:
client.send_msg(
title="只有管理员才有权限执行此命令",
userid=str(userid),
original_chat_id=str(chat_id) if chat_id else None,
)
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]:
@@ -119,6 +165,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
client_config = self.get_config(source)
if not client_config:
return None
client: Discord = self.get_instance(client_config.name)
try:
msg_json: dict = json.loads(body)
except Exception as e:
@@ -137,6 +184,11 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
message_id = msg_json.get("message_id")
chat_id = msg_json.get("chat_id")
if callback_data and userid:
if str(callback_data).strip().startswith("/") and self._should_reject_admin_command(
client_config.config, userid, username
):
self._send_admin_denied(client, userid, chat_id)
return None
logger.info(
f"收到来自 {client_config.name} 的 Discord 按钮回调:"
f"userid={userid}, username={username}, callback_data={callback_data}"
@@ -161,6 +213,11 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
audio_refs = self._extract_audio_refs(msg_json)
files = self._extract_files(msg_json)
if (text or images or audio_refs or files) and userid:
if text and text.startswith("/") and self._should_reject_admin_command(
client_config.config, userid, username
):
self._send_admin_denied(client, userid, chat_id)
return None
logger.info(
f"收到来自 {client_config.name} 的 Discord 消息:"
f"userid={userid}, username={username}, text={text}, "

View File

@@ -5,7 +5,7 @@ import tempfile
import threading
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urlparse
import lark_oapi as lark
@@ -107,6 +107,19 @@ class Feishu:
self._api_client = self._build_api_client()
self._start_ws_client()
def _should_reject_admin_command(
self, *user_ids: Optional[Union[str, int]]
) -> bool:
"""判断飞书命令或命令型按钮回调是否应因非管理员身份被拒绝。"""
if not self._admins:
return False
candidates = [
str(user_id).strip()
for user_id in user_ids
if user_id is not None and str(user_id).strip()
]
return not any(candidate in self._admins for candidate in candidates)
def _build_api_client(self) -> lark.Client:
"""构建飞书 OpenAPI client用于发送和编辑消息。"""
return (
@@ -494,6 +507,16 @@ class Feishu:
callback_data = message.get("callback_data")
if not callback_data:
return None
if str(callback_data).strip().startswith("/") and self._should_reject_admin_command(
open_id, user_id
):
self.send_text(
"只有管理员才有权限执行此命令",
userid=str(userid),
chat_id=message.get("chat_id"),
receive_id_type="open_id" if open_id else "user_id",
)
return None
return CommingMessage(
channel=MessageChannel.Feishu,
source=self._name,
@@ -522,7 +545,7 @@ class Feishu:
if not text and not images and not audio_refs and not files:
return None
if text.startswith("/") and self._admins and str(userid) not in self._admins:
if text.startswith("/") and self._should_reject_admin_command(open_id, user_id):
self.send_text(
"只有管理员才有权限执行此命令",
userid=str(userid),

View File

@@ -82,6 +82,46 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def _get_admins(config: Optional[dict]) -> List[str]:
"""
解析 QQ 管理员配置,兼容逗号分隔和首尾空白。
"""
return [
admin.strip()
for admin in str((config or {}).get("QQBOT_ADMINS") or "").split(",")
if admin.strip()
]
@classmethod
def _should_reject_admin_command(
cls,
config: Optional[dict],
*user_ids: Optional[Union[str, int]],
) -> bool:
"""
判断 QQ 斜杠命令是否应因非管理员身份被拒绝。
"""
admins = cls._get_admins(config)
if not admins:
return False
candidates = [
str(user_id).strip()
for user_id in user_ids
if user_id is not None and str(user_id).strip()
]
return not any(candidate in admins for candidate in candidates)
@staticmethod
def _send_admin_denied(
client: Optional[QQBot], userid: Optional[Union[str, int]]
) -> None:
"""
向 QQ 非管理员用户发送命令拒绝提示。
"""
if client and userid:
client.send_msg(title="只有管理员才有权限执行此命令", userid=str(userid))
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]:
@@ -92,6 +132,7 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
client_config = self.get_config(source)
if not client_config:
return None
client: QQBot = self.get_instance(client_config.name)
try:
if isinstance(body, bytes):
msg_body = json.loads(body)
@@ -116,6 +157,11 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
user_openid = author.get("user_openid", "")
if not user_openid:
return None
if content.startswith("/") and self._should_reject_admin_command(
client_config.config, user_openid
):
self._send_admin_denied(client, user_openid)
return None
logger.info(
f"收到 QQ 私聊消息: userid={user_openid}, "
f"text={(content or '')[:50]}..., images={len(images) if images else 0}, "
@@ -137,6 +183,11 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
group_openid = msg_body.get("group_openid", "")
# 群聊用 group:group_openid 作为 userid便于回复时识别
userid = f"group:{group_openid}" if group_openid else member_openid
if content.startswith("/") and self._should_reject_admin_command(
client_config.config, member_openid, userid
):
self._send_admin_denied(client, userid)
return None
logger.info(
f"收到 QQ 群消息: group={group_openid}, userid={member_openid}, "
f"text={(content or '')[:50]}..., images={len(images) if images else 0}, "

View File

@@ -82,6 +82,44 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def _get_admins(config: Optional[dict]) -> List[str]:
"""
解析 Slack 管理员配置,兼容逗号分隔和首尾空白。
"""
return [
admin.strip()
for admin in str((config or {}).get("SLACK_ADMINS") or "").split(",")
if admin.strip()
]
@classmethod
def _should_reject_admin_command(
cls,
config: Optional[dict],
*user_ids: Optional[Union[str, int]],
) -> bool:
"""
判断 Slack 命令或命令型按钮回调是否应因非管理员身份被拒绝。
"""
admins = cls._get_admins(config)
if not admins:
return False
candidates = [
str(user_id).strip()
for user_id in user_ids
if user_id is not None and str(user_id).strip()
]
return not any(candidate in admins for candidate in candidates)
@staticmethod
def _send_admin_denied(client: Optional[Slack], userid: Optional[Union[str, int]]) -> None:
"""
向 Slack 非管理员用户发送命令拒绝提示。
"""
if client and userid:
client.send_msg(title="只有管理员才有权限执行此命令", userid=str(userid))
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]:
@@ -209,6 +247,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
client_config = self.get_config(source)
if not client_config:
return None
client: Slack = self.get_instance(client_config.name)
try:
msg_json = json.loads(body)
while isinstance(msg_json, str):
@@ -229,6 +268,11 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
userid = msg_json.get("user")
text = msg_json.get("text")
username = msg_json.get("user")
if text and text.startswith("/") and self._should_reject_admin_command(
client_config.config, userid, username
):
self._send_admin_denied(client, userid)
return None
message_id = msg_json.get("ts")
chat_id = msg_json.get("channel")
images = self._extract_images(msg_json)
@@ -240,6 +284,11 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
# 使用CALLBACK前缀标识按钮回调
text = f"CALLBACK:{callback_data}"
username = msg_json.get("user", {}).get("name")
if str(callback_data).strip().startswith("/") and self._should_reject_admin_command(
client_config.config, userid, username
):
self._send_admin_denied(client, userid)
return None
# 获取原消息信息用于编辑
message_info = msg_json.get("message", {})
@@ -275,6 +324,11 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
flags=re.IGNORECASE,
).strip()
username = ""
if text and text.startswith("/") and self._should_reject_admin_command(
client_config.config, userid
):
self._send_admin_denied(client, userid)
return None
message_id = msg_json.get("event", {}).get("ts")
chat_id = msg_json.get("event", {}).get("channel")
images = self._extract_images(msg_json.get("event", {}))
@@ -284,11 +338,19 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
userid = msg_json.get("user", {}).get("id")
text = msg_json.get("callback_id")
username = msg_json.get("user", {}).get("username")
if text and text.startswith("/") and self._should_reject_admin_command(
client_config.config, userid, username
):
self._send_admin_denied(client, userid)
return None
elif msg_json.get("command"):
userid = msg_json.get("user_id")
text = msg_json.get("command")
username = msg_json.get("user_name")
chat_id = msg_json.get("channel_id")
if self._should_reject_admin_command(client_config.config, userid, username):
self._send_admin_denied(client, userid)
return None
else:
return None
logger.info(

View File

@@ -88,6 +88,46 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def _get_admins(config: Optional[dict]) -> List[str]:
"""
解析 Synology Chat 管理员配置,兼容逗号分隔和首尾空白。
"""
return [
admin.strip()
for admin in str((config or {}).get("SYNOLOGYCHAT_ADMINS") or "").split(",")
if admin.strip()
]
@classmethod
def _should_reject_admin_command(
cls,
config: Optional[dict],
*user_ids: Optional[Union[str, int]],
) -> bool:
"""
判断 Synology Chat 斜杠命令是否应因非管理员身份被拒绝。
"""
admins = cls._get_admins(config)
if not admins:
return False
candidates = [
str(user_id).strip()
for user_id in user_ids
if user_id is not None and str(user_id).strip()
]
return not any(candidate in admins for candidate in candidates)
@staticmethod
def _send_admin_denied(
client: Optional[SynologyChat], userid: Optional[Union[str, int]]
) -> None:
"""
向 Synology Chat 非管理员用户发送命令拒绝提示。
"""
if client and userid:
client.send_msg(title="只有管理员才有权限执行此命令", userid=str(userid))
def message_parser(self, source: str, body: Any, form: Any,
args: Any) -> Optional[CommingMessage]:
"""
@@ -127,6 +167,11 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
audio_refs = self._extract_audio_refs(message)
files = self._extract_files(message)
if (text or images or audio_refs or files) and user_id:
if text and text.startswith("/") and self._should_reject_admin_command(
client_config.config, user_id, user_name
):
self._send_admin_denied(client, user_id)
return None
logger.info(
f"收到来自 {client_config.name} 的SynologyChat消息"
f"userid={user_id}, username={user_name}, text={text}, "

View File

@@ -77,6 +77,36 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def _get_admins(config: Optional[dict]) -> List[str]:
"""
解析 Telegram 管理员配置,兼容逗号分隔和首尾空白。
"""
return [
admin.strip()
for admin in str((config or {}).get("TELEGRAM_ADMINS") or "").split(",")
if admin.strip()
]
@classmethod
def _should_reject_admin_command(
cls,
config: Optional[dict],
*user_ids: Optional[Union[str, int]],
) -> bool:
"""
判断 Telegram 命令或命令型按钮回调是否应因非管理员身份被拒绝。
"""
admins = cls._get_admins(config)
if not admins:
return False
candidates = [
str(user_id).strip()
for user_id in user_ids
if user_id is not None and str(user_id).strip()
]
return not any(candidate in admins for candidate in candidates)
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]:
@@ -149,16 +179,15 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
if message:
# 处理按钮回调
if "callback_query" in message:
return self._handle_callback_query(message, client_config)
return self._handle_callback_query(message, client_config, client)
# 处理普通消息
return self._handle_text_message(message, client_config, client)
return None
@staticmethod
def _handle_callback_query(
message: dict, client_config: NotificationConf
self, message: dict, client_config: NotificationConf, client: Telegram
) -> Optional[CommingMessage]:
"""
处理按钮回调查询
@@ -170,6 +199,17 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
user_name = user_info.get("username")
if callback_data and user_id:
if str(callback_data).strip().startswith("/") and self._should_reject_admin_command(
client_config.config, user_id, user_name
):
if client:
client.answer_callback_query(
callback_query_id=callback_query.get("id"),
text="只有管理员才有权限执行此命令",
show_alert=True,
)
return None
logger.info(
f"收到来自 {client_config.name} 的Telegram按钮回调"
f"userid={user_id}, username={user_name}, callback_data={callback_data}"
@@ -237,16 +277,10 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
else None
)
admin_users = client_config.config.get("TELEGRAM_ADMINS")
user_list = client_config.config.get("TELEGRAM_USERS")
config_chat_id = client_config.config.get("TELEGRAM_CHAT_ID")
if cleaned_text and cleaned_text.startswith("/"):
if (
admin_users
and str(user_id) not in admin_users.split(",")
and str(user_id) != config_chat_id
):
if self._should_reject_admin_command(client_config.config, user_id, user_name):
client.send_msg(
title="只有管理员才有权限执行此命令", userid=user_id
)

View File

@@ -87,6 +87,46 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def _get_admins(config: Optional[dict]) -> List[str]:
"""
解析 VoceChat 管理员配置,兼容逗号分隔和首尾空白。
"""
return [
admin.strip()
for admin in str((config or {}).get("VOCECHAT_ADMINS") or "").split(",")
if admin.strip()
]
@classmethod
def _should_reject_admin_command(
cls,
config: Optional[dict],
*user_ids: Optional[Union[str, int]],
) -> bool:
"""
判断 VoceChat 斜杠命令是否应因非管理员身份被拒绝。
"""
admins = cls._get_admins(config)
if not admins:
return False
candidates = [
str(user_id).strip()
for user_id in user_ids
if user_id is not None and str(user_id).strip()
]
return not any(candidate in admins for candidate in candidates)
@staticmethod
def _send_admin_denied(
client: Optional[VoceChat], userid: Optional[Union[str, int]]
) -> None:
"""
向 VoceChat 非管理员用户发送命令拒绝提示。
"""
if client and userid:
client.send_msg(title="只有管理员才有权限执行此命令", userid=str(userid))
def message_parser(self, source: str, body: Any, form: Any,
args: Any) -> Optional[CommingMessage]:
"""
@@ -120,6 +160,7 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
client_config = self.get_config(source)
if not client_config:
return None
client: VoceChat = self.get_instance(client_config.name)
# 报文体
msg_body = json.loads(body)
# 类型
@@ -149,6 +190,11 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]):
# 处理消息内容
if (text or images or audio_refs or files) and userid:
if text and text.startswith("/") and self._should_reject_admin_command(
client_config.config, msg_body.get("from_uid"), userid
):
self._send_admin_denied(client, userid)
return None
logger.info(
f"收到来自 {client_config.name} 的VoceChat消息"
f"userid={userid}, text={text}, images={len(images) if images else 0}, "

View File

@@ -66,6 +66,29 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
def _is_bot_mode(config: dict) -> bool:
return (config or {}).get("WECHAT_MODE", "app") == "bot"
@staticmethod
def _get_admins(config: Optional[dict]) -> List[str]:
"""
解析企业微信管理员配置,兼容逗号分隔和首尾空白。
"""
return [
admin.strip()
for admin in str((config or {}).get("WECHAT_ADMINS") or "").split(",")
if admin.strip()
]
@classmethod
def _should_reject_admin_command(
cls, config: Optional[dict], user_id: Optional[str]
) -> bool:
"""
判断企业微信菜单或斜杠命令是否应因非管理员身份被拒绝。
"""
admins = cls._get_admins(config)
if not admins:
return False
return str(user_id or "").strip() not in admins
@classmethod
def _create_client(cls, conf):
if cls._is_bot_mode(conf.config):
@@ -171,13 +194,10 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
audio_refs = None
files = None
if msg_type == "event" and event == "click":
# 校验用户有权限执行交互命令
if client_config.config.get('WECHAT_ADMINS'):
wechat_admins = client_config.config.get('WECHAT_ADMINS').split(',')
if wechat_admins and not any(
user_id == admin_user for admin_user in wechat_admins):
client.send_msg(title="用户无权限执行菜单命令", userid=user_id)
return None
# 企业微信菜单最终会转成命令文本,需与斜杠命令使用一致的管理员校验。
if self._should_reject_admin_command(client_config.config, user_id):
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
return None
# 根据EventKey执行命令
content = DomUtils.tag_value(root_node, "EventKey")
logger.info(f"收到来自 {client_config.name} 的微信事件userid={user_id}, event={content}")
@@ -221,6 +241,12 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
else:
return None
if content and content.startswith("/") and self._should_reject_admin_command(
client_config.config, user_id
):
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
return None
if content or images or audio_refs or files:
# 处理消息内容
return CommingMessage(channel=MessageChannel.Wechat, source=client_config.name,
@@ -274,17 +300,13 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
if text:
text = re.sub(r"@\S+", "", text).strip()
if text and text.startswith("/") and client_config.config.get('WECHAT_ADMINS'):
wechat_admins = [
admin.strip()
for admin in client_config.config.get('WECHAT_ADMINS', '').split(',')
if admin.strip()
]
if wechat_admins and sender not in wechat_admins:
client: WeChatBot = self.get_instance(client_config.name)
if client:
client.send_msg(title="只有管理员才有权限执行此命令", userid=sender)
return None
if text and text.startswith("/") and self._should_reject_admin_command(
client_config.config, sender
):
client: WeChatBot = self.get_instance(client_config.name)
if client:
client.send_msg(title="只有管理员才有权限执行此命令", userid=sender)
return None
if not text and not images and not audio_refs and not files:
return None

View File

@@ -181,7 +181,9 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
for admin in str(client_config.config.get("WECHATCLAWBOT_ADMINS") or "").split(",")
if admin.strip()
]
if text.startswith("/") and admins and user_id not in admins:
callback_data = text[9:].strip() if text.startswith("CALLBACK:") else ""
is_admin_command = text.startswith("/") or callback_data.startswith("/")
if is_admin_command and admins and user_id not in admins:
client = self.get_instance(client_config.name)
if client:
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)