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.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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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__)
|
||||||
|
|
||||||
|
|||||||
@ -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__)
|
||||||
|
|
||||||
|
|||||||
@ -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__)
|
||||||
|
|
||||||
|
|||||||
@ -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"},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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__)
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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__)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user