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>
442 lines
17 KiB
Python
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""
|
|
+ (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")
|