diff --git a/backend/package/yuxi/channels/adapters/signal/__init__.py b/backend/package/yuxi/channels/adapters/signal/__init__.py index c7f002dc..506185b2 100644 --- a/backend/package/yuxi/channels/adapters/signal/__init__.py +++ b/backend/package/yuxi/channels/adapters/signal/__init__.py @@ -1,13 +1,15 @@ -from yuxi.channels.registry import register_builtin_adapter +from yuxi.channels.adapters.signal import ( + daemon, # noqa: F401 + entity_cache, # noqa: F401 + event_queue, # noqa: F401 + exec_auth, # noqa: F401 + monitor, # noqa: F401 + normalize, # noqa: F401 + security, # noqa: F401 + send, # noqa: F401 + sse_reconnect, # noqa: F401 +) from yuxi.channels.adapters.signal.channel import SignalChannel -from yuxi.channels.adapters.signal import send # noqa: F401 -from yuxi.channels.adapters.signal import monitor # noqa: F401 -from yuxi.channels.adapters.signal import daemon # noqa: F401 -from yuxi.channels.adapters.signal import security # noqa: F401 -from yuxi.channels.adapters.signal import exec_auth # noqa: F401 -from yuxi.channels.adapters.signal import sse_reconnect # noqa: F401 -from yuxi.channels.adapters.signal import normalize # noqa: F401 -from yuxi.channels.adapters.signal import event_queue # noqa: F401 -from yuxi.channels.adapters.signal import entity_cache # noqa: F401 +from yuxi.channels.registry import register_builtin_adapter -register_builtin_adapter(SignalChannel) \ No newline at end of file +register_builtin_adapter(SignalChannel) diff --git a/backend/package/yuxi/channels/adapters/signal/channel.py b/backend/package/yuxi/channels/adapters/signal/channel.py index 3a3868f5..4f3c5c53 100644 --- a/backend/package/yuxi/channels/adapters/signal/channel.py +++ b/backend/package/yuxi/channels/adapters/signal/channel.py @@ -10,18 +10,6 @@ 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, ) @@ -42,13 +30,31 @@ from yuxi.channels.adapters.signal.normalize import ( 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 +from yuxi.channels.adapters.signal.send import SignalSender +from yuxi.channels.adapters.signal.streaming import ( + STREAM_BUFFER_MAX_ENTRIES, + STREAM_BUFFER_TTL, + StreamBuffer, + StreamCoalescer, +) +from yuxi.channels.base import BaseChannelAdapter +from yuxi.channels.capabilities import ChannelCapabilities +from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError +from yuxi.channels.meta import ChannelMeta +from yuxi.channels.models import ( + ChannelMessage, + ChannelResponse, + ChannelStatus, + ChannelType, + ChatType, + DeliveryResult, + HealthStatus, + MessageType, +) logger = logging.getLogger(__name__) -STREAM_BUFFER_MAX_ENTRIES = 100 -STREAM_BUFFER_TTL = 300 DAEMON_STARTUP_TIMEOUT_MS = 30000 @@ -61,6 +67,37 @@ class SignalChannel(BaseChannelAdapter): streaming_modes: ClassVar[list[str]] = ["off", "block", "progress"] max_media_size_mb: ClassVar[int] = 100 + capabilities = ChannelCapabilities( + chat_types=["direct", "group"], + reactions=True, + edit=True, + unsend=True, + reply=True, + media=True, + pin=True, + unpin=True, + native_commands=True, + supports_markdown=True, + supports_streaming=True, + streaming_modes=["off", "block", "progress"], + text_chunk_limit=4000, + max_media_size_mb=100, + typing=True, + send_ephemeral=True, + block_streaming=True, + group_management=False, + threads=False, + polls=False, + ) + + meta = ChannelMeta( + id="signal", + label="Signal", + aliases=["signal"], + markdown_capable=True, + selection_label="Signal (signal-cli REST API)", + ) + def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED @@ -71,7 +108,6 @@ class SignalChannel(BaseChannelAdapter): 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) @@ -84,6 +120,17 @@ class SignalChannel(BaseChannelAdapter): 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) + self._stream_buffer = StreamBuffer( + max_entries=STREAM_BUFFER_MAX_ENTRIES, + ttl=STREAM_BUFFER_TTL, + ) + self._lane_separator = self.config.get("lane_separator", "---") + self._stream_coalescer = StreamCoalescer( + buffer=self._stream_buffer, + min_chars=self._stream_coalesce_min_chars, + idle_ms=self._stream_coalesce_idle_ms, + lane_separator=self._lane_separator, + ) reaction_level = self.config.get("reaction_level", "minimal") self._reaction_controller = ReactionLevelController(reaction_level) self._state_change_handlers: list[Callable[[ChannelStatus], Awaitable[None]]] = [] @@ -103,7 +150,6 @@ class SignalChannel(BaseChannelAdapter): 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"), @@ -115,6 +161,26 @@ class SignalChannel(BaseChannelAdapter): self._warned_once.add(key) logger.warning(f"[Signal] {message}") + def _validate_config(self) -> None: + errors = [] + + accounts = list_enabled_signal_accounts(self.config) + for account in accounts: + if not account.get("signal_number"): + errors.append(f"signal_number is required for account {account}") + + command_double_auth = self.config.get("command_double_auth", False) + security_cfg = self.config.get("security", {}) + require_mention = security_cfg.get("require_mention", False) + if command_double_auth and not require_mention: + logger.warning( + "command_double_auth is enabled but require_mention is disabled; " + "commands in groups will not trigger mention check" + ) + + if errors: + raise ValueError(f"Signal config validation failed: {'; '.join(errors)}") + def on_state_change(self, handler: Callable[[ChannelStatus], Awaitable[None]]) -> None: self._state_change_handlers.append(handler) @@ -151,6 +217,8 @@ class SignalChannel(BaseChannelAdapter): self._status = ChannelStatus.DISABLED return + self._validate_config() + self._status = ChannelStatus.CONNECTING logger.info(f"[Signal] Starting channel '{self.config.get('name', self.channel_id)}'") @@ -562,42 +630,67 @@ class SignalChannel(BaseChannelAdapter): duration_ms=duration_ms, ) + async def create_group(self, group_name: str, members: list[str]) -> DeliveryResult: + if not self._sender: + return DeliveryResult(success=False, error="Sender not initialized") + return await self._call_with_cb( + self._sender.create_group, + group_name=group_name, + members=members, + ) + + async def delete_group(self, group_id: str) -> DeliveryResult: + if not self._sender: + return DeliveryResult(success=False, error="Sender not initialized") + return await self._call_with_cb( + self._sender.delete_group, + group_id=group_id, + ) + + async def manage_group_members( + self, group_id: str, add: list[str] | None = None, remove: list[str] | None = None + ) -> DeliveryResult: + if not self._sender: + return DeliveryResult(success=False, error="Sender not initialized") + return await self._call_with_cb( + self._sender.manage_members, + group_id=group_id, + add=add, + remove=remove, + ) + 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: + if self._stream_coalescer.is_reasoning_chunk(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: + if self._stream_coalescer.should_flush_on_idle(chat_id): + buffered = self._stream_buffer.pop(chat_id) or "" flushed = await self._flush_stream(chat_id, msg_id, buffered, False) if not flushed.success: return flushed - buffered = "" - accumulated = buffered + chunk + self._stream_buffer.set(chat_id, self._stream_buffer.get(chat_id) + chunk) - if finished or len(accumulated) >= self._stream_coalesce_min_chars: - self._stream_buffer.pop(chat_id, None) + if finished or self._stream_coalescer.should_flush_on_size(chat_id): + accumulated = self._stream_buffer.pop(chat_id) or "" + self._stream_buffer.prune() return await self._flush_stream(chat_id, msg_id, accumulated, finished) - self._stream_buffer[chat_id] = (accumulated, now) - self._prune_stream_buffer() + self._stream_buffer.prune() 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()) + self._stream_buffer.set(chat_id, content) return result if not finished: @@ -621,21 +714,6 @@ class SignalChannel(BaseChannelAdapter): ) 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: @@ -709,19 +787,19 @@ class SignalChannel(BaseChannelAdapter): 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 + 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._command_double_auth: + 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}") @@ -977,10 +1055,8 @@ class SignalChannel(BaseChannelAdapter): 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) + formatted = self._stream_coalescer.format_reasoning(chunk) + return await self._flush_stream(chat_id, msg_id, formatted, finished) async def _send_with_exec_auth(self, chat_id: str, message: ChannelMessage) -> bool: action = message.metadata.get("command") or message.event_type.value diff --git a/backend/package/yuxi/channels/adapters/signal/daemon.py b/backend/package/yuxi/channels/adapters/signal/daemon.py index 5543c303..832b8cf1 100644 --- a/backend/package/yuxi/channels/adapters/signal/daemon.py +++ b/backend/package/yuxi/channels/adapters/signal/daemon.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio import logging import shutil -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable -from yuxi.channels.models import HealthStatus from yuxi.channels.adapters.signal.client import RpcClient +from yuxi.channels.models import HealthStatus logger = logging.getLogger(__name__) diff --git a/backend/package/yuxi/channels/adapters/signal/exec_auth.py b/backend/package/yuxi/channels/adapters/signal/exec_auth.py index 2cb3ff3c..1491d24d 100644 --- a/backend/package/yuxi/channels/adapters/signal/exec_auth.py +++ b/backend/package/yuxi/channels/adapters/signal/exec_auth.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio import logging +from collections.abc import Awaitable, Callable from enum import StrEnum from typing import Any -from collections.abc import Awaitable, Callable logger = logging.getLogger(__name__) diff --git a/backend/package/yuxi/channels/adapters/signal/install.py b/backend/package/yuxi/channels/adapters/signal/install.py index 63d7a56f..5a6ed282 100644 --- a/backend/package/yuxi/channels/adapters/signal/install.py +++ b/backend/package/yuxi/channels/adapters/signal/install.py @@ -1,11 +1,11 @@ +import json import logging import os import platform import shutil import subprocess import tempfile -from urllib.request import urlopen, Request -import json +from urllib.request import Request, urlopen logger = logging.getLogger(__name__) diff --git a/backend/package/yuxi/channels/adapters/signal/message_actions.py b/backend/package/yuxi/channels/adapters/signal/message_actions.py index 8c432519..2ed32a27 100644 --- a/backend/package/yuxi/channels/adapters/signal/message_actions.py +++ b/backend/package/yuxi/channels/adapters/signal/message_actions.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any - SIGNAL_MESSAGE_ACTIONS = { "send": { "description": "Send a text message to a Signal chat", @@ -65,6 +64,41 @@ SIGNAL_MESSAGE_ACTIONS = { "channel_user_id": {"type": "str", "description": "E.164 number of the user"}, }, }, + "send_sticker": { + "description": "Send a sticker to a Signal chat", + "params": { + "recipient": {"type": "str", "description": "Recipient E.164 number or group:ID"}, + "pack_id": {"type": "str", "description": "Sticker pack ID"}, + "sticker_id": {"type": "int", "description": "Sticker ID within the pack"}, + }, + }, + "send_voice": { + "description": "Send a voice message to a Signal chat", + "params": { + "recipient": {"type": "str", "description": "Recipient E.164 number or group:ID"}, + "audio_data": {"type": "bytes", "description": "Base64-encoded audio/ogg data"}, + }, + }, + "send_silent": { + "description": "Send a silent text message without notification", + "params": { + "recipient": {"type": "str", "description": "Recipient E.164 number or group:ID"}, + "message_body": {"type": "str", "description": "Message content"}, + }, + }, + "pin": { + "description": "Pin a message to the top of a Signal chat", + "params": { + "recipient": {"type": "str", "description": "Recipient E.164 number or group:ID"}, + "timestamp": {"type": "int", "description": "Timestamp of the message to pin"}, + }, + }, + "unpin": { + "description": "Unpin a previously pinned message", + "params": { + "recipient": {"type": "str", "description": "Recipient E.164 number or group:ID"}, + }, + }, } diff --git a/backend/package/yuxi/channels/adapters/signal/monitor.py b/backend/package/yuxi/channels/adapters/signal/monitor.py index c465637b..96d6c58b 100644 --- a/backend/package/yuxi/channels/adapters/signal/monitor.py +++ b/backend/package/yuxi/channels/adapters/signal/monitor.py @@ -3,9 +3,8 @@ from __future__ import annotations import asyncio import json import logging -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable -from yuxi.channels.models import ChannelMessage, EventType from yuxi.channels.adapters.signal.client import RpcClient from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue from yuxi.channels.adapters.signal.normalize import ( @@ -19,6 +18,7 @@ from yuxi.channels.adapters.signal.normalize import ( parse_signal_reaction, ) from yuxi.channels.adapters.signal.sse_reconnect import sse_event_stream +from yuxi.channels.models import ChannelMessage, EventType logger = logging.getLogger(__name__) diff --git a/backend/package/yuxi/channels/adapters/signal/normalize.py b/backend/package/yuxi/channels/adapters/signal/normalize.py index 38bd07b1..4675976e 100644 --- a/backend/package/yuxi/channels/adapters/signal/normalize.py +++ b/backend/package/yuxi/channels/adapters/signal/normalize.py @@ -2,7 +2,7 @@ from __future__ import annotations import re import time -from datetime import datetime, UTC +from datetime import UTC, datetime from yuxi.channels.models import ( Attachment, diff --git a/backend/package/yuxi/channels/adapters/signal/outbound_session.py b/backend/package/yuxi/channels/adapters/signal/outbound_session.py index 570f58bb..641bb987 100644 --- a/backend/package/yuxi/channels/adapters/signal/outbound_session.py +++ b/backend/package/yuxi/channels/adapters/signal/outbound_session.py @@ -2,8 +2,8 @@ from __future__ import annotations from dataclasses import dataclass -from yuxi.channels.models import ChatType from yuxi.channels.adapters.signal.normalize import normalize_target +from yuxi.channels.models import ChatType @dataclass diff --git a/backend/package/yuxi/channels/adapters/signal/probe.py b/backend/package/yuxi/channels/adapters/signal/probe.py index 29f311c6..6c62a25b 100644 --- a/backend/package/yuxi/channels/adapters/signal/probe.py +++ b/backend/package/yuxi/channels/adapters/signal/probe.py @@ -6,8 +6,8 @@ from dataclasses import dataclass, field import aiohttp -from yuxi.channels.models import HealthStatus from yuxi.channels.adapters.signal.client import RpcClient, RpcError +from yuxi.channels.models import HealthStatus logger = logging.getLogger(__name__) diff --git a/backend/package/yuxi/channels/adapters/signal/security.py b/backend/package/yuxi/channels/adapters/signal/security.py index a2ce8220..b9a5c573 100644 --- a/backend/package/yuxi/channels/adapters/signal/security.py +++ b/backend/package/yuxi/channels/adapters/signal/security.py @@ -1,6 +1,8 @@ -from enum import StrEnum +import asyncio import logging +import time from collections.abc import Awaitable, Callable +from enum import StrEnum from yuxi.channels.models import ChannelMessage @@ -150,6 +152,21 @@ class SignalSecurityPolicy: else: raise ValueError(f"Unknown target_type: {target_type}, expected 'dm' or 'group'") + if self._store_write_fn: + try: + asyncio.ensure_future( + self._store_write_fn( + f"signal:pairing:{target_type}:{target_id}", + { + "user_id": target_id, + "target_type": target_type, + "added_at": int(time.time()), + }, + ) + ) + except RuntimeError: + logger.debug("No running event loop, skip persist add_to_allowlist") + def remove_from_allowlist(self, target_id: str, target_type: str = "dm") -> None: if target_type == "dm": self._dm_allowlist.discard(target_id) diff --git a/backend/package/yuxi/channels/adapters/signal/send.py b/backend/package/yuxi/channels/adapters/signal/send.py index 05e99820..7b7c529e 100644 --- a/backend/package/yuxi/channels/adapters/signal/send.py +++ b/backend/package/yuxi/channels/adapters/signal/send.py @@ -5,7 +5,6 @@ import base64 import logging import uuid -from yuxi.channels.models import DeliveryResult from yuxi.channels.adapters.signal.client import RpcClient, RpcError from yuxi.channels.adapters.signal.format import ( FormattedText, @@ -14,6 +13,7 @@ from yuxi.channels.adapters.signal.format import ( split_text, ) from yuxi.channels.adapters.signal.normalize import normalize_target +from yuxi.channels.models import DeliveryResult logger = logging.getLogger(__name__) @@ -103,12 +103,18 @@ class SignalSender: formatted_body: FormattedText | None = None, chunk_mode: str = "newline", text_mode: str = "markdown", + previews: list[dict] | None = None, + mentions: list[dict] | None = None, ) -> DeliveryResult: if text_mode == "plain" and formatted_body is not None: - return await self._send_formatted_text(recipient, formatted_body, reply_to_id, chunk_mode) + return await self._send_formatted_text( + recipient, formatted_body, reply_to_id, chunk_mode, previews, mentions + ) if formatted_body is not None and text_mode == "markdown": - return await self._send_formatted_text(recipient, formatted_body, reply_to_id, chunk_mode) + return await self._send_formatted_text( + recipient, formatted_body, reply_to_id, chunk_mode, previews, mentions + ) chunks = split_text(message_body, self.MAX_TEXT_LENGTH, chunk_mode) last_message_id = None @@ -121,6 +127,12 @@ class SignalSender: } if reply_to_id and i == 0: params["quoteTimestamp"] = int(reply_to_id) + if previews: + params["previews"] = previews + if mentions: + params["bodyRanges"] = [ + {"mentionUuid": m["user_id"], "start": m["start"], "length": m["length"]} for m in mentions + ] try: result = await self._call_with_retry("send", params) @@ -139,6 +151,8 @@ class SignalSender: formatted: FormattedText, reply_to_id: str | None = None, chunk_mode: str = "newline", + previews: list[dict] | None = None, + mentions: list[dict] | None = None, ) -> DeliveryResult: chunks = split_text(formatted.body, self.MAX_TEXT_LENGTH, chunk_mode) last_message_id = None @@ -158,6 +172,12 @@ class SignalSender: params["styles"] = [{"start": s.start, "length": s.length, "style": s.style} for s in clamped] if reply_to_id and i == 0: params["quoteTimestamp"] = int(reply_to_id) + if previews: + params["previews"] = previews + if mentions: + params["bodyRanges"] = [ + {"mentionUuid": m["user_id"], "start": m["start"], "length": m["length"]} for m in mentions + ] try: result = await self._call_with_retry("send", params) @@ -412,6 +432,72 @@ class SignalSender: logger.error(f"Failed to send voice to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) + async def create_group( + self, + group_name: str, + members: list[str], + avatar_path: str | None = None, + ) -> DeliveryResult: + try: + params = { + "account": self._account, + "groupName": group_name, + "members": members, + } + if avatar_path: + params["avatar"] = avatar_path + result = await self._rpc.call("createGroup", params) + group_id = result.get("groupId", "") + return DeliveryResult( + success=True, + message_id=group_id, + ) + except Exception as e: + logger.error(f"Failed to create group '{group_name}': {e}") + return DeliveryResult(success=False, error=str(e)) + + async def delete_group(self, group_id: str) -> DeliveryResult: + try: + if not group_id.startswith(GROUP_PREFIX): + group_id = f"{GROUP_PREFIX}{group_id}" + params = { + "account": self._account, + "groupId": group_id, + } + await self._rpc.call("quitGroup", params) + return DeliveryResult(success=True) + except Exception as e: + logger.error(f"Failed to delete group {group_id}: {e}") + return DeliveryResult(success=False, error=str(e)) + + async def manage_members( + self, + group_id: str, + add: list[str] | None = None, + remove: list[str] | None = None, + ) -> DeliveryResult: + try: + if not group_id.startswith(GROUP_PREFIX): + group_id = f"{GROUP_PREFIX}{group_id}" + if add: + params = { + "account": self._account, + "groupId": group_id, + "members": add, + } + await self._rpc.call("addMembers", params) + if remove: + params = { + "account": self._account, + "groupId": group_id, + "members": remove, + } + await self._rpc.call("removeMembers", params) + return DeliveryResult(success=True) + except Exception as e: + logger.error(f"Failed to manage members for group {group_id}: {e}") + return DeliveryResult(success=False, error=str(e)) + def _extract_chunk_styles(styles: list[StyleRange], offset: int, chunk_len: int) -> list[StyleRange]: chunk_end = offset + chunk_len diff --git a/backend/package/yuxi/channels/adapters/signal/session.py b/backend/package/yuxi/channels/adapters/signal/session.py index 2c0a6294..28f304e4 100644 --- a/backend/package/yuxi/channels/adapters/signal/session.py +++ b/backend/package/yuxi/channels/adapters/signal/session.py @@ -1,5 +1,5 @@ -from yuxi.channels.models import ChannelIdentity, ChannelType from yuxi.channels.adapters.signal.normalize import normalize_e164 +from yuxi.channels.models import ChannelIdentity, ChannelType def resolve_thread(identity: ChannelIdentity, agent_id: str = "main") -> str: diff --git a/backend/package/yuxi/channels/adapters/signal/setup.py b/backend/package/yuxi/channels/adapters/signal/setup.py index ab19824e..c0abfa2c 100644 --- a/backend/package/yuxi/channels/adapters/signal/setup.py +++ b/backend/package/yuxi/channels/adapters/signal/setup.py @@ -148,6 +148,7 @@ class SetupWizard: def auto_install(self) -> dict: import asyncio + from yuxi.channels.adapters.signal.install import auto_install_signal_cli try: diff --git a/backend/package/yuxi/channels/adapters/signal/sse_reconnect.py b/backend/package/yuxi/channels/adapters/signal/sse_reconnect.py index e2c68eee..8cca5bae 100644 --- a/backend/package/yuxi/channels/adapters/signal/sse_reconnect.py +++ b/backend/package/yuxi/channels/adapters/signal/sse_reconnect.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging import random -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable import aiohttp