refactor(signal-adapter): 整理代码结构与导入顺序,新增批量消息操作支持

1.  调整多个文件的导入顺序与格式,统一代码风格
2.  在security模块新增允许列表持久化存储逻辑
3.  新增send_sticker/send_voice/send_silent/pin/unpin等消息操作
4.  新增群组创建/删除/成员管理方法
5.  重构流式消息处理逻辑,提取为独立工具类
6.  修复配置校验与安全检查的逻辑顺序问题
7.  优化初始化流程,新增配置校验步骤
This commit is contained in:
Kris 2026-05-13 16:14:06 +08:00
parent ef5483dc1a
commit b47c6126e6
15 changed files with 305 additions and 89 deletions

View File

@ -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.channel import SignalChannel
from yuxi.channels.adapters.signal import send # noqa: F401 from yuxi.channels.registry import register_builtin_adapter
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
register_builtin_adapter(SignalChannel) register_builtin_adapter(SignalChannel)

View File

@ -10,18 +10,6 @@ from typing import Any, ClassVar
import aiohttp 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 ( from yuxi.channels.adapters.signal.accounts import (
list_enabled_signal_accounts, list_enabled_signal_accounts,
) )
@ -42,13 +30,31 @@ from yuxi.channels.adapters.signal.normalize import (
parse_signal_receipt, parse_signal_receipt,
) )
from yuxi.channels.adapters.signal.reaction_level import ReactionLevelController 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.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__) logger = logging.getLogger(__name__)
STREAM_BUFFER_MAX_ENTRIES = 100
STREAM_BUFFER_TTL = 300
DAEMON_STARTUP_TIMEOUT_MS = 30000 DAEMON_STARTUP_TIMEOUT_MS = 30000
@ -61,6 +67,37 @@ class SignalChannel(BaseChannelAdapter):
streaming_modes: ClassVar[list[str]] = ["off", "block", "progress"] streaming_modes: ClassVar[list[str]] = ["off", "block", "progress"]
max_media_size_mb: ClassVar[int] = 100 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): def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config) super().__init__(config)
self._status = ChannelStatus.DISCONNECTED self._status = ChannelStatus.DISCONNECTED
@ -71,7 +108,6 @@ class SignalChannel(BaseChannelAdapter):
self._account_number: str = "" self._account_number: str = ""
self._account_uuid: str | None = None self._account_uuid: str | None = None
self._supports_edit: bool = False self._supports_edit: bool = False
self._stream_buffer: dict[str, tuple[str, float]] = {}
self._security: SignalSecurityPolicy | None = None self._security: SignalSecurityPolicy | None = None
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60) self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60)
self._enabled = self.config.get("enabled", True) self._enabled = self.config.get("enabled", True)
@ -84,6 +120,17 @@ class SignalChannel(BaseChannelAdapter):
coalesce_cfg = self.config.get("block_streaming_coalesce", {}) coalesce_cfg = self.config.get("block_streaming_coalesce", {})
self._stream_coalesce_min_chars: int = coalesce_cfg.get("min_chars", 1500) 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_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") reaction_level = self.config.get("reaction_level", "minimal")
self._reaction_controller = ReactionLevelController(reaction_level) self._reaction_controller = ReactionLevelController(reaction_level)
self._state_change_handlers: list[Callable[[ChannelStatus], Awaitable[None]]] = [] 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) self._audit_enabled = self.config.get("audit", {}).get("enabled", False)
ai_vision_cfg = self.config.get("ai_vision", {}) ai_vision_cfg = self.config.get("ai_vision", {})
self._ai_vision_enabled = ai_vision_cfg.get("enabled", False) 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._warned_once: set[str] = set()
self._vision_analyzer = MediaVisionAnalyzer( self._vision_analyzer = MediaVisionAnalyzer(
llm_call_fn=self.config.get("vision_llm_call"), llm_call_fn=self.config.get("vision_llm_call"),
@ -115,6 +161,26 @@ class SignalChannel(BaseChannelAdapter):
self._warned_once.add(key) self._warned_once.add(key)
logger.warning(f"[Signal] {message}") 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: def on_state_change(self, handler: Callable[[ChannelStatus], Awaitable[None]]) -> None:
self._state_change_handlers.append(handler) self._state_change_handlers.append(handler)
@ -151,6 +217,8 @@ class SignalChannel(BaseChannelAdapter):
self._status = ChannelStatus.DISABLED self._status = ChannelStatus.DISABLED
return return
self._validate_config()
self._status = ChannelStatus.CONNECTING self._status = ChannelStatus.CONNECTING
logger.info(f"[Signal] Starting channel '{self.config.get('name', self.channel_id)}'") logger.info(f"[Signal] Starting channel '{self.config.get('name', self.channel_id)}'")
@ -562,42 +630,67 @@ class SignalChannel(BaseChannelAdapter):
duration_ms=duration_ms, 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: 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: if not self._sender or not self._rpc_client:
return DeliveryResult(success=False, error="Not initialized") 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) return await self._handle_reasoning_chunk(chat_id, msg_id, chunk, finished)
block_streaming = self.config.get("block_streaming", False) block_streaming = self.config.get("block_streaming", False)
if block_streaming: if block_streaming:
return await self._flush_stream(chat_id, msg_id, chunk, finished) return await self._flush_stream(chat_id, msg_id, chunk, finished)
now = time.monotonic() if self._stream_coalescer.should_flush_on_idle(chat_id):
buffered, last_ts = self._stream_buffer.get(chat_id, ("", 0)) buffered = self._stream_buffer.pop(chat_id) or ""
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) flushed = await self._flush_stream(chat_id, msg_id, buffered, False)
if not flushed.success: if not flushed.success:
return flushed 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: if finished or self._stream_coalescer.should_flush_on_size(chat_id):
self._stream_buffer.pop(chat_id, None) accumulated = self._stream_buffer.pop(chat_id) or ""
self._stream_buffer.prune()
return await self._flush_stream(chat_id, msg_id, accumulated, finished) return await self._flush_stream(chat_id, msg_id, accumulated, finished)
self._stream_buffer[chat_id] = (accumulated, now) self._stream_buffer.prune()
self._prune_stream_buffer()
return DeliveryResult(success=True, message_id=msg_id) return DeliveryResult(success=True, message_id=msg_id)
async def _flush_stream(self, chat_id: str, msg_id: str, content: str, finished: bool) -> DeliveryResult: async def _flush_stream(self, chat_id: str, msg_id: str, content: str, finished: bool) -> DeliveryResult:
if not msg_id: if not msg_id:
result = await self._call_with_cb(self._sender.send_text, recipient=chat_id, message_body=content) result = await self._call_with_cb(self._sender.send_text, recipient=chat_id, message_body=content)
if not finished: if not finished:
self._stream_buffer[chat_id] = (content, time.monotonic()) self._stream_buffer.set(chat_id, content)
return result return result
if not finished: 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) 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: def _build_formatted_body(self, content: str) -> Any:
markdown_enabled = self.config.get("markdown_enabled", True) markdown_enabled = self.config.get("markdown_enabled", True)
if not markdown_enabled or not content: if not markdown_enabled or not content:
@ -709,19 +787,19 @@ class SignalChannel(BaseChannelAdapter):
if self._ingest_enabled: if self._ingest_enabled:
await self._ingest_group_message(message) await self._ingest_group_message(message)
return 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 self._security.require_mention:
if message.mentions and message.mentions.is_bot_mentioned: if not self._security.check_require_mention(message):
if not self._security.check_command_double_auth(message): if self._audit_enabled:
logger.info(f"[Signal] Command double-auth failed for {message.identity.channel_user_id}") self._log_audit(message, "filtered", "require_mention")
if self._audit_enabled: if self._ingest_enabled:
self._log_audit(message, "denied", "command_double_auth") await self._ingest_group_message(message)
return 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: else:
if not self._security.check_dm_permission(message): if not self._security.check_dm_permission(message):
logger.debug(f"[Signal] DM blocked by security: {message.identity.channel_user_id}") logger.debug(f"[Signal] DM blocked by security: {message.identity.channel_user_id}")
@ -977,10 +1055,8 @@ class SignalChannel(BaseChannelAdapter):
return False return False
async def _handle_reasoning_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: async def _handle_reasoning_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
reasoning_content = chunk.replace("lane:reasoning:", "") formatted = self._stream_coalescer.format_reasoning(chunk)
sep = self._lane_separator return await self._flush_stream(chat_id, msg_id, formatted, finished)
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: async def _send_with_exec_auth(self, chat_id: str, message: ChannelMessage) -> bool:
action = message.metadata.get("command") or message.event_type.value action = message.metadata.get("command") or message.event_type.value

View File

@ -3,10 +3,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import shutil 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.adapters.signal.client import RpcClient
from yuxi.channels.models import HealthStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -2,9 +2,9 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Awaitable, Callable
from enum import StrEnum from enum import StrEnum
from typing import Any from typing import Any
from collections.abc import Awaitable, Callable
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,11 +1,11 @@
import json
import logging import logging
import os import os
import platform import platform
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from urllib.request import urlopen, Request from urllib.request import Request, urlopen
import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any from typing import Any
SIGNAL_MESSAGE_ACTIONS = { SIGNAL_MESSAGE_ACTIONS = {
"send": { "send": {
"description": "Send a text message to a Signal chat", "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"}, "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"},
},
},
} }

View File

@ -3,9 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging 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.client import RpcClient
from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue
from yuxi.channels.adapters.signal.normalize import ( from yuxi.channels.adapters.signal.normalize import (
@ -19,6 +18,7 @@ from yuxi.channels.adapters.signal.normalize import (
parse_signal_reaction, parse_signal_reaction,
) )
from yuxi.channels.adapters.signal.sse_reconnect import sse_event_stream from yuxi.channels.adapters.signal.sse_reconnect import sse_event_stream
from yuxi.channels.models import ChannelMessage, EventType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
import time import time
from datetime import datetime, UTC from datetime import UTC, datetime
from yuxi.channels.models import ( from yuxi.channels.models import (
Attachment, Attachment,

View File

@ -2,8 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from yuxi.channels.models import ChatType
from yuxi.channels.adapters.signal.normalize import normalize_target from yuxi.channels.adapters.signal.normalize import normalize_target
from yuxi.channels.models import ChatType
@dataclass @dataclass

View File

@ -6,8 +6,8 @@ from dataclasses import dataclass, field
import aiohttp import aiohttp
from yuxi.channels.models import HealthStatus
from yuxi.channels.adapters.signal.client import RpcClient, RpcError from yuxi.channels.adapters.signal.client import RpcClient, RpcError
from yuxi.channels.models import HealthStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,6 +1,8 @@
from enum import StrEnum import asyncio
import logging import logging
import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from enum import StrEnum
from yuxi.channels.models import ChannelMessage from yuxi.channels.models import ChannelMessage
@ -150,6 +152,21 @@ class SignalSecurityPolicy:
else: else:
raise ValueError(f"Unknown target_type: {target_type}, expected 'dm' or 'group'") 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: def remove_from_allowlist(self, target_id: str, target_type: str = "dm") -> None:
if target_type == "dm": if target_type == "dm":
self._dm_allowlist.discard(target_id) self._dm_allowlist.discard(target_id)

View File

@ -5,7 +5,6 @@ import base64
import logging import logging
import uuid import uuid
from yuxi.channels.models import DeliveryResult
from yuxi.channels.adapters.signal.client import RpcClient, RpcError from yuxi.channels.adapters.signal.client import RpcClient, RpcError
from yuxi.channels.adapters.signal.format import ( from yuxi.channels.adapters.signal.format import (
FormattedText, FormattedText,
@ -14,6 +13,7 @@ from yuxi.channels.adapters.signal.format import (
split_text, split_text,
) )
from yuxi.channels.adapters.signal.normalize import normalize_target from yuxi.channels.adapters.signal.normalize import normalize_target
from yuxi.channels.models import DeliveryResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -103,12 +103,18 @@ class SignalSender:
formatted_body: FormattedText | None = None, formatted_body: FormattedText | None = None,
chunk_mode: str = "newline", chunk_mode: str = "newline",
text_mode: str = "markdown", text_mode: str = "markdown",
previews: list[dict] | None = None,
mentions: list[dict] | None = None,
) -> DeliveryResult: ) -> DeliveryResult:
if text_mode == "plain" and formatted_body is not None: 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": 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) chunks = split_text(message_body, self.MAX_TEXT_LENGTH, chunk_mode)
last_message_id = None last_message_id = None
@ -121,6 +127,12 @@ class SignalSender:
} }
if reply_to_id and i == 0: if reply_to_id and i == 0:
params["quoteTimestamp"] = int(reply_to_id) 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: try:
result = await self._call_with_retry("send", params) result = await self._call_with_retry("send", params)
@ -139,6 +151,8 @@ class SignalSender:
formatted: FormattedText, formatted: FormattedText,
reply_to_id: str | None = None, reply_to_id: str | None = None,
chunk_mode: str = "newline", chunk_mode: str = "newline",
previews: list[dict] | None = None,
mentions: list[dict] | None = None,
) -> DeliveryResult: ) -> DeliveryResult:
chunks = split_text(formatted.body, self.MAX_TEXT_LENGTH, chunk_mode) chunks = split_text(formatted.body, self.MAX_TEXT_LENGTH, chunk_mode)
last_message_id = None 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] params["styles"] = [{"start": s.start, "length": s.length, "style": s.style} for s in clamped]
if reply_to_id and i == 0: if reply_to_id and i == 0:
params["quoteTimestamp"] = int(reply_to_id) 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: try:
result = await self._call_with_retry("send", params) result = await self._call_with_retry("send", params)
@ -412,6 +432,72 @@ class SignalSender:
logger.error(f"Failed to send voice to {recipient}: {e}") logger.error(f"Failed to send voice to {recipient}: {e}")
return DeliveryResult(success=False, error=str(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]: def _extract_chunk_styles(styles: list[StyleRange], offset: int, chunk_len: int) -> list[StyleRange]:
chunk_end = offset + chunk_len chunk_end = offset + chunk_len

View File

@ -1,5 +1,5 @@
from yuxi.channels.models import ChannelIdentity, ChannelType
from yuxi.channels.adapters.signal.normalize import normalize_e164 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: def resolve_thread(identity: ChannelIdentity, agent_id: str = "main") -> str:

View File

@ -148,6 +148,7 @@ class SetupWizard:
def auto_install(self) -> dict: def auto_install(self) -> dict:
import asyncio import asyncio
from yuxi.channels.adapters.signal.install import auto_install_signal_cli from yuxi.channels.adapters.signal.install import auto_install_signal_cli
try: try:

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import random import random
from collections.abc import Callable, Awaitable from collections.abc import Awaitable, Callable
import aiohttp import aiohttp