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

442 lines
17 KiB
Python

"""DingTalk channel — refactored with WebSocketMixin + TokenMixin."""
import asyncio
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import ClassVar
from urllib.parse import quote_plus
from ..base import Channel, ChannelError, RawIncoming
from ..capabilities import DINGTALK as DINGTALK_CAPS
from ..config import BaseChannelConfig
from ..mixins import TokenMixin, WebSocketMixin
logger = logging.getLogger(__name__)
GATEWAY_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open"
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
MEDIA_SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
MEDIA_UPLOAD_URL = "https://oapi.dingtalk.com/media/upload"
FILE_DOWNLOAD_URL = "https://api.dingtalk.com/v1.0/robot/messageFiles/download"
@dataclass
class DingTalkConfig(BaseChannelConfig):
client_id: str = ""
client_secret: str = ""
text_chunk_limit: int = 4096
class DingTalkChannel(Channel, WebSocketMixin, TokenMixin):
capabilities = DINGTALK_CAPS
name = "dingtalk"
_ready_attrs = ("_http_client", "_access_token")
_non_retryable_patterns = (
*Channel._non_retryable_patterns,
"invalidauthentication",
"40014",
)
_mention_pattern = r"@\S+\s*"
_mention_strip_count = 1
def __init__(self, config: DingTalkConfig):
super().__init__(config)
async def start(self) -> None:
import httpx
if not self.config.client_id or not self.config.client_secret:
raise ChannelError("DingTalk client_id and client_secret are required")
self._http_client = httpx.AsyncClient(timeout=15, proxy=self.config.proxy)
await self._refresh_token()
self._running = True
logger.info("DingTalk channel starting (Stream Mode)...")
self._ws_task = asyncio.create_task(self._ws_loop())
# ── TokenMixin ────────────────────────────────────────────────
async def _fetch_token(self) -> tuple[str, int]:
data = await self._api_post(
TOKEN_URL,
{
"appKey": self.config.client_id,
"appSecret": self.config.client_secret,
},
)
token = data.get("accessToken")
if not token:
raise ChannelError(f"DingTalk auth error: {data}")
return token, int(data.get("expireIn", 7200))
async def _api_post(self, url, body, headers=None):
resp = await self._http_client.post(url, json=body, headers=headers)
return resp.json()
async def _resolve_download_code(self, download_code: str) -> str | None:
"""Exchange a DingTalk downloadCode for a real download URL."""
try:
token = await self._ensure_token()
data = await self._api_post(
FILE_DOWNLOAD_URL,
{"downloadCode": download_code, "robotCode": self.config.client_id},
headers={"x-acs-dingtalk-access-token": token},
)
url = data.get("downloadUrl") or ""
if url:
return url
logger.warning(f"DingTalk downloadCode resolve failed: {data}")
except Exception as e:
logger.warning(f"DingTalk downloadCode resolve error: {e}")
return None
# ── WebSocketMixin ────────────────────────────────────────────
async def _get_ws_url(self) -> str:
resp = await self._http_client.post(
GATEWAY_URL,
json={
"clientId": self.config.client_id,
"clientSecret": self.config.client_secret,
"subscriptions": [
{"type": "CALLBACK", "topic": "/v1.0/im/bot/messages/get"}
],
"ua": "dingtalk-sdk-python/v0.24.3-union",
},
)
data = resp.json()
endpoint, ticket = data.get("endpoint"), data.get("ticket")
if not endpoint or not ticket:
raise ChannelError(f"DingTalk gateway failed: {data}")
return f"{endpoint}?ticket={quote_plus(ticket)}"
async def _on_ws_message(self, data) -> None:
if not isinstance(data, dict):
return
headers = data.get("headers", {})
msg_id = headers.get("messageId", "")
# System ping
if data.get("type") == "SYSTEM" and headers.get("topic") == "ping":
await self._ws_send_json(
{
"code": 200,
"headers": headers,
"message": "OK",
"data": data.get("data", ""),
}
)
return
# ACK
await self._ws_send_json(
{
"code": 200,
"headers": {"contentType": "application/json", "messageId": msg_id},
"message": "OK",
"data": "{}",
}
)
if data.get("type") != "CALLBACK":
return
payload = data.get("data", "{}")
payload = json.loads(payload) if isinstance(payload, str) else payload
text_obj = payload.get("text", {})
content = (
text_obj.get("content", "") if isinstance(text_obj, dict) else str(text_obj)
).strip()
if not content:
raw_content = payload.get("content", "")
content = raw_content.strip() if isinstance(raw_content, str) else ""
# Download attachments if present
annotations: list[str] = []
media_paths: list[str] = []
# DingTalk file/image messages may put download info in
# payload["content"] (as a dict) instead of in a dedicated
# "fileContent"/"imageContent" key.
raw_content_obj = payload.get("content")
if isinstance(raw_content_obj, dict) and raw_content_obj not in [
payload.get(k)
for k in ("imageContent", "fileContent", "videoContent", "audioContent")
]:
msg_type = payload.get("msgtype") or payload.get("msgType") or ""
media_label = msg_type or "file"
file_size = (
raw_content_obj.get("fileSize")
or raw_content_obj.get("downloadSize")
or 0
)
file_name = (
raw_content_obj.get("fileName")
or raw_content_obj.get("name")
or f"dingtalk_{msg_type}"
)
download_code = raw_content_obj.get("downloadCode") or ""
download_url = raw_content_obj.get("downloadUrl") or ""
# downloadCode is NOT a URL — resolve it via DingTalk API first
if download_code and not download_code.startswith("http"):
resolved = await self._resolve_download_code(download_code)
if resolved:
download_url = resolved
elif download_code:
download_url = download_code
if download_url:
try:
dl_token = await self._ensure_token()
dl_headers = {"x-acs-dingtalk-access-token": dl_token}
except Exception:
dl_headers = None
local, ann = await self._download_attachment(
download_url,
f"dingtalk_{file_name}",
headers=dl_headers,
file_size=int(file_size) if file_size else None,
)
if local:
media_paths.append(local)
if ann:
ann = ann.replace("[attachment:", f"[{media_label}:")
annotations.append(ann)
elif file_name:
annotations.append(f"[{media_label}: {file_name}]")
for att_key in ("imageContent", "fileContent", "videoContent", "audioContent"):
att = payload.get(att_key)
if att and isinstance(att, dict):
file_size = att.get("fileSize") or att.get("downloadSize") or 0
file_name = att.get("fileName", att_key)
download_code = att.get("downloadCode") or ""
download_url = att.get("downloadUrl") or ""
# Resolve downloadCode via API if it's not a URL
if download_code and not download_code.startswith("http"):
resolved = await self._resolve_download_code(download_code)
if resolved:
download_url = resolved
elif download_code:
download_url = download_code
# DingTalk audioContent is voice messages
media_label = "voice" if att_key == "audioContent" else att_key
if download_url and (
self.config.include_attachments
if hasattr(self.config, "include_attachments")
else True
):
# DingTalk download URLs require access token
try:
dl_token = await self._ensure_token()
dl_headers = {"x-acs-dingtalk-access-token": dl_token}
except Exception:
dl_headers = None
local, ann = await self._download_attachment(
download_url,
f"dingtalk_{file_name}",
headers=dl_headers,
file_size=int(file_size) if file_size else None,
)
if local:
media_paths.append(local)
if ann:
ann = ann.replace("[attachment:", f"[{media_label}:")
annotations.append(ann)
elif file_size:
too_large = self._check_attachment_size(int(file_size), file_name)
if too_large:
annotations.append(too_large)
else:
annotations.append(f"[{media_label}: {file_name}]")
if not content and not media_paths and not annotations:
return
sender_id = payload.get("senderStaffId") or payload.get("senderId", "")
is_group = payload.get("conversationType") == "2"
# For send API (oToMessages/batchSend), userIds needs staffId, not conversationId
chat_id = sender_id
create_time = payload.get("createAt") or payload.get("createTime", "")
# Mention gating: DMs always pass; groups require @bot
was_mentioned = not is_group
if is_group:
# isInAtList is set by DingTalk when bot is @mentioned
if payload.get("isInAtList"):
was_mentioned = True
else:
# Fallback: check atUsers array
at_users = payload.get("atUsers") or []
for u in at_users:
if u.get("dingtalkId") == self.config.client_id:
was_mentioned = True
break
try:
ts = (
datetime.fromtimestamp(int(create_time) / 1000)
if create_time
else datetime.now()
)
except (ValueError, TypeError, OSError):
ts = datetime.now()
await self._enqueue_raw(
RawIncoming(
sender_id=sender_id,
chat_id=chat_id,
text=content,
timestamp=ts,
message_id=msg_id,
is_group=is_group,
was_mentioned=was_mentioned,
media_files=media_paths,
content_annotations=annotations,
metadata={
"chat_id": chat_id,
"sender_nick": payload.get("senderNick", ""),
"backend": "dingtalk",
},
)
)
# _send_typing_action: inherited no-op (DingTalk has no typing API)
# _format_chunk: inherited from base (UnifiedFormatter)
# ── Send ──────────────────────────────────────────────────────
async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
token = await self._ensure_token()
data = await self._api_post(
SEND_URL,
{
"robotCode": self.config.client_id,
"userIds": [chat_id],
"msgKey": "sampleMarkdown",
"msgParam": json.dumps({"text": raw_text, "title": "EvoScientist"}),
},
headers={"x-acs-dingtalk-access-token": token},
)
return data
# ── Media send ────────────────────────────────────────────────
_IMAGE_EXTS: ClassVar[set[str]] = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
async def _send_media_impl(
self,
recipient: str,
file_path: str,
caption: str = "",
metadata: dict | None = None,
) -> bool:
"""Send a media file through DingTalk.
For images: uploads via /media/upload to get media_id, then sends
as sampleImageMsg. Non-image files are sent as markdown links
(DingTalk robot API does not support arbitrary file uploads).
"""
token = await self._ensure_token()
chat_id = self._resolve_media_chat_id(recipient, metadata)
headers = {"x-acs-dingtalk-access-token": token}
ext = Path(file_path).suffix.lower()
if ext in self._IMAGE_EXTS:
# Try uploading image to get media_id for native image message
media_id = await self._upload_dingtalk_media(token, file_path, "image")
if media_id:
await self._api_post(
MEDIA_SEND_URL,
{
"robotCode": self.config.client_id,
"userIds": [chat_id],
"msgKey": "sampleImageMsg",
"msgParam": json.dumps({"photoURL": media_id}),
},
headers=headers,
)
else:
# Fallback to markdown with file path
await self._api_post(
MEDIA_SEND_URL,
{
"robotCode": self.config.client_id,
"userIds": [chat_id],
"msgKey": "sampleMarkdown",
"msgParam": json.dumps(
{
"text": f"![image]({file_path})"
+ (f"\n{caption}" if caption else ""),
"title": caption or "Image",
}
),
},
headers=headers,
)
else:
# Non-image: send as markdown with filename
name = Path(file_path).name
text = f"[文件] {name}" + (f"\n{caption}" if caption else "")
await self._api_post(
MEDIA_SEND_URL,
{
"robotCode": self.config.client_id,
"userIds": [chat_id],
"msgKey": "sampleMarkdown",
"msgParam": json.dumps({"text": text, "title": name}),
},
headers=headers,
)
if caption and ext in self._IMAGE_EXTS:
# Send caption separately for image messages
await self._api_post(
MEDIA_SEND_URL,
{
"robotCode": self.config.client_id,
"userIds": [chat_id],
"msgKey": "sampleMarkdown",
"msgParam": json.dumps({"text": caption, "title": "Caption"}),
},
headers=headers,
)
return True
async def _upload_dingtalk_media(
self,
token: str,
file_path: str,
media_type: str = "image",
) -> str | None:
"""Upload a file to DingTalk media API and return the media_id."""
try:
url = f"{MEDIA_UPLOAD_URL}?access_token={token}&type={media_type}"
with open(file_path, "rb") as f:
resp = await self._http_client.post(
url,
files={"media": (Path(file_path).name, f)},
)
data = resp.json()
return data.get("media_id")
except Exception as e:
logger.warning(f"DingTalk media upload failed: {e}")
return None
async def _cleanup(self) -> None:
if hasattr(self, "_ws_task") and self._ws_task:
self._ws_task.cancel()
try:
await self._ws_task
except (asyncio.CancelledError, Exception):
pass
self._ws_task = None
await self._stop_ws()
if hasattr(self, "_http_client") and self._http_client:
await self._http_client.aclose()
self._http_client = None
self._access_token = None
logger.info("DingTalk channel stopped")