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>
349 lines
13 KiB
Python
349 lines
13 KiB
Python
"""Slack channel implementation using slack-sdk Socket Mode."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
from ..base import Channel, ChannelError, RawIncoming
|
|
from ..capabilities import SLACK as SLACK_CAPS
|
|
from ..config import BaseChannelConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class SlackConfig(BaseChannelConfig):
|
|
bot_token: str = ""
|
|
app_token: str = ""
|
|
text_chunk_limit: int = 4096
|
|
|
|
|
|
def _slack_response_types() -> tuple[type, ...]:
|
|
from slack_sdk.web.async_slack_response import AsyncSlackResponse
|
|
from slack_sdk.web.slack_response import SlackResponse
|
|
|
|
return (SlackResponse, AsyncSlackResponse)
|
|
|
|
|
|
class SlackChannel(Channel):
|
|
"""Slack channel using slack-sdk Socket Mode."""
|
|
|
|
name = "slack"
|
|
|
|
capabilities = SLACK_CAPS
|
|
_ready_attrs = ("_web_client",)
|
|
_mention_pattern = r"<@{bot_id}>\s*"
|
|
|
|
def __init__(self, config: SlackConfig):
|
|
super().__init__(config)
|
|
self._socket_client = None
|
|
self._web_client = None
|
|
self._typing_message_ts: dict[str, str] = {}
|
|
|
|
async def start(self) -> None:
|
|
if not self.config.bot_token:
|
|
raise ChannelError("Slack bot token is required")
|
|
if not self.config.app_token:
|
|
raise ChannelError(
|
|
"Slack app token is required for Socket Mode (starts with xapp-)"
|
|
)
|
|
|
|
try:
|
|
from slack_sdk.socket_mode.aiohttp import SocketModeClient
|
|
from slack_sdk.socket_mode.request import SocketModeRequest
|
|
from slack_sdk.socket_mode.response import SocketModeResponse
|
|
from slack_sdk.web.async_client import AsyncWebClient
|
|
except ImportError:
|
|
raise ChannelError(
|
|
"slack-sdk or aiohttp not installed. "
|
|
"Install with: pip install evoscientist[slack]"
|
|
) from None
|
|
|
|
self._web_client = AsyncWebClient(
|
|
token=self.config.bot_token,
|
|
proxy=self._get_proxy(),
|
|
)
|
|
|
|
# Get bot user ID for filtering own messages
|
|
try:
|
|
auth = await asyncio.wait_for(
|
|
self._web_client.auth_test(),
|
|
timeout=15,
|
|
)
|
|
self._bot_user_id = auth["user_id"]
|
|
except TimeoutError:
|
|
raise ChannelError(
|
|
"Slack auth_test timed out — check network and bot token"
|
|
) from None
|
|
except Exception as e:
|
|
raise ChannelError(f"Failed to authenticate Slack bot: {e}") from e
|
|
|
|
self._socket_client = SocketModeClient(
|
|
app_token=self.config.app_token,
|
|
web_client=self._web_client,
|
|
)
|
|
|
|
async def _event_handler(
|
|
client: SocketModeClient,
|
|
req: SocketModeRequest,
|
|
) -> None:
|
|
# Acknowledge immediately
|
|
resp = SocketModeResponse(envelope_id=req.envelope_id)
|
|
await client.send_socket_mode_response(resp)
|
|
|
|
logger.debug(f"Slack socket event: type={req.type}")
|
|
|
|
if req.type == "events_api":
|
|
event = req.payload.get("event", {})
|
|
event_type = event.get("type", "")
|
|
if event_type == "message" and "subtype" not in event:
|
|
is_dm = event.get("channel_type") == "im"
|
|
await self._on_message(
|
|
event,
|
|
is_group=not is_dm,
|
|
was_mentioned=is_dm,
|
|
)
|
|
elif event_type == "app_mention":
|
|
await self._on_message(
|
|
event,
|
|
is_group=True,
|
|
was_mentioned=True,
|
|
)
|
|
|
|
self._socket_client.socket_mode_request_listeners.append(_event_handler)
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._socket_client.connect(),
|
|
timeout=30,
|
|
)
|
|
except TimeoutError:
|
|
raise ChannelError(
|
|
"Slack Socket Mode connection timed out — "
|
|
"check app token (must start with xapp-) and "
|
|
"ensure Socket Mode is enabled in your Slack app settings"
|
|
) from None
|
|
self._running = True
|
|
logger.info("Slack channel started (Socket Mode)")
|
|
|
|
async def _cleanup(self) -> None:
|
|
if self._socket_client:
|
|
await self._socket_client.close()
|
|
logger.info("Slack channel stopped")
|
|
|
|
# ── Typing indicator (override base) ────────────────────────────
|
|
|
|
async def _send_typing_action(self, chat_id: str) -> None:
|
|
"""Send typing indicator via Slack.
|
|
|
|
Slack's Web API and Socket Mode do not expose a dedicated
|
|
typing-indicator endpoint for bot tokens. We approximate
|
|
the experience by posting a short-lived status message that
|
|
is deleted once the real reply is sent (handled by
|
|
``stop_typing``). When the status post fails we silently
|
|
fall back to no indicator.
|
|
"""
|
|
if not self._web_client:
|
|
return
|
|
try:
|
|
resp = await self._web_client.chat_postMessage(
|
|
channel=chat_id,
|
|
text="\u2026", # "…" ellipsis as minimal typing hint
|
|
)
|
|
ts = resp.get("ts")
|
|
if ts:
|
|
self._typing_message_ts[chat_id] = ts
|
|
except Exception:
|
|
pass
|
|
|
|
async def stop_typing(self, chat_id: str) -> None:
|
|
"""Cancel typing loop and clean up the status message."""
|
|
# Delete the ephemeral "…" message if we posted one
|
|
ts = self._typing_message_ts.pop(chat_id, None)
|
|
if ts and self._web_client:
|
|
try:
|
|
await self._web_client.chat_delete(channel=chat_id, ts=ts)
|
|
except Exception:
|
|
pass
|
|
await super().stop_typing(chat_id)
|
|
|
|
# ── Send (template method overrides) ──────────────────────────
|
|
|
|
async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
|
|
kwargs = {"channel": chat_id}
|
|
# Always route to thread if thread_ts is present in metadata,
|
|
# not just for the first chunk (reply_to is only set for chunk 0).
|
|
if metadata:
|
|
thread_ts = metadata.get("thread_ts")
|
|
if thread_ts:
|
|
kwargs["thread_ts"] = thread_ts
|
|
|
|
async def _send(text):
|
|
await self._web_client.chat_postMessage(text=text, **kwargs)
|
|
|
|
await self._send_with_format_fallback(_send, formatted_text, raw_text)
|
|
|
|
async def _send_media_impl(
|
|
self,
|
|
recipient: str,
|
|
file_path: str,
|
|
caption: str = "",
|
|
metadata: dict | None = None,
|
|
) -> bool:
|
|
"""Send a media file through Slack."""
|
|
channel_id = self._resolve_media_chat_id(recipient, metadata)
|
|
await self._web_client.files_upload_v2(
|
|
channel=channel_id,
|
|
file=file_path,
|
|
initial_comment=caption or None,
|
|
)
|
|
return True
|
|
|
|
def _get_bot_identifier(self) -> str | None:
|
|
return getattr(self, "_bot_user_id", None)
|
|
|
|
# ── Retry error code extraction (override base) ─────────────────
|
|
|
|
def _extract_status_code(self, exc: Exception) -> int | None:
|
|
"""Extract HTTP status code from SlackApiError or fallback to base."""
|
|
from slack_sdk.errors import SlackApiError
|
|
|
|
if isinstance(exc, SlackApiError) and isinstance(
|
|
exc.response, _slack_response_types()
|
|
):
|
|
return exc.response.status_code
|
|
return super()._extract_status_code(exc)
|
|
|
|
def _extract_sdk_error_code(self, exc: Exception) -> str | None:
|
|
"""Extract structured error code string from SlackApiError."""
|
|
from slack_sdk.errors import SlackApiError
|
|
|
|
if isinstance(exc, SlackApiError) and isinstance(
|
|
exc.response, _slack_response_types()
|
|
):
|
|
error = exc.response.get("error")
|
|
return error.lower() if isinstance(error, str) else None
|
|
return super()._extract_sdk_error_code(exc)
|
|
|
|
def _extract_retry_delay(self, exc: Exception) -> float | None:
|
|
"""Read Slack's ``Retry-After`` header from a SlackApiError.
|
|
|
|
``SlackResponse.headers`` is a plain ``dict`` whose key casing depends
|
|
on the HTTP client, so match the key case-insensitively (the same
|
|
approach slack_sdk's own ``RateLimitErrorRetryHandler`` takes).
|
|
"""
|
|
from slack_sdk.errors import SlackApiError
|
|
|
|
if isinstance(exc, SlackApiError):
|
|
for key, raw in exc.response.headers.items():
|
|
if key.lower() == "retry-after":
|
|
return self._parse_retry_after(raw)
|
|
return None
|
|
return super()._extract_retry_delay(exc)
|
|
|
|
# ── ACK Reactions ───────────────────────────────────────────────
|
|
|
|
async def _send_ack_reaction(
|
|
self, chat_id: str, message_id: str, emoji: str = "eyes"
|
|
) -> None:
|
|
"""Add an emoji reaction to acknowledge receipt."""
|
|
if self._web_client and message_id:
|
|
try:
|
|
await self._web_client.reactions_add(
|
|
channel=chat_id,
|
|
timestamp=message_id,
|
|
name=emoji,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Slack ACK reaction failed: {e}")
|
|
|
|
async def _remove_ack_reaction(
|
|
self, chat_id: str, message_id: str, emoji: str = "eyes"
|
|
) -> None:
|
|
"""Remove the ACK reaction after replying."""
|
|
if self._web_client and message_id:
|
|
try:
|
|
await self._web_client.reactions_remove(
|
|
channel=chat_id,
|
|
timestamp=message_id,
|
|
name=emoji,
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Slack remove ACK reaction failed: {e}")
|
|
|
|
async def _on_message(
|
|
self,
|
|
event: dict,
|
|
*,
|
|
is_group: bool = False,
|
|
was_mentioned: bool = True,
|
|
) -> None:
|
|
"""Handle an incoming Slack message event."""
|
|
user_id = event.get("user", "")
|
|
|
|
# Skip bot's own messages
|
|
if user_id == getattr(self, "_bot_user_id", None):
|
|
logger.debug("Skipping own bot message")
|
|
return
|
|
|
|
# Skip bot messages (e.g. from other bots)
|
|
if event.get("bot_id"):
|
|
logger.debug(f"Skipping bot message from bot_id={event.get('bot_id')}")
|
|
return
|
|
|
|
channel_id = event.get("channel", "")
|
|
|
|
text = event.get("text", "")
|
|
|
|
annotations: list[str] = []
|
|
media_paths: list[str] = []
|
|
|
|
# Handle file attachments
|
|
if self.config.include_attachments:
|
|
files = event.get("files", [])
|
|
for file_info in files:
|
|
file_size = file_info.get("size", 0)
|
|
filename = file_info.get("name", "unknown")
|
|
|
|
url = file_info.get("url_private_download") or file_info.get(
|
|
"url_private"
|
|
)
|
|
if url and self._web_client:
|
|
headers = {"Authorization": f"Bearer {self.config.bot_token}"}
|
|
local_path, annotation = await self._download_attachment(
|
|
url,
|
|
f"{file_info.get('id', 'unknown')}_{filename}",
|
|
headers=headers,
|
|
file_size=file_size,
|
|
)
|
|
if local_path:
|
|
media_paths.append(local_path)
|
|
if annotation:
|
|
annotations.append(annotation)
|
|
|
|
ts = event.get("ts", "")
|
|
thread_ts = event.get("thread_ts") or ts
|
|
try:
|
|
timestamp = datetime.fromtimestamp(float(ts)) if ts else datetime.now()
|
|
except (ValueError, TypeError):
|
|
timestamp = datetime.now()
|
|
|
|
await self._enqueue_raw(
|
|
RawIncoming(
|
|
sender_id=user_id,
|
|
chat_id=channel_id,
|
|
text=text,
|
|
media_files=media_paths,
|
|
content_annotations=annotations,
|
|
timestamp=timestamp,
|
|
message_id=ts,
|
|
metadata={"chat_id": channel_id, "thread_ts": thread_ts},
|
|
is_group=is_group,
|
|
was_mentioned=was_mentioned,
|
|
)
|
|
)
|
|
logger.info(
|
|
f"Slack message queued: sender={user_id}, "
|
|
f"channel={channel_id}, content={text[:50]}"
|
|
)
|