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

993 lines
41 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import random
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any, ClassVar
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
HealthStatus,
MessageType,
)
from yuxi.channels.adapters.signal.accounts import (
list_enabled_signal_accounts,
)
from yuxi.channels.adapters.signal.client import RpcClient, RpcError
from yuxi.channels.adapters.signal.daemon import SignalDaemonManager
from yuxi.channels.adapters.signal.directory import list_groups, list_peers
from yuxi.channels.adapters.signal.entity_cache import EntityCache
from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue
from yuxi.channels.adapters.signal.exec_auth import ExecAuthAdapter
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
from yuxi.channels.adapters.signal.identity import get_identities
from yuxi.channels.adapters.signal.media_vision import MediaVisionAnalyzer
from yuxi.channels.adapters.signal.monitor import SSEMonitor
from yuxi.channels.adapters.signal.normalize import (
parse_signal_delete,
parse_signal_message,
parse_signal_reaction,
parse_signal_receipt,
)
from yuxi.channels.adapters.signal.reaction_level import ReactionLevelController
from yuxi.channels.adapters.signal.send import SignalSender
from yuxi.channels.adapters.signal.security import SignalSecurityPolicy
logger = logging.getLogger(__name__)
STREAM_BUFFER_MAX_ENTRIES = 100
STREAM_BUFFER_TTL = 300
DAEMON_STARTUP_TIMEOUT_MS = 30000
class SignalChannel(BaseChannelAdapter):
channel_id: ClassVar[str] = "signal"
channel_type: ClassVar[ChannelType] = ChannelType.SIGNAL
text_chunk_limit: ClassVar[int] = 4000
supports_markdown: ClassVar[bool] = True
supports_streaming: ClassVar[bool] = True
streaming_modes: ClassVar[list[str]] = ["off", "block", "progress"]
max_media_size_mb: ClassVar[int] = 100
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._daemon: SignalDaemonManager | None = None
self._rpc_client: RpcClient | None = None
self._sender: SignalSender | None = None
self._monitor: SSEMonitor | None = None
self._account_number: str = ""
self._account_uuid: str | None = None
self._supports_edit: bool = False
self._stream_buffer: dict[str, tuple[str, float]] = {}
self._security: SignalSecurityPolicy | None = None
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
self._enabled = self.config.get("enabled", True)
text_chunk_limit_cfg = self.config.get("text_chunk_limit")
if text_chunk_limit_cfg is not None:
self.text_chunk_limit = int(text_chunk_limit_cfg) # type: ignore[assignment]
media_max_mb_cfg = self.config.get("media_max_mb")
if media_max_mb_cfg is not None:
self.max_media_size_mb = int(media_max_mb_cfg) # type: ignore[assignment]
coalesce_cfg = self.config.get("block_streaming_coalesce", {})
self._stream_coalesce_min_chars: int = coalesce_cfg.get("min_chars", 1500)
self._stream_coalesce_idle_ms: int = coalesce_cfg.get("idle_ms", 1000)
reaction_level = self.config.get("reaction_level", "minimal")
self._reaction_controller = ReactionLevelController(reaction_level)
self._state_change_handlers: list[Callable[[ChannelStatus], Awaitable[None]]] = []
self._history_store: dict[str, list[str]] = {}
self._account_instances: list[dict] = []
entity_cache_ttl = self.config.get("entity_cache_ttl", 600)
self._entity_cache = EntityCache(ttl=entity_cache_ttl)
self._event_queue = OrderedEventQueue(
max_size=self.config.get("event_queue_max_size", 1000),
)
self._exec_auth = ExecAuthAdapter(
auto_approve=self.config.get("exec_auth", {}).get("auto_approve", False),
)
self._main_dm_owner_pin = self.config.get("main_dm_owner_pin")
self._ingest_enabled = self.config.get("ingest", {}).get("enabled", False)
self._audit_enabled = self.config.get("audit", {}).get("enabled", False)
ai_vision_cfg = self.config.get("ai_vision", {})
self._ai_vision_enabled = ai_vision_cfg.get("enabled", False)
self._lane_separator = self.config.get("lane_separator", "---")
self._warned_once: set[str] = set()
self._vision_analyzer = MediaVisionAnalyzer(
llm_call_fn=self.config.get("vision_llm_call"),
enabled=self._ai_vision_enabled,
)
def _warn_once(self, key: str, message: str) -> None:
if key not in self._warned_once:
self._warned_once.add(key)
logger.warning(f"[Signal] {message}")
def on_state_change(self, handler: Callable[[ChannelStatus], Awaitable[None]]) -> None:
self._state_change_handlers.append(handler)
async def _notify_state_change(self, new_status: ChannelStatus) -> None:
for handler in self._state_change_handlers:
try:
await handler(new_status)
except Exception:
logger.exception("State change handler failed")
async def _on_daemon_crash(self, exit_code: int, msg: str) -> None:
logger.error(f"[Signal] Daemon crashed (exit_code={exit_code}), attempting restart")
self._status = ChannelStatus.RECONNECTING
await self._notify_state_change(ChannelStatus.RECONNECTING)
try:
if self._daemon:
await self._daemon.restart()
self._status = ChannelStatus.CONNECTED
await self._notify_state_change(ChannelStatus.CONNECTED)
except Exception:
logger.exception("[Signal] Daemon restart failed")
self._status = ChannelStatus.ERROR
await self._notify_state_change(ChannelStatus.ERROR)
async def connect(self) -> None:
if not self._enabled:
self._status = ChannelStatus.DISABLED
logger.info(f"[Signal] Channel '{self.config.get('name', self.channel_id)}' is disabled, skipping connect")
return
auto_start = self.config.get("auto_start", True)
if not auto_start:
logger.info(f"[Signal] auto_start is disabled for '{self.config.get('name', self.channel_id)}'")
self._status = ChannelStatus.DISABLED
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[Signal] Starting channel '{self.config.get('name', self.channel_id)}'")
accounts = list_enabled_signal_accounts(self.config)
if not accounts:
raise ValueError("No enabled Signal accounts found in config")
self._account_instances = []
startup_tasks = []
for account_cfg in accounts:
task = self._connect_account(account_cfg)
startup_tasks.append(task)
results = await asyncio.gather(*startup_tasks, return_exceptions=True)
connected = 0
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(f"[Signal] Account '{accounts[i].get('_account_id')}' failed to connect: {result}")
else:
connected += 1
if connected == 0:
self._status = ChannelStatus.ERROR
raise RuntimeError("All Signal accounts failed to connect")
first = self._account_instances[0]
self._daemon = first["daemon"]
self._rpc_client = first["rpc_client"]
self._sender = first["sender"]
self._monitor = first["monitor"]
self._account_number = first["number"]
self._account_uuid = first["uuid"]
self._supports_edit = first["supports_edit"]
self._security = first["security"]
self._status = ChannelStatus.CONNECTED
logger.info(f"[Signal] Channel connected ({connected}/{len(accounts)} accounts)")
block_streaming = self.config.get("block_streaming", False)
streaming_mode = self.config.get("streaming_mode", "block")
if block_streaming and streaming_mode != "off":
logger.warning(
f"[Signal] block_streaming=true but streaming_mode='{streaming_mode}'; "
"streaming chunks will be sent immediately without coalescing"
)
async def _connect_account(self, account_cfg: dict) -> None:
signal_number = account_cfg.get("signal_number", "")
if not signal_number:
raise ValueError("signal_number is required in account config")
cli_path = account_cfg.get("cli_path", "signal-cli")
http_host = account_cfg.get("http_host")
http_port = account_cfg.get("http_port")
http_listen = account_cfg.get("http_listen") or (
f"{http_host or '127.0.0.1'}:{http_port or 8080}" if http_host or http_port else "127.0.0.1:8080"
)
home_dir = account_cfg.get("home_dir")
java_opts = account_cfg.get("java_opts", "-Xmx256m")
receive_mode = account_cfg.get("receive_mode")
send_read_receipts = account_cfg.get("send_read_receipts")
receive_mode_daemon_level = self.config.get("receive_mode_daemon_level", "auto")
if receive_mode_daemon_level == "manual":
send_read_receipts = False
elif receive_mode_daemon_level == "on-start":
send_read_receipts = True
account_uuid = account_cfg.get("account_uuid")
allow_remote_daemon = account_cfg.get("allow_remote_daemon", False)
startup_timeout = account_cfg.get("startup_timeout_ms", DAEMON_STARTUP_TIMEOUT_MS)
daemon_startup_retries = max(1, startup_timeout // 1000)
daemon_ready_cfg = self.config.get("daemon_ready", {})
daemon = SignalDaemonManager(
cli_path=cli_path,
account=signal_number,
http_listen=http_listen,
home_dir=home_dir,
java_opts=java_opts,
receive_mode=receive_mode,
send_read_receipts=send_read_receipts,
daemon_startup_retries=daemon_startup_retries,
poll_interval_ms=daemon_ready_cfg.get("poll_interval_ms", 150),
log_after_ms=daemon_ready_cfg.get("log_after_ms", 10000),
log_interval_ms=daemon_ready_cfg.get("log_interval_ms", 10000),
)
daemon.on_crash(self._on_daemon_crash)
await daemon.start()
base_url = f"http://{daemon.listen_addr}"
http_url = self.config.get("http_url")
if http_url:
base_url = http_url.rstrip("/")
rpc_client = RpcClient(
base_url,
timeout_ms=account_cfg.get("rpc_timeout_ms", 30000),
allow_remote_daemon=allow_remote_daemon,
)
await rpc_client.connect()
await rpc_client.call("version")
sender = SignalSender(rpc_client, signal_number)
supports_edit = await self._detect_edit_support_for(rpc_client)
security_cfg = self.config.get("security", {})
require_mention = security_cfg.get("require_mention", False)
group_policy = security_cfg.get("group_policy") or self.config.get("group_policy") or "allowlist"
if not security_cfg.get("group_policy") and not self.config.get("group_policy"):
self._warn_once(
"missing_group_policy_fallback",
"group_policy not set in security config or channels.signal; falling back to 'allowlist'",
)
security = SignalSecurityPolicy(
dm_policy=security_cfg.get("dm_policy", "pairing"),
group_policy=group_policy,
allow_from=security_cfg.get("allow_from", []),
group_allow_from=security_cfg.get("group_allow_from", []),
require_mention=require_mention,
command_double_auth=self.config.get("command_double_auth", True),
)
monitor = SSEMonitor(
rpc_client,
signal_number,
account_uuid=account_uuid,
ignore_attachments=self.config.get("ignore_attachments", False),
ignore_stories=self.config.get("ignore_stories", False),
debounce_interval_ms=self.config.get("debounce_interval_ms", 0),
duplicate_reaction_check=self.config.get("duplicate_reaction_check", True),
event_queue=self._event_queue,
sent_message_cache=self._sent_messages,
)
monitor.on_message(self._handle_message)
await monitor.start()
self._account_instances.append(
{
"daemon": daemon,
"rpc_client": rpc_client,
"sender": sender,
"monitor": monitor,
"number": signal_number,
"uuid": account_uuid,
"supports_edit": supports_edit,
"security": security,
}
)
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[Signal] Stopping channel '{self.config.get('name', self.channel_id)}'")
for instance in self._account_instances:
if instance.get("monitor"):
await instance["monitor"].stop()
if instance.get("rpc_client"):
await instance["rpc_client"].disconnect()
if instance.get("daemon"):
await instance["daemon"].stop()
self._account_instances = []
self._monitor = None
self._rpc_client = None
self._daemon = None
self._stream_buffer.clear()
self._status = ChannelStatus.DISCONNECTED
MEDIA_MESSAGE_TYPES: ClassVar[set[MessageType]] = {
MessageType.IMAGE,
MessageType.VIDEO,
MessageType.AUDIO,
MessageType.FILE,
}
_MTYPE_TO_MEDIA: ClassVar[dict[MessageType, str]] = {
MessageType.IMAGE: "image",
MessageType.VIDEO: "video",
MessageType.AUDIO: "audio",
MessageType.FILE: "file",
}
@staticmethod
def _media_placeholder(mime_type: str) -> str:
if mime_type.startswith("image/"):
return "<media:image>"
if mime_type.startswith("video/"):
return "<media:video>"
if mime_type.startswith("audio/"):
return "<media:audio>"
return "<media:file>"
def _resolve_media_max_bytes(self, opts_max_bytes: int | None = None) -> int:
if opts_max_bytes is not None and opts_max_bytes > 0:
return opts_max_bytes
account_cfg_max_mb = self.config.get("media_max_mb")
if account_cfg_max_mb is not None:
return int(account_cfg_max_mb) * 1024 * 1024
return self.max_media_size_mb * 1024 * 1024
async def _call_with_cb(self, fn, *args, **kwargs) -> DeliveryResult:
async def _call():
return await fn(*args, **kwargs)
try:
return await self._circuit_breaker.call(_call)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open for Signal")
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
chat_id = response.identity.channel_chat_id
if not chat_id:
chat_id = self.config.get("default_to", "")
if not chat_id:
return DeliveryResult(success=False, error="No target chat_id and no default_to configured")
async def _do_send():
human_delay_cfg = self.config.get("human_delay", {})
if human_delay_cfg.get("enabled", False):
min_ms = human_delay_cfg.get("min_ms", 300)
max_ms = human_delay_cfg.get("max_ms", 1500)
if max_ms > min_ms:
delay_ms = random.randint(min_ms, max_ms)
await asyncio.sleep(delay_ms / 1000.0)
if response.message_type in self.MEDIA_MESSAGE_TYPES and response.attachments:
media_type = self._MTYPE_TO_MEDIA[response.message_type]
att = response.attachments[0]
caption = response.content or None
if caption is None and att.mime_type:
caption = self._media_placeholder(att.mime_type)
return await self.send_media(
chat_id=chat_id,
media_type=media_type,
data=att.url or b"",
filename=att.filename,
caption=caption,
)
formatted = self._build_formatted_body(response.content)
return await self._sender.send_text(
recipient=chat_id,
message_body=response.content,
reply_to_id=response.reply_to_message_id,
formatted_body=formatted,
chunk_mode=self.config.get("chunk_mode", "newline"),
)
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open for Signal send")
async def send_media(
self,
chat_id: str,
media_type: str,
data: Any,
filename: str | None = None,
caption: str | None = None,
) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
if isinstance(data, str):
import base64 as b64
data = b64.b64decode(data)
if not isinstance(data, bytes):
return DeliveryResult(success=False, error="Invalid media data type")
max_size = self._resolve_media_max_bytes()
if len(data) > max_size:
return DeliveryResult(
success=False,
error=f"Media size ({len(data)} bytes) exceeds limit ({self.max_media_size_mb}MB)",
)
return await self._call_with_cb(
self._sender.send_media,
recipient=chat_id,
media_data=data,
media_type=media_type,
filename=filename,
caption=caption,
)
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str, remove: bool = False) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
if not self._reaction_controller.should_send_reaction(chat_id, emoji):
return DeliveryResult(success=True)
return await self._call_with_cb(
self._sender.send_reaction,
recipient=chat_id,
target_author=self._account_number,
target_sent_timestamp=int(msg_id),
reaction=emoji,
remove=remove,
)
async def send_read_receipt(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
return await self._call_with_cb(
self._sender.send_read_receipt,
recipient=chat_id,
timestamps=[int(msg_id)],
)
async def send_chat_action(self, chat_id: str, action: str = "typing") -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
if action == "typing":
return await self._sender.send_typing_indicator(chat_id)
return DeliveryResult(success=True)
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
if not self._supports_edit:
return DeliveryResult(success=False, error="Edit not supported by current signal-cli version")
return await self._call_with_cb(
self._sender.edit_message,
recipient=chat_id,
target_author=self._account_number,
target_sent_timestamp=int(msg_id),
new_body=content,
)
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
return await self._call_with_cb(
self._sender.delete_message,
recipient=chat_id,
timestamps=[int(msg_id)],
)
async def send_sticker(self, chat_id: str, pack_id: str, sticker_id: int) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
return await self._call_with_cb(
self._sender.send_sticker,
recipient=chat_id,
sticker_pack_id=pack_id,
sticker_id=sticker_id,
)
async def send_silent_message(self, chat_id: str, content: str, reply_to_id: str | None = None) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
return await self._call_with_cb(
self._sender.send_silent_message,
recipient=chat_id,
message_body=content,
reply_to_id=reply_to_id,
)
async def pin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
return await self._call_with_cb(
self._sender.pin_message,
recipient=chat_id,
message_timestamp=int(msg_id),
)
async def unpin_message(self, chat_id: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
return await self._call_with_cb(
self._sender.unpin_message,
recipient=chat_id,
)
async def send_voice(self, chat_id: str, audio_data: bytes, duration_ms: int = 0) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
return await self._call_with_cb(
self._sender.send_voice,
recipient=chat_id,
audio_data=audio_data,
duration_ms=duration_ms,
)
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
if not self._sender or not self._rpc_client:
return DeliveryResult(success=False, error="Not initialized")
if "lane:reasoning:" in chunk:
return await self._handle_reasoning_chunk(chat_id, msg_id, chunk, finished)
block_streaming = self.config.get("block_streaming", False)
if block_streaming:
return await self._flush_stream(chat_id, msg_id, chunk, finished)
now = time.monotonic()
buffered, last_ts = self._stream_buffer.get(chat_id, ("", 0))
idle_ms = (now - last_ts) * 1000 if last_ts else 0
if buffered and idle_ms >= self._stream_coalesce_idle_ms:
flushed = await self._flush_stream(chat_id, msg_id, buffered, False)
if not flushed.success:
return flushed
buffered = ""
accumulated = buffered + chunk
if finished or len(accumulated) >= self._stream_coalesce_min_chars:
self._stream_buffer.pop(chat_id, None)
return await self._flush_stream(chat_id, msg_id, accumulated, finished)
self._stream_buffer[chat_id] = (accumulated, now)
self._prune_stream_buffer()
return DeliveryResult(success=True, message_id=msg_id)
async def _flush_stream(self, chat_id: str, msg_id: str, content: str, finished: bool) -> DeliveryResult:
if not msg_id:
result = await self._call_with_cb(self._sender.send_text, recipient=chat_id, message_body=content)
if not finished:
self._stream_buffer[chat_id] = (content, time.monotonic())
return result
if not finished:
if self._supports_edit:
return await self._call_with_cb(
self._sender.edit_message,
recipient=chat_id,
target_author=self._account_number,
target_sent_timestamp=int(msg_id),
new_body=content,
)
return DeliveryResult(success=True, message_id=msg_id)
if self._supports_edit:
return await self._call_with_cb(
self._sender.edit_message,
recipient=chat_id,
target_author=self._account_number,
target_sent_timestamp=int(msg_id),
new_body=content,
)
return await self._call_with_cb(self._sender.send_text, recipient=chat_id, message_body=content)
def _prune_stream_buffer(self) -> None:
now = time.monotonic()
expired = [k for k, (_, ts) in self._stream_buffer.items() if now - ts > STREAM_BUFFER_TTL]
for k in expired:
self._stream_buffer.pop(k, None)
if len(self._stream_buffer) > STREAM_BUFFER_MAX_ENTRIES:
sorted_keys = sorted(
self._stream_buffer.keys(),
key=lambda k: self._stream_buffer[k][1],
)
excess = len(self._stream_buffer) - STREAM_BUFFER_MAX_ENTRIES
for k in sorted_keys[:excess]:
self._stream_buffer.pop(k, None)
def _build_formatted_body(self, content: str) -> Any:
markdown_enabled = self.config.get("markdown_enabled", True)
if not markdown_enabled or not content:
return None
table_mode = self.config.get("markdown_table_mode", "bullets")
heading_style = self.config.get("heading_style", "bold")
blockquote_prefix = self.config.get("blockquote_prefix", "> ")
return markdown_to_signal_styles(
content,
table_mode=table_mode,
heading_style=heading_style,
blockquote_prefix=blockquote_prefix,
)
async def receive(self) -> AsyncIterator[ChannelMessage]:
return
yield
def normalize_inbound(self, raw: dict) -> ChannelMessage:
for parser in (parse_signal_message, parse_signal_reaction, parse_signal_receipt, parse_signal_delete):
msg = parser(raw, channel_id=self.channel_id)
if msg is not None:
return msg
raise ValueError("Invalid signal message: no recognized event found")
def format_outbound(self, response: ChannelResponse) -> dict:
payload: dict[str, Any] = {
"recipient": response.identity.channel_chat_id,
"messageBody": response.content,
}
if response.reply_to_message_id:
payload["quoteTimestamp"] = int(response.reply_to_message_id)
if response.message_type in self.MEDIA_MESSAGE_TYPES and response.attachments:
payload["messageBody"] = response.content or ""
payload["attachments"] = [
{
"contentType": att.mime_type or "application/octet-stream",
"filename": att.filename or "attachment",
"id": att.file_id,
}
for att in response.attachments
]
return payload
async def health_check(self) -> HealthStatus:
if not self._rpc_client:
return HealthStatus(status="unhealthy", last_error="RPC client not initialized")
try:
await self._rpc_client.call("version")
return HealthStatus(
status="healthy",
metadata={
"account": self._account_number,
"stream_edit_support": self._supports_edit,
"adapter_status": self._status.value,
},
)
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def _handle_message(self, message: ChannelMessage) -> None:
security_reason = None
if self._security is not None:
if message.chat_type == ChatType.GROUP:
if not self._security.check_group_permission(message):
logger.debug(f"[Signal] Group message blocked by security: {message.identity.channel_chat_id}")
if self._audit_enabled:
self._log_audit(message, "denied", "group_policy")
if self._ingest_enabled:
await self._ingest_group_message(message)
return
if not self._security.check_require_mention(message):
if self._audit_enabled:
self._log_audit(message, "filtered", "require_mention")
if self._ingest_enabled:
await self._ingest_group_message(message)
return
if self._security.require_mention:
if message.mentions and message.mentions.is_bot_mentioned:
if not self._security.check_command_double_auth(message):
logger.info(f"[Signal] Command double-auth failed for {message.identity.channel_user_id}")
if self._audit_enabled:
self._log_audit(message, "denied", "command_double_auth")
return
else:
if not self._security.check_dm_permission(message):
logger.debug(f"[Signal] DM blocked by security: {message.identity.channel_user_id}")
if self._audit_enabled:
self._log_audit(message, "denied", "dm_policy")
return
if self._main_dm_owner_pin and message.identity.channel_user_id != self._main_dm_owner_pin:
logger.debug(
f"[Signal] DM skipped by ownerPin: expected={self._main_dm_owner_pin}, "
f"got={message.identity.channel_user_id}"
)
return
if not self._check_context_visibility(message):
return
content_lower = (message.content or "").lower()
if not self.allow_config_writes() and self._is_config_command(content_lower):
logger.info(f"[Signal] Config write denied (config_writes=false): {message.identity.channel_user_id}")
return
if not self._exec_auth.auto_approve and self._is_sensitive_command(content_lower):
approved = await self._send_with_exec_auth(message.identity.channel_chat_id, message)
if not approved:
if self._audit_enabled:
self._log_audit(message, "denied", "exec_auth")
return
if self._audit_enabled:
self._log_audit(message, "allowed", security_reason)
if self._ai_vision_enabled and message.message_type == MessageType.IMAGE and message.attachments:
await self._analyze_vision(message)
if self._reaction_controller.should_send_auto_ack(message.identity.channel_chat_id):
await self.send_reaction(
message.identity.channel_chat_id,
message.identity.channel_message_id,
"\U0001f440",
)
history_limit = self.config.get("history_limit")
if history_limit is not None and message.chat_type == ChatType.GROUP:
chat_id = message.identity.channel_chat_id
if chat_id not in self._history_store:
self._history_store[chat_id] = []
recent = self._history_store[chat_id]
msg_id = message.identity.channel_message_id
if msg_id in recent:
return
recent.append(msg_id)
if len(recent) > history_limit:
self._history_store[chat_id] = recent[-history_limit:]
await super()._handle_message(message)
async def _ingest_group_message(self, message: ChannelMessage) -> None:
sender = message.identity.channel_user_id
group = message.identity.channel_chat_id
content_preview = message.content[:200] if message.content else "(no text)"
logger.info(f"[Signal] Group silent ingest: sender={sender}, group={group}, content_preview={content_preview}")
@staticmethod
def _is_config_command(content: str) -> bool:
config_keywords = ["/config", "/set ", "/settings", "config_writes", "dm_policy", "group_policy"]
return any(kw in content for kw in config_keywords)
@staticmethod
def _is_sensitive_command(content: str) -> bool:
sensitive_keywords = ["/delete", "/edit", "/unsend", "/send_media", "/media"]
return any(kw in content for kw in sensitive_keywords)
async def _analyze_vision(self, message: ChannelMessage) -> None:
for att in message.attachments:
if not att.mime_type or not att.mime_type.startswith("image/"):
continue
try:
if att.file_id:
image_data = await self.download_media(att.file_id)
description = await self._vision_analyzer.analyze_image(image_data, att.mime_type)
if description:
existing = message.content or ""
prefix = f"{existing}\n[image: {description}]" if existing else f"[image: {description}]"
message.content = prefix
except Exception:
logger.exception("Vision analysis failed for incoming message")
def _log_audit(self, message: ChannelMessage, decision: str, reason: str | None = None) -> None:
entry = {
"timestamp": int(time.time() * 1000),
"channel": "signal",
"user_id": message.identity.channel_user_id,
"chat_id": message.identity.channel_chat_id,
"chat_type": message.chat_type.value,
"message_id": message.identity.channel_message_id,
"action": message.event_type.value,
"decision": decision,
"reason": reason or "",
"dm_policy": str(self._security.dm_policy) if self._security else "none",
"group_policy": str(self._security.group_policy) if self._security else "none",
}
logger.info(f"[Signal Audit] {json.dumps(entry, ensure_ascii=False)}")
def _check_context_visibility(self, message: ChannelMessage) -> bool:
mode = self.config.get("context_visibility_mode", "all")
if mode == "all":
return True
if mode == "none":
return False
if mode == "same-group":
return message.chat_type == ChatType.GROUP
if mode == "trusted":
user_id = message.identity.channel_user_id
return self._security is not None and self._security.check_dm_permission(message)
return True
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._rpc_client:
return {}
cached = await self._entity_cache.get(f"user:{channel_user_id}")
if cached is not None:
return cached
try:
identities = await get_identities(self._rpc_client, self._account_number)
for identity in identities:
if identity.get("number") == channel_user_id:
info = {
"number": identity.get("number", channel_user_id),
"name": identity.get("name", ""),
"fingerprint": identity.get("fingerprint", ""),
"trust_level": identity.get("trustLevel", "UNTRUSTED"),
"added": identity.get("added", ""),
}
await self._entity_cache.set(f"user:{channel_user_id}", info)
return info
info = {"number": channel_user_id}
await self._entity_cache.set(f"user:{channel_user_id}", info)
return info
except RpcError as e:
logger.warning(f"get_user_info failed for {channel_user_id}: {e}")
return {}
async def list_peers(self) -> list[dict]:
if not self._rpc_client:
return []
cached = await self._entity_cache.get("peers:list")
if cached is not None:
return cached
peers = await list_peers(self._rpc_client, self._account_number)
await self._entity_cache.set("peers:list", peers)
return peers
async def list_groups(self) -> list[dict]:
if not self._rpc_client:
return []
cached = await self._entity_cache.get("groups:list")
if cached is not None:
return cached
groups = await list_groups(self._rpc_client, self._account_number)
await self._entity_cache.set("groups:list", groups)
return groups
async def download_media(self, file_id: str) -> bytes:
if not self._rpc_client:
raise RuntimeError("RPC client not initialized")
max_size_mb = self.config.get("attachment_max_size_mb", 0)
if max_size_mb > 0:
max_size_bytes = max_size_mb * 1024 * 1024
try:
result = await self._rpc_client.call(
"getAttachment",
{"account": self._account_number, "attachmentId": file_id},
)
data_b64 = result.get("data", "")
import base64 as b64
data = b64.b64decode(data_b64)
if max_size_mb > 0 and len(data) > max_size_bytes:
raise RuntimeError(f"Attachment size ({len(data)} bytes) exceeds limit ({max_size_mb}MB)")
return data
except RpcError as e:
raise RuntimeError(f"Failed to download attachment {file_id}: {e}")
async def pre_connect(self) -> dict:
signal_number = self.config.get("signal_number", "")
if not signal_number:
return {"status": "error", "message": "Missing signal_number"}
cli_path = self.config.get("cli_path", "signal-cli")
import shutil
if not shutil.which(cli_path) and cli_path != "signal-cli":
return {"status": "error", "message": f"signal-cli not found at: {cli_path}"}
http_listen = self.config.get("http_listen", "127.0.0.1:8080")
rpc_client = RpcClient(f"http://{http_listen}")
try:
await rpc_client.connect()
result = await rpc_client.call("version")
return {
"status": "ok",
"version": result.get("version", "unknown"),
"account": signal_number,
}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
try:
await rpc_client.disconnect()
except Exception:
pass
@property
def base_url(self) -> str:
return self._rpc_client.base_url if self._rpc_client else ""
def allow_config_writes(self) -> bool:
return self.config.get("config_writes", False)
def get_sent_message_status(self, msg_id: str) -> dict | None:
if not self._sender:
return None
return self._sender._get_cached(msg_id)
async def _detect_edit_support(self) -> bool:
if not self._rpc_client:
return False
return await self._detect_edit_support_for(self._rpc_client)
async def _detect_edit_support_for(self, rpc_client: RpcClient) -> bool:
edit_mode = self.config.get("streaming", {}).get("edit_support", "auto")
if edit_mode == "disabled":
return False
if edit_mode == "force":
return True
try:
version_result = await rpc_client.call("version")
version_str = version_result.get("version", "0.0")
parts = version_str.lstrip("v").split(".")
major = int(parts[0]) if parts else 0
minor = int(parts[1]) if len(parts) > 1 else 0
return (major > 0) or (major == 0 and minor >= 13)
except Exception:
return False
async def _handle_reasoning_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
reasoning_content = chunk.replace("lane:reasoning:", "")
sep = self._lane_separator
prefix = f"(thinking)\n{reasoning_content}\n{sep}\n"
return await self._flush_stream(chat_id, msg_id, prefix, finished)
async def _send_with_exec_auth(self, chat_id: str, message: ChannelMessage) -> bool:
action = message.metadata.get("command") or message.event_type.value
user_id = message.identity.channel_user_id
params = {
"content": message.content,
"chat_id": chat_id,
"message_type": message.message_type.value,
}
from yuxi.channels.adapters.signal.exec_auth import ExecAuthResult
result = await self._exec_auth.request_approval(action, params, user_id)
if result == ExecAuthResult.DENIED:
logger.info(f"[Signal] Exec auth denied for {action} by {user_id}")
return False
if result == ExecAuthResult.PENDING:
logger.info(f"[Signal] Exec auth pending for {action} by {user_id}")
return False
return True