Files
EvoScientist-Multi/EvoScientist/channels/slack/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

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]}"
)