Files
EvoScientist-Multi/EvoScientist/channels/signal/channel.py
T
Faych 9bd7a37a77 fix: _extract_retry_after returns None for non-retryable errors (#394)
* fix: _extract_retry_after returns None for non-retryable errors

* fix: update _extract_retry_after to handle generic transient errors with default retry delay

* fix: extend base _non_retryable_patterns in channel subclasses

* fix(channels): merge structured SDK error check into non-retryable step

* fix(channels): decouple status code and SDK error code extraction in retry logic

- Independently evaluate HTTP status codes and structured SDK error codes
- Fix misleading doc comments for Feishu and DingTalk patterns
- Remove redundant try-except AttributeError on getattr with default
- Expand test coverage for dual-signal matrix and header parsing

* refactor(channels): simplify status code and SDK error extraction via channel overrides

- Handle httpx and aiohttp exceptions in base Channel class
- Override _extract_status_code and _extract_sdk_error_code in SlackChannel and DiscordChannel
- Replace mock exception types in comprehensive test suite with real httpx and aiohttp errors
- Add dedicated Slack and Discord retry error extraction test suites

* fix(channels): clean up Slack and Discord error code extraction

- Remove defensive string checks and attribute guards in SlackChannel
- Directly access exc.response.status_code and exc.response.get('error') in SlackChannel
- Remove unnecessary _extract_sdk_error_code override in DiscordChannel
- Use real SlackApiError, SlackResponse, and discord.HTTPException in unit tests

* refactor: reorder retry logic to prioritize non-retryable checks, remove aiohttp dependency, and clean up exception handling in base and channel modules.

* test(channels): skip Slack/Discord retry tests when the SDK extra is absent

The retry-extraction tests build real SlackApiError / discord.HTTPException
objects, but slack-sdk and discord.py are optional extras that the dev
dependency group does not install. Under CI's `uv sync --dev` all nine
tests failed with ModuleNotFoundError raised from the channel override.

Gate both test classes with skipif(find_spec(...) is None) so the suite is
green without the extras and the tests still run wherever they are installed.

* refactor(channels): replace retry-delay lookup with _extract_retry_delay

_extract_retry_after still read the server-supplied delay by probing
exc.retry_after and exc.response.headers via getattr/hasattr, the last
remnant of the pattern the extractors moved away from. Replace both steps
with one overridable hook, _extract_retry_delay, implemented against the
real exception types:

- base: httpx.HTTPStatusError -> Retry-After header (httpx.Headers is
  case-insensitive; HTTP-date form remains unsupported)
- SlackChannel: SlackApiError -> Retry-After, matched case-insensitively
  because SlackResponse.headers is a plain dict whose casing depends on the
  HTTP client (same approach as slack_sdk's RateLimitErrorRetryHandler)
- TelegramChannel: telegram.error.RetryAfter.retry_after (int, or timedelta
  under PTB_TIMEDELTA)
- DiscordChannel: discord.RateLimited.retry_after, which the old duck-typed
  getattr matched and would otherwise have been lost

Drop the isinstance(retry, bool) and val >= 0 guards; no SDK produces those.
Delete the test that asserted the duck-typed attribute; add real-object tests
for each override, guarded like the existing SDK-dependent classes.

* ci: install the all-channels extra so SDK-dependent channel tests run

The Slack, Discord, and Telegram retry tests build real SDK exception
objects and are skipped when the SDK is absent. CI only ran `uv sync --dev`,
so those tests never executed there. Install the existing all-channels
extra alongside the dev group; the skipif guards remain for lean local runs.

* fix(channels): honor HTTP-date Retry-After and tolerate malformed values

RFC 9110 allows Retry-After as either delay-seconds or an HTTP-date. The
httpx path treated a date as unparseable and fell back to the 1.0 s default,
so a 503 asking for a specific wait was retried too early. Add
Channel._parse_retry_after, which returns delay-seconds as-is and converts
an HTTP-date to the non-negative seconds until it (tz-less dates read as
UTC).

SlackChannel used a bare float() on the header. A non-numeric value raised
inside the retry predicate, which escapes retry_async and drops the chunk
instead of retrying. Route Slack through the same helper so a bad header
falls back to _rate_limit_delay.

Addresses CodeRabbit review comments on base.py:866 and slack/channel.py:229.

* fix(channels): treat HTTP 400 and 404 as non-retryable

Both are permanent for a given request, so retrying burns the attempt
budget for nothing. Add them to _non_retryable_status_codes alongside
401/403.

Deliberately not a 4xx range check: 408 and 425 are retryable by
definition and 429 is handled by the rate-limit path. A test pins 408 as
still retryable so the range shortcut is not reintroduced later.

Partially addresses CodeRabbit's outside-diff comment on base.py:749-750.

* fix(channels): guard Slack retry extractors against raw aiohttp responses

slack_sdk attaches the bare aiohttp.ClientResponse to SlackApiError when a
JSON-declared body fails to parse. That object has neither status_code nor
get(), so _extract_status_code raised AttributeError inside should_retry,
replacing the original error and skipping the remaining attempts. Narrow
both extractors to SlackResponse/AsyncSlackResponse so such errors fall
through to the message patterns and retry as before. Add a wire-level
regression test against a local aiohttp server.

---------

Co-authored-by: Dinos Papakostas <dinospk1999@gmail.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
2026-09-10 22:25:12 +00:00

515 lines
19 KiB
Python

"""Signal channel implementation using signal-cli JSON RPC."""
import asyncio
import json
import logging
import re
import subprocess
from collections import deque
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from ..base import Channel, ChannelError, RawIncoming
from ..capabilities import SIGNAL as SIGNAL_CAPS
from ..config import BaseChannelConfig
logger = logging.getLogger(__name__)
@dataclass
class SignalConfig(BaseChannelConfig):
phone_number: str = ""
cli_path: str = "signal-cli"
config_dir: str | None = None
rpc_port: int = 7583
text_chunk_limit: int = 4096
class SignalChannel(Channel):
"""Signal channel using signal-cli JSON RPC."""
name = "signal"
capabilities = SIGNAL_CAPS
_non_retryable_patterns = (
*Channel._non_retryable_patterns,
"unregistered",
"auth",
)
def __init__(self, config: SignalConfig):
super().__init__(config)
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._rpc_id = 0
self._daemon_proc = None
# Pending RPC responses: rpc_id -> Future
self._pending_rpcs: dict[int, asyncio.Future] = {}
# Cache message_id → sender for reaction targetAuthor (bounded)
self._msg_senders: dict[str, str] = {}
self._msg_senders_order: deque = deque(maxlen=200)
self._listen_task: asyncio.Task | None = None
async def start(self) -> None:
if not self.config.phone_number:
raise ChannelError("Signal phone_number is required")
# Try to start signal-cli daemon if not already running
await self._ensure_daemon()
try:
# Connect to JSON RPC socket
await self._connect()
except Exception:
# If connect fails after daemon was started, clean up the daemon
await self._cleanup()
raise
self._running = True
logger.info(f"Signal channel started (phone: {self.config.phone_number})")
# Listen for incoming messages in background task
# (start() must return so that run() can iterate receive())
self._listen_task = asyncio.create_task(self._listen_loop())
async def _cleanup(self) -> None:
if self._listen_task:
self._listen_task.cancel()
self._listen_task = None
# Cancel any pending RPC futures
for fut in self._pending_rpcs.values():
if not fut.done():
fut.cancel()
self._pending_rpcs.clear()
if self._writer:
self._writer.close()
try:
await self._writer.wait_closed()
except Exception:
pass
self._writer = None
self._reader = None
if self._daemon_proc:
self._daemon_proc.terminate()
self._daemon_proc = None
logger.info("Signal channel stopped")
async def _ensure_daemon(self) -> None:
"""Start signal-cli daemon if not already running."""
try:
_reader, writer = await asyncio.wait_for(
asyncio.open_connection("localhost", self.config.rpc_port),
timeout=2,
)
writer.close()
await writer.wait_closed()
logger.info("signal-cli daemon already running")
return
except (TimeoutError, ConnectionRefusedError, OSError):
pass
# Start daemon
cmd = [self.config.cli_path, "-u", self.config.phone_number]
if self.config.config_dir:
cmd.extend(["--config", self.config.config_dir])
cmd.extend(
[
"daemon",
"--tcp",
f"localhost:{self.config.rpc_port}",
"--no-receive-stdout",
]
)
logger.info(f"Starting signal-cli daemon: {' '.join(cmd)}")
try:
self._daemon_proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
raise ChannelError(
f"signal-cli not found at '{self.config.cli_path}'. "
"Install: https://github.com/AsamK/signal-cli"
) from None
# Wait for daemon to be ready
for _ in range(30):
await asyncio.sleep(1)
try:
_reader, writer = await asyncio.open_connection(
"localhost",
self.config.rpc_port,
)
writer.close()
await writer.wait_closed()
logger.info("signal-cli daemon started")
return
except (ConnectionRefusedError, OSError):
continue
raise ChannelError("signal-cli daemon failed to start within 30s")
async def _connect(self) -> None:
"""Connect to signal-cli JSON RPC socket."""
try:
self._reader, self._writer = await asyncio.open_connection(
"localhost",
self.config.rpc_port,
)
except Exception as e:
raise ChannelError(f"Cannot connect to signal-cli: {e}") from e
async def _listen_loop(self) -> None:
"""Listen for incoming JSON RPC notifications and responses."""
while self._running and self._reader:
try:
line = await self._reader.readline()
if not line:
break
data = json.loads(line.decode())
# Dispatch RPC response if it has an 'id' matching a pending call
rpc_id = data.get("id")
if rpc_id is not None and rpc_id in self._pending_rpcs:
fut = self._pending_rpcs.pop(rpc_id)
if not fut.done():
if "error" in data:
fut.set_exception(
RuntimeError(f"signal-cli RPC error: {data['error']}")
)
else:
fut.set_result(data.get("result"))
continue
await self._handle_rpc(data)
except asyncio.CancelledError:
break
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Signal listen error: {e}")
# Reconnect
if self._running:
await asyncio.sleep(2)
try:
await self._connect()
except Exception:
logger.warning("Signal reconnect failed, exiting listen loop")
break
async def _handle_rpc(self, data: dict) -> None:
"""Handle a JSON RPC message from signal-cli."""
method = data.get("method", "")
if method != "receive":
return
params = data.get("params", {})
envelope = params.get("envelope", {})
source = envelope.get("source") or envelope.get("sourceUuid") or ""
source_number = envelope.get("sourceNumber") or source
source_name = envelope.get("sourceName") or ""
timestamp = envelope.get("timestamp", 0)
# Ignore messages from self
if (
source_number == self.config.phone_number
or source == self.config.phone_number
):
logger.debug("Ignoring message from self")
return
# Data message (text)
data_msg = envelope.get("dataMessage", {})
if data_msg:
text = data_msg.get("message", "")
group_info = data_msg.get("groupInfo", {})
is_group = bool(group_info)
chat_id = (
group_info.get("groupId", source_number) if is_group else source_number
)
msg_ts = data_msg.get("timestamp", timestamp)
media_paths: list[str] = []
annotations: list[str] = []
_VOICE_TYPES = {
"audio/aac",
"audio/ogg",
"audio/mp4",
"audio/mpeg",
"audio/opus",
}
attachments = data_msg.get("attachments", [])
for att in attachments:
att_size = att.get("size", 0)
att_name = att.get("filename", "attachment")
att_file = att.get("file") # signal-cli provides local path
content_type = att.get("contentType", "")
is_voice = content_type in _VOICE_TYPES or att.get("voiceNote", False)
media_label = "voice" if is_voice else "attachment"
if att_file:
from pathlib import Path as _Path
att_path = _Path(att_file)
if att_path.exists():
from ..base import MAX_ATTACHMENT_BYTES
if att_path.stat().st_size > MAX_ATTACHMENT_BYTES:
annotations.append(
f"[{media_label}: {att_name} - too large ({att_path.stat().st_size} bytes)]"
)
else:
local = self._media_path(f"signal_{att_name}")
import shutil
shutil.copy2(str(att_path), str(local))
media_paths.append(str(local))
annotations.append(f"[{media_label}: {local}]")
else:
annotations.append(
f"[{media_label}: {att_name} - file not found]"
)
elif att_size:
too_large = self._check_attachment_size(att_size, att_name)
if too_large:
annotations.append(too_large)
else:
annotations.append(f"[{media_label}: {att_name}]")
if not text and not media_paths and not annotations:
if not attachments:
return
# Had attachments but none downloaded successfully
if not annotations:
text = "[attachment]"
try:
ts = datetime.fromtimestamp(msg_ts / 1000) if msg_ts else datetime.now()
except (ValueError, TypeError, OSError):
ts = datetime.now()
was_mentioned = not is_group # DMs always pass
if is_group:
mentions = data_msg.get("mentions", [])
for m in mentions:
if (
m.get("uuid") == self.config.phone_number
or m.get("number") == self.config.phone_number
):
was_mentioned = True
break
# Cache message_id → sender for reaction targetAuthor
self._cache_msg_sender(str(msg_ts), source_number)
logger.info(
"Signal message from %s: %s",
source_number,
text[:50] if text else "[media]",
)
await self._enqueue_raw(
RawIncoming(
sender_id=source_number,
chat_id=chat_id,
text=text,
content_annotations=annotations,
media_files=media_paths,
timestamp=ts,
message_id=str(msg_ts),
is_group=is_group,
was_mentioned=was_mentioned,
metadata={
"chat_id": chat_id,
"source_name": source_name,
"sender_id": source_number,
"backend": "signal",
},
)
)
# ── Typing indicator ────────────────────────────────────────────
async def _send_typing_action(self, chat_id: str) -> None:
"""Send typing indicator via signal-cli JSON RPC."""
params: dict[str, Any] = {
"account": self.config.phone_number,
}
if self._is_group_id(chat_id):
params["groupId"] = chat_id
else:
params["recipient"] = [chat_id]
try:
await self._rpc_call("sendTyping", params)
except Exception:
pass # typing indicator is best-effort
# ── ACK reaction ─────────────────────────────────────────────
def _cache_msg_sender(self, message_id: str, sender: str) -> None:
"""Store message_id → sender mapping for reaction targetAuthor."""
if len(self._msg_senders) >= 200:
oldest = self._msg_senders_order.popleft()
self._msg_senders.pop(oldest, None)
self._msg_senders[message_id] = sender
self._msg_senders_order.append(message_id)
async def _send_ack_reaction(
self, chat_id: str, message_id: str, emoji: str = "👀"
) -> None:
"""Send an acknowledgment reaction via signal-cli sendReaction."""
target_author = self._msg_senders.get(message_id, "")
if not target_author:
return # cannot send reaction without knowing the original sender
try:
params: dict[str, Any] = {
"account": self.config.phone_number,
"emoji": emoji,
"targetAuthor": target_author,
"targetTimestamp": int(message_id),
}
if self._is_group_id(chat_id):
params["groupId"] = chat_id
else:
params["recipient"] = [chat_id]
await self._rpc_call("sendReaction", params)
except Exception as e:
logger.debug(f"Signal ack reaction failed: {e}")
async def _remove_ack_reaction(
self, chat_id: str, message_id: str, emoji: str = "👀"
) -> None:
"""Remove ACK reaction via signal-cli sendReaction --remove."""
target_author = self._msg_senders.get(message_id, "")
if not target_author:
return
try:
params: dict[str, Any] = {
"account": self.config.phone_number,
"emoji": emoji,
"targetAuthor": target_author,
"targetTimestamp": int(message_id),
"remove": True,
}
if self._is_group_id(chat_id):
params["groupId"] = chat_id
else:
params["recipient"] = [chat_id]
await self._rpc_call("sendReaction", params)
except Exception as e:
logger.debug(f"Signal remove ACK reaction failed: {e}")
# ── Send ──────────────────────────────────────────────────────
@staticmethod
def _is_group_id(chat_id: str) -> bool:
"""Return True if *chat_id* looks like a Signal group ID.
Group IDs are base64-encoded strings (e.g. ``"aB3d...=="``).
Individual recipients are either phone numbers (``"+1234..."``)
or UUIDs (``"817ab5e9-..."``) — neither of which is a group.
"""
return not chat_id.startswith("+") and "-" not in chat_id
def _is_ready(self) -> bool:
return self._writer is not None and not self._writer.is_closing()
async def _rpc_call(
self, method: str, params: dict, timeout: float = 10.0
) -> dict | None:
"""Send a JSON RPC call to signal-cli and wait for the response."""
if not self._writer:
return None
self._rpc_id += 1
rpc_id = self._rpc_id
request = {
"jsonrpc": "2.0",
"id": rpc_id,
"method": method,
"params": params,
}
# Register a Future before sending so the listen loop can resolve it
loop = asyncio.get_running_loop()
fut: asyncio.Future = loop.create_future()
self._pending_rpcs[rpc_id] = fut
line = json.dumps(request) + "\n"
self._writer.write(line.encode())
await self._writer.drain()
try:
return await asyncio.wait_for(fut, timeout=timeout)
except TimeoutError:
self._pending_rpcs.pop(rpc_id, None)
logger.warning(f"Signal RPC '{method}' timed out after {timeout}s")
return None
async def _send_chunk(
self,
chat_id,
formatted_text,
raw_text,
reply_to,
metadata,
):
# Determine if group or individual
params: dict[str, Any] = {
"message": raw_text,
"account": self.config.phone_number,
}
if self._is_group_id(chat_id):
params["groupId"] = chat_id
else:
params["recipient"] = [chat_id]
await self._rpc_call("send", params)
# ── Mention stripping ────────────────────────────────────────────
def _strip_mention(self, text: str) -> str:
"""Strip bot mention from Signal messages.
Signal mentions are embedded as special objects that reference
the phone number. The text contains a placeholder character (U+FFFC)
at the mention position.
"""
phone = self.config.phone_number
if phone:
# Remove phone number if directly mentioned as text
text = re.sub(rf"@?{re.escape(phone)}\s*", "", text).strip()
# Remove Unicode Object Replacement Character used as mention placeholder
text = text.replace("\ufffc", "").strip()
return text
# ── Media send ────────────────────────────────────────────────
async def _send_media_impl(
self,
recipient: str,
file_path: str,
caption: str = "",
metadata: dict | None = None,
) -> bool:
"""Send a media file via signal-cli JSON RPC.
Uses the "send" RPC method with the attachments parameter.
"""
chat_id = self._resolve_media_chat_id(recipient, metadata)
params: dict[str, Any] = {
"account": self.config.phone_number,
"attachments": [file_path],
}
if caption:
params["message"] = caption
if self._is_group_id(chat_id):
params["groupId"] = chat_id
else:
params["recipient"] = [chat_id]
await self._rpc_call("send", params)
return True