There are three ways to wire AI into DingTalk — picking wrong doesn't just mean more code, it means messages that never arrive, rate limits, or a data boundary you can't explain to compliance
| Dimension | A: Internal Robot + Stream | B: Custom Group Robot Webhook | C: Third-Party SaaS |
|---|---|---|---|
| Receive messages | ✅ 1:1 and group, send and receive | ❌ Push to group only | ✅ Platform relays both ways |
| Public endpoint required | No — your service dials out over a long connection | No (outbound only) | No |
| Data boundary | Messages and replies stay on your servers | Push content via DingTalk servers | Conversations via third-party platform |
| Time to launch | 1–3 days (mostly connection and card debugging) | ~10 minutes | Half a day to 1 day (config-driven) |
| Best for | Production AI assistant, FAQ, workflow queries | Alerts, daily digests, CI/CD notices | Fast validation, no dev team |
One line: conversations need A, notifications fit B, quick validation uses C. The classic mistakes: trying to build a chatbot on B (it can't receive group messages), or jumping to C without checking the data boundary.
This is DingTalk's recommended path: your service uses Stream mode to dial out and hold a WebSocket long connection to DingTalk. An employee messages the robot → DingTalk pushes the message down that connection → your service calls the AI → the reply goes back up the same connection. Because there is no inbound HTTP callback, you need no public IP, no registered domain, and no inbound firewall port — it runs fine on an intranet-only machine. That is the big difference from the HTTP-callback route.
DingTalk developer console → "App Development" → "Internal Enterprise App" → create. Then add a robot inside the app and set the message receiving mode to Stream mode. Note three values:
Client ID (formerly AppKey): on the credentials pageClient Secret (formerly AppSecret): credentials page (shown once — store it in a secrets manager, never in git)robotCode: on the robot detail page; required when sending messagesBefore publishing, scope visibility to one small department as a canary, then widen it after validation.
The core of Stream mode: on startup, exchange your credentials for a temporary connection endpoint, connect, and subscribe to the robot message topic. If the connection drops, reconnect — that's the root of Pitfall 1 below.
# dingtalk_stream_bot.py — open a long connection and subscribe to robot messages
from dingtalk_stream import DingTalkStreamClient, Credential
from dingtalk_stream.chatbot import ChatbotHandler, ChatbotMessage
class AIHandler(ChatbotHandler):
async def process(self, callback):
msg = ChatbotMessage.from_dict(callback.data)
# ack first, process asynchronously — never wait for the AI inside the callback (Pitfall 3)
self.reply_text("Got it, working on it…", msg)
question = msg.text.content.strip()
answer = ask_ai(question) # your own AI service / knowledge base
self.reply_text(answer, msg)
return 200, "OK"
credential = Credential(CLIENT_ID, CLIENT_SECRET) # read from env vars, never hardcode
client = DingTalkStreamClient(credential)
client.register_callback_handler("/v1.0/im/bot/messages/get", AIHandler())
client.start_forever() # has built-in reconnect, but still needs process supervision
The most common debugging trap: the topic string is wrong — robot messages use a fixed topic, and a typo shows up as "connected but never receives anything", with no error in the log at all.
Reply to a conversation through the robot message API; for clickable interactions like approval progress or ticket status, use interactive cards. Both go through the same access_token, which must be cached centrally (valid 7200 seconds, with a fetch rate limit).
# dingtalk_client.py — centralized token cache + send robot message
import time, requests
_token, _ts = "", 0
def get_token():
global _token, _ts
if _token and time.time() - _ts < 7000: # 200s safety margin
return _token
r = requests.post("https://api.dingtalk.com/v1.0/oauth2/accessToken",
json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET},
timeout=5).json()
_token, _ts = r["accessToken"], time.time()
return _token
def reply_text(user_id, text):
requests.post(
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
headers={"x-acs-dingtalk-access-token": get_token()},
json={"robotCode": ROBOT_CODE, "userIds": [user_id],
"msgKey": "sampleText", "msgParam": '{"content": "%s"}' % text[:1800]},
timeout=5)
Note the last line: DingTalk wraps text content inside a JSON string in msgParam, so truncate or split long answers — otherwise the call fails outright (Pitfall 5).
Swap ask_ai() for your RAG knowledge base or LLM service. Three engineering points:
conversationId — don't concatenate unbounded history into the promptGroup settings → "Smart Group Assistant" → "Add Robot" → "Custom" gives you a webhook URL. POST JSON to it and the message lands in the group (optional signed verification):
curl 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"Deploy done: order service v2.3 is live"}}'
Its role is one-way notification: monitoring alerts, CI/CD results, daily digests. Three hard limits: it cannot receive group messages (no conversations), each robot has a per-minute send cap (high-frequency alerts must be aggregated), and @-mentions need a phone number or userId (not a display name). Text, markdown, link, and actionCard types are supported. Good for "tell humans what the system did" — wrong for "humans ask, system answers".
Managed platforms let you authorize DingTalk and configure a knowledge base from a web console — no long-connection code to write, live within half a day. Two situations fit: no dev capacity, or you want to validate the value of an AI assistant before committing engineering time.
But ask four questions before signing, or you'll pay for it later:
| Dimension | DingTalk | Feishu | WeCom |
|---|---|---|---|
| Main integration path | Internal robot + Stream long connection | Event subscription (long connection or HTTP callback) | Custom app + HTTP callback |
| Public endpoint needed | No — the client dials out | Either, long connection avoids it | Yes: HTTPS callback required |
| Callback timing pressure | None on the long connection | HTTP callbacks still expect a fast return | Must respond within 5 seconds or it retries |
| Deduplication key | Message ID | Event ID | MsgId |
| Intranet friendliness | High: no inbound port | Medium | Low: must expose a callback URL |
Conclusion: if your intranet restricts outbound traffic and you don't want to apply for a public domain just for callbacks, DingTalk's Stream mode is the easiest of the three to land. WeCom's 5-second callback limit needs the most engineering cushioning; Feishu sits in between. When you integrate several platforms, extract "receive → call AI → reply" into one shared gateway and keep platform differences in an adapter layer — otherwise adding the next platform means starting over.
Symptom: the robot is unresponsive in the morning; restarting the service fixes it.
Cause: an intermediate network device killed the long connection and the client never reconnected, or the process exited on an unhandled exception.
Symptom: the log says connected, employees get no response, and there is no error either.
Cause: the subscribed topic is wrong; or the robot isn't published and visibility doesn't include the asker; or the registered callback path doesn't match the message type.
Symptom: for complex questions the same message gets answered two or three times, or the user only ever gets "Got it".
Cause: waiting synchronously for the AI inside the callback so the platform re-pushes; or sending an ack and then never following up.
Symptom: during an incident the alerts flood, and the most important ones never reach the group.
Cause: custom group robots have a per-minute send cap; over the cap, messages are dropped or rejected.
Symptom: longer AI answers never arrive or only the first half shows up, with a parameter error from the API.
Cause: the text is wrapped in a JSON string inside msgParam — over-length content or unescaped quotes both break it.
json.dumps instead of string concatenation; split long content into multiple messages by paragraph, or reply with a summary plus a link to the knowledge base page.No approach is universally better — the choice comes down to three things: how strict your data boundary is, how much dev capacity you have, and what scenario you're solving. Don't spend engineering on Approach A for a notification feed; don't build a chatbot on Approach B (it can't receive); don't sign up for Approach C without asking the four data-boundary questions. Many teams' real path: validate quickly with C or B, then migrate to A as the long-term self-hosted home.
If you only keep one line: conversations → A, notifications → B, validation → C — don't mix them.