这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
1000 lines
42 KiB
Python
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
|