9bd7a37a77
* 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>
515 lines
19 KiB
Python
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
|