Skip to content

fix(dingtalk): fix async compatibility with dingtalk-stream SDK v2+#9131

Closed
Jsoneft wants to merge 1 commit into
NousResearch:mainfrom
Jsoneft:fix/dingtalk-stream-sdk-async-compat
Closed

fix(dingtalk): fix async compatibility with dingtalk-stream SDK v2+#9131
Jsoneft wants to merge 1 commit into
NousResearch:mainfrom
Jsoneft:fix/dingtalk-stream-sdk-async-compat

Conversation

@Jsoneft

@Jsoneft Jsoneft commented Apr 13, 2026

Copy link
Copy Markdown

Summary

The DingTalk adapter was written against an older synchronous version of the dingtalk-stream SDK. The SDK has since migrated to a fully async architecture, breaking the adapter in three distinct ways that caused all incoming messages to be silently dropped and all replies to fail.

Bug 1 — _run_stream used asyncio.to_thread() on an async coroutine

DingTalkStreamClient.start() is now an async def. Wrapping it in asyncio.to_thread() (which is meant for blocking sync functions) created a coroutine object that was passed as a callable but never awaited, so the WebSocket connection was never established.

# Before
await asyncio.to_thread(self._stream_client.start)

# After
await self._stream_client.start()

Bug 2 — _IncomingHandler.process was sync and missing message conversion

The SDK now dispatches via await handler.raw_process(callback_message)await handler.process(callback_message). Two problems:

  • process was a plain def, causing object tuple can't be used in 'await' expression
  • The argument is a CallbackMessage container; the actual chatbot fields live in .data and must be converted via ChatbotMessage.from_dict(callback_message.data) before use
# Before — sync, wrong type
def process(self, message: ChatbotMessage):
    future = asyncio.run_coroutine_threadsafe(...)
    future.result(timeout=60)

# After — async, correct conversion
async def process(self, callback_message: CallbackMessage):
    chatbot_msg = dingtalk_stream.ChatbotMessage.from_dict(callback_message.data)
    await self._adapter._on_message(chatbot_msg)

Bug 3 — SSRF-guard regex rejected valid sessionWebhook URLs

DingTalk's sessionWebhook URLs use the oapi.dingtalk.com subdomain, but the regex only whitelisted api.dingtalk.com. Every incoming webhook was silently discarded, making replies impossible.

# Before
re.compile(r'^https://api\.dingtalk\.com/')

# After — covers all *.dingtalk.com subdomains
re.compile(r'^https://[a-zA-Z0-9\-]+\.dingtalk\.com/')

Enhancement — immediate acknowledgement message

Sends a ⏳ 思考中... text message to the user as soon as a message arrives, before the agent begins processing. This provides responsive feedback during longer inference calls.

Test plan

  • Send a direct message to the DingTalk bot; confirm ⏳ 思考中... appears immediately
  • Confirm the agent response is received within a few seconds
  • Confirm no coroutine was never awaited or tuple can't be used in await warnings in logs
  • Confirm sessionWebhook is stored and used correctly for replies

Environment

Tested with dingtalk-stream 0.x (async SDK), Python 3.12.

Made with Cursor

The dingtalk-stream SDK migrated to a fully async architecture.
This commit fixes three bugs that caused the DingTalk adapter to
silently fail with newer SDK versions:

1. `_run_stream`: replace `asyncio.to_thread(client.start)` with
   `await client.start()`. The SDK's `start()` is now an async
   coroutine; wrapping it in `to_thread` created an unawaited
   coroutine object and never established the WebSocket connection.

2. `_IncomingHandler.process`: rewrite as `async def` and add the
   missing `ChatbotMessage.from_dict(callback_message.data)` conversion.
   The SDK now calls `await handler.process(callback_message)` where
   `callback_message` is a `CallbackMessage` container — the actual
   chatbot fields live in `.data` and must be parsed explicitly.
   The old sync implementation caused `object tuple can't be used in
   'await' expression` errors and dropped all incoming messages.

3. `_DINGTALK_WEBHOOK_RE`: broaden the SSRF-guard regex from
   `api.dingtalk.com` to any `*.dingtalk.com` subdomain. DingTalk's
   `sessionWebhook` URLs use `oapi.dingtalk.com`, so the old pattern
   silently discarded every webhook, making replies impossible.

Also adds an immediate "⏳ 思考中..." acknowledgement message sent
to the user as soon as a message is received, before the agent
processes it, to provide responsive feedback.

Made-with: Cursor
@teknium1

Copy link
Copy Markdown
Contributor

Closing as superseded by #11471 (#11471) which salvaged @kevinskysunny's minimal fix (#11257) and added a follow-up for the broken _extract_text() path found during E2E testing.

Thanks for the fix — a lot of contributors hit this SDK break at the same time. Your investigation helped confirm the root cause.

@teknium1 teknium1 closed this Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants