refactor(signal-adapter): 整理代码结构与导入顺序,新增批量消息操作支持
1. 调整多个文件的导入顺序与格式,统一代码风格 2. 在security模块新增允许列表持久化存储逻辑 3. 新增send_sticker/send_voice/send_silent/pin/unpin等消息操作 4. 新增群组创建/删除/成员管理方法 5. 重构流式消息处理逻辑,提取为独立工具类 6. 修复配置校验与安全检查的逻辑顺序问题 7. 优化初始化流程,新增配置校验步骤
This commit is contained in:
parent
ef5483dc1a
commit
b47c6126e6
@ -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)
|
||||
register_builtin_adapter(SignalChannel)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user