ForcePilot/backend/package/yuxi/channels/adapters/signal/security.py
Kris 8dc86766f1 feat(channels/signal): 新增Signal渠道适配器完整实现
新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
2026-05-12 00:48:25 +08:00

215 lines
8.0 KiB
Python

from enum import StrEnum
import logging
from collections.abc import Awaitable, Callable
from yuxi.channels.models import ChannelMessage
logger = logging.getLogger(__name__)
class DmPolicy(StrEnum):
PAIRING = "pairing"
ALLOWLIST = "allowlist"
OPEN = "open"
DISABLED = "disabled"
class GroupPolicy(StrEnum):
OPEN = "open"
ALLOWLIST = "allowlist"
DISABLED = "disabled"
class ReactionNotificationPolicy(StrEnum):
OFF = "off"
OWN = "own"
ALLOWLIST = "allowlist"
ALL = "all"
class SignalSecurityPolicy:
def __init__(
self,
dm_policy: str = "pairing",
group_policy: str = "allowlist",
allow_from: list[str] | None = None,
group_allow_from: list[str] | None = None,
require_mention: bool = False,
reaction_notifications: str = "all",
reaction_allowlist: list[str] | None = None,
command_double_auth: bool = True,
):
self.dm_policy = DmPolicy(dm_policy)
self.group_policy = GroupPolicy(group_policy)
self._dm_allowlist: set[str] = set(self._expand_allowlist(allow_from or []))
self._group_allowlist: set[str] = set(group_allow_from or [])
self._pairing_pending: set[str] = set()
self.require_mention = require_mention
self.reaction_notifications = ReactionNotificationPolicy(reaction_notifications)
self._reaction_allowlist: set[str] = set(reaction_allowlist or [])
self._pairing_challenge_pending: set[str] = set()
self._command_double_auth = command_double_auth
self._store_write_fn: Callable[[str, dict], Awaitable[None]] | None = None
self._store_read_fn: Callable[[str], Awaitable[dict | None]] | None = None
@staticmethod
def _expand_allowlist(entries: list[str]) -> list[str]:
result = []
for entry in entries:
stripped = entry.strip()
if stripped.startswith("signal:"):
stripped = stripped.removeprefix("signal:")
result.append(stripped)
return result
def _is_wildcard_match(self, user_id: str) -> bool:
return "*" in self._dm_allowlist
def check_dm_permission(self, message: ChannelMessage) -> bool:
user_id = message.identity.channel_user_id
match self.dm_policy:
case DmPolicy.DISABLED:
logger.info(f"[Signal Security] DM denied (policy=disabled, user={user_id})")
return False
case DmPolicy.OPEN:
return True
case DmPolicy.ALLOWLIST:
allowed = user_id in self._dm_allowlist or self._is_wildcard_match(user_id)
if not allowed:
logger.info(f"[Signal Security] DM denied (policy=allowlist, user={user_id})")
return allowed
case DmPolicy.PAIRING:
if user_id in self._dm_allowlist or self._is_wildcard_match(user_id):
return True
self._pairing_pending.add(user_id)
logger.info(f"[Signal Security] DM denied (policy=pairing, user={user_id}, pending)")
return False
case _:
logger.info(f"[Signal Security] DM denied (policy=unknown, user={user_id})")
return False
def check_group_permission(self, message: ChannelMessage) -> bool:
group_id = message.identity.channel_chat_id
match self.group_policy:
case GroupPolicy.DISABLED:
logger.info(f"[Signal Security] Group denied (policy=disabled, group={group_id})")
return False
case GroupPolicy.OPEN:
return True
case GroupPolicy.ALLOWLIST:
allowed = group_id in self._group_allowlist
if not allowed:
logger.info(f"[Signal Security] Group denied (policy=allowlist, group={group_id})")
return allowed
case _:
logger.info(f"[Signal Security] Group denied (policy=unknown, group={group_id})")
return False
def check_require_mention(self, message: ChannelMessage) -> bool:
if not self.require_mention:
return True
if message.chat_type.value != "group":
return True
if message.mentions and message.mentions.is_bot_mentioned:
return True
return False
def check_reaction_notification(self, user_id: str) -> bool:
match self.reaction_notifications:
case ReactionNotificationPolicy.OFF:
return False
case ReactionNotificationPolicy.OWN:
return False
case ReactionNotificationPolicy.ALLOWLIST:
return user_id in self._reaction_allowlist
case ReactionNotificationPolicy.ALL:
return True
case _:
return True
def record_pairing_challenge(self, user_id: str) -> None:
self._pairing_challenge_pending.add(user_id)
def has_pairing_challenge(self, user_id: str) -> bool:
return user_id in self._pairing_challenge_pending
def approve_pairing(self, user_id: str) -> None:
self._dm_allowlist.add(user_id)
self._pairing_pending.discard(user_id)
def reject_pairing(self, user_id: str) -> None:
self._pairing_pending.discard(user_id)
@property
def pending_pairings(self) -> set[str]:
return self._pairing_pending.copy()
def add_to_allowlist(self, target_id: str, target_type: str = "dm") -> None:
if target_type == "dm":
self._dm_allowlist.add(target_id)
elif target_type == "group":
self._group_allowlist.add(target_id)
else:
raise ValueError(f"Unknown target_type: {target_type}, expected 'dm' or 'group'")
def remove_from_allowlist(self, target_id: str, target_type: str = "dm") -> None:
if target_type == "dm":
self._dm_allowlist.discard(target_id)
elif target_type == "group":
self._group_allowlist.discard(target_id)
else:
raise ValueError(f"Unknown target_type: {target_type}, expected 'dm' or 'group'")
def check_command_double_auth(self, message: ChannelMessage) -> bool:
if not self._command_double_auth:
return True
user_id = message.identity.channel_user_id
group_id = message.identity.channel_chat_id
dm_allowed = user_id in self._dm_allowlist or self._is_wildcard_match(user_id)
group_allowed = group_id in self._group_allowlist
if dm_allowed or group_allowed:
return True
logger.info(
f"[Signal Security] Command double-auth denied: user={user_id}, "
f"group={group_id}, dm_allowed={dm_allowed}, group_allowed={group_allowed}"
)
return False
def set_store_handlers(
self,
write_fn: Callable[[str, dict], Awaitable[None]],
read_fn: Callable[[str], Awaitable[dict | None]],
) -> None:
self._store_write_fn = write_fn
self._store_read_fn = read_fn
async def approve_pairing(self, user_id: str) -> None:
self._dm_allowlist.add(user_id)
self._pairing_pending.discard(user_id)
if self._store_write_fn:
try:
await self._store_write_fn(
f"signal:pairing:dm:{user_id}",
{"user_id": user_id, "approved_at": __import__("time").time()},
)
except Exception:
logger.exception("Failed to persist pairing approval to store")
async def load_pairing_store(self) -> None:
if not self._store_read_fn:
return
try:
data = await self._store_read_fn("signal:pairing:dm:*")
if data and isinstance(data, dict):
for key, value in data.items():
user_id = value.get("user_id") if isinstance(value, dict) else str(value)
if user_id:
self._dm_allowlist.add(user_id)
except Exception:
logger.exception("Failed to load pairing store")