ForcePilot/backend/package/yuxi/channels/adapters/signal/channel.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

1000 lines
42 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
import aiohttp
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")
except RpcError as e:
logger.warning(f"[Signal] RPC error sending to {chat_id}: {e}")
return DeliveryResult(success=False, error=f"RPC error: {e}")
except (TimeoutError, aiohttp.ClientError) as e:
logger.warning(f"[Signal] Network error sending to {chat_id}: {e}")
return DeliveryResult(success=False, error=f"Network error: {e}")
except Exception as e:
logger.exception(f"[Signal] Unexpected error sending to {chat_id}")
return DeliveryResult(success=False, error=f"Unexpected error: {e}")
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":
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.APPROVED:
return True
logger.info(f"[Signal] Exec auth {result.value} for {action} by {user_id}")
return False