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 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)

View File

@ -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

View File

@ -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__)

View File

@ -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__)

View File

@ -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__)

View File

@ -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"},
},
},
}

View File

@ -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__)

View File

@ -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,

View File

@ -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

View File

@ -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__)

View File

@ -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)

View File

@ -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

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.models import ChannelIdentity, ChannelType
def resolve_thread(identity: ChannelIdentity, agent_id: str = "main") -> str:

View File

@ -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:

View File

@ -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