agentscope-ai / agentscope-ai/QwenPaw

[Feature]: DingTalk channel: support interactive card callbacks in stream mode

Offen
#7,608 1 Kommentar 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
Vorherrschende Sprache
Python
Sterne
34.9k
Forks
3.1k
Ø Merge
1 T. 15 Std.
Gemergte PRs (30 T.)
225

Beschreibung

> **Submitted by**: 狄仁杰·Repairer@QPQAT

## Summary

The DingTalk channel creates interactive cards with `callback_type="STREAM"`, but the stream client never subscribes to the card callback topic (`/v1.0/card/instances/callback`). As a result, user interactions on cards (single-choice selections, form submissions, button clicks) are silently lost — the agent can never learn what the user chose, and cards cannot be updated in place based on that answer.

Request: register a handler for `Card_Callback_Router_Topic` on the **same** stream connection and expose card callback events to the upper layer (custom handler registration and/or a config switch).

## Component(s) Affected

- [ ] Core / Backend (app, agents, config, providers, utils, local_models)
- [ ] Console (frontend web UI)
- [x] Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.)
- [ ] Skills
- [ ] CLI
- [ ] Documentation (website)
- [ ] Tests
- [ ] CI/CD
- [ ] Scripts / Deploy

## Background

QwenPaw's DingTalk channel connects via the `dingtalk-stream` SDK (`dingtalk-stream>=0.24.3`) using a Stream (WebSocket) long connection, and already uses DingTalk interactive cards ("AI Card") to stream assistant replies back into the chat.

Two relevant facts about how that SDK works:

1. **Subscriptions are derived from registered handlers.** `DingTalkStreamClient.open_connection()` builds the `subscriptions` list of the connect request by iterating `self.callback_handler_map.keys()` (`dingtalk_stream/stream.py`, `open_connection`). A topic with no registered handler is therefore *never subscribed* — DingTalk will not even push those callbacks down the connection.
2. **Card interactions arrive on their own topic.** Interactive cards created with `callbackType="STREAM"` deliver user actions to `/v1.0/card/instances/callback`, exposed by the SDK as `dingtalk_stream.Card_Callback_Router_Topic` (`card_callback.py`) and `CallbackHandler.TOPIC_CARD_CALLBACK` (`handlers.py`). The payload is parsed by `CardCallbackMessage.from_dict()`, whose `content` field carries the user's selection / form values and whose `outTrackId` identifies the card instance.

## Current behavior

**Only the chatbot message topic is registered** — `src/qwenpaw/app/channels/dingtalk/channel.py` (lines 2851-2854 at `origin/main` @ `1cfaad4ef`, version `2.2.1b1`):

```python
self._client = dingtalk_stream.DingTalkStreamClient(credential)
...
internal_handler = DingTalkChannelHandler(...)
self._client.register_callback_handler(
ChatbotMessage.TOPIC, # '/v1.0/im/bot/messages/get'
internal_handler,
)
```

There is no `register_callback_handler(Card_Callback_Router_Topic, ...)` anywhere in the channel.

**Yet the card is created asking for STREAM callbacks** — same file, line 3153:

```python
create_request = dingtalk_card_models.CreateCardRequest(
card_template_id=self.card_template_id,
out_track_id=card_instance_id,
card_data=dingtalk_card_models.CreateCardRequestCardData(...),
callback_type="STREAM",
...
)
```

So the sending side declares "call me back over Stream", while the receiving side never subscribes to that callback topic and has no handler for it. The link is a dead end:

- The connect request subscribes to `/v1.0/im/bot/messages/get` only, so card action callbacks are not delivered at all.
- Even if such a message did arrive, `route_message()` would find no handler in `callback_handler_map` and log `"unknown callback message topic"` before dropping it — with no ACK, which also means DingTalk-side retries/timeouts.

**Observable impact:** a card with buttons/radio/form controls renders fine and users can click it, but QwenPaw receives nothing. The conversation cannot continue from the card, and the card cannot be updated to reflect the user's choice.

## Expected behavior

1. On the existing stream client, register a handler for `Card_Callback_Router_Topic` so the connect request subscribes to `/v1.0/card/instances/callback`.
2. Parse the callback into a usable event (`CardCallbackMessage`: `outTrackId` / card instance id, `userId`, `content` with the user's selection or form values, `extension`).
3. Return a proper ACK (`AckMessage.STATUS_OK`) so DingTalk considers the callback handled.
4. Expose the event to the upper layer instead of swallowing it inside the channel — ideally via both:
- a pluggable/custom handler registration hook (so agent or skill code can react to a specific card's answer), and
- a config switch (e.g. `DINGTALK_CARD_CALLBACK_ENABLED`, default off for backward compatibility) so existing deployments see no behavior change unless they opt in.
5. Correlate the callback back to the originating card instance (the channel already tracks active cards in `self._active_cards` / `AICardPendingStore`) so the answer can be routed to the right session, and the card can then be updated in place — the update primitives are already there (`StreamingUpdateRequest` in `_stream_ai_card`, plus `CardReplier.put_card_data` in the SDK).

## Use case

**Interactive decision cards.** When an agent needs the user to pick between options — "which of these three fixes should I apply?", "approve / reject this change?", "which environment should I deploy to?" — the natural UX on DingTalk is a card with buttons or a radio group, not asking the user to type a number as a new chat message. Today the agent can send such a card but is blind to the click, so the flow has to fall back to free-text replies.

**Approval cards.** Human-in-the-loop gates (deploy approval, destructive-operation confirmation) benefit from an auditable, one-click card answer bound to a specific card instance and user id, rather than parsing chat text.

**Form-style parameter collection.** Collecting several fields at once (target version, region, flags) in a single card submission, then updating the same card to show "received: …".

## Constraint: one stream connection per client-id

DingTalk allows only **one** Stream long connection per `client-id`. If a second process opens a connection with the same credentials just to pick up card callbacks, the two connections compete for the same incoming traffic — chatbot messages get diverted to whichever connection receives them, and the main agent silently stops seeing user messages. That makes the workaround "just run a separate callback listener process" unviable for anyone who also wants normal chat to keep working.

The only correct fix is to add the subscription **on the same stream client instance** that already handles `ChatbotMessage.TOPIC`, i.e. inside the channel's existing connection setup.

## Proposed implementation sketch (optional)

```python
from dingtalk_stream import (
CallbackHandler, CallbackMessage, AckMessage, Card_Callback_Router_Topic,
)

class DingTalkCardCallbackHandler(CallbackHandler):
def __init__(self, channel, main_loop):
super().__init__()
self._channel = channel
self._loop = main_loop

async def process(self, callback: CallbackMessage):
# CardCallbackMessage.from_dict(callback.data) gives
# outTrackId (card instance), userId, content (user's answer)
await self._channel._on_card_callback(callback.data)
return AckMessage.STATUS_OK, "OK"

# in the same place where ChatbotMessage.TOPIC is registered:
if self.card_callback_enabled:
self._client.register_callback_handler(
Card_Callback_Router_Topic,
DingTalkCardCallbackHandler(self, self._loop),
)
```

Notes:
- Registering before `start_forever()` is what makes `open_connection()` include the topic in `subscriptions`; no extra connection is needed.
- Keep it behind a config flag, default off, so the connect request of existing deployments is unchanged.
- The handler runs on the SDK's stream thread; hop to the channel's asyncio loop the same way `DingTalkChannelHandler` already does, and keep `process()` fast (hand off heavy work) so ACKs are not delayed.

## Alternatives considered

- **Separate process with the same client-id** — rejected: splits chatbot messages across connections (see Constraint), breaks the main agent.
- **HTTP callback endpoint instead of Stream** — requires a publicly reachable URL and a different DingTalk app configuration; Stream is already the channel's model and needs no new inbound port.
- **Free-text replies instead of card interactions** — the current de-facto workaround; loses the one-click UX, is ambiguous to parse, and cannot be bound reliably to a specific card instance.

## Additional context

- QwenPaw version checked: `2.2.1b1` at `origin/main` @ `1cfaad4ef` (latest tag `v2.2.0-beta.7`).
- SDK: `dingtalk-stream` 0.24.3; relevant symbols `Card_Callback_Router_Topic`, `CardCallbackMessage`, `CallbackHandler.TOPIC_CARD_CALLBACK`, `AckMessage.STATUS_OK`.
- Related SDK facilities already available but unused by the channel: `CardReplier.create_and_deliver_card`, `CardReplier.put_card_data`, `AICardReplier`.
- DingTalk interactive card callback topic: `/v1.0/card/instances/callback`.

## Willing to Contribute

We are a QA team running QwenPaw in a multi-agent test setup. We are not opening a PR for this right now, but we can help verify a fix end-to-end in our own DingTalk test environment (card with radio/form controls → callback received → card updated in place), and we can supply logs/payload captures from the current dead-end behavior if that helps.

Beitragsleitfaden

Beitragsleitfaden öffnen

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.