钉钉机器人接入 Claude Code:不用 API key 在钉钉里流式对话 + 遥控(dingtalk-stream + claude -p)
先说结论:在钉钉里直接和 Claude Code 对话、甚至遥控本机干活,不需要 Anthropic API key——复用你本机已登录的 Claude Code 订阅当大脑,带全套本地工具和 skill。效果:钉钉里 @机器人或单聊发一句,卡片上逐字打字机式回出来,每个会话各有记忆。
核心三件套:dingtalk-stream(Stream 模式)+ claude -p(无头流式)+ 钉钉 AI 卡片(流式更新)。本文给能直接照抄跑的完整最小代码 + 必做清单 + 踩过的坑。

一、开工前必做清单(缺一个就跑不起来)
| 必做项 | 怎么做 / 怎么确认 |
|---|---|
| ① 本机装好 Claude Code | 终端 claude --version 能出版本;which claude 记下路径(常见 ~/.local/bin/claude) |
| ② 钉钉企业内部应用 + 机器人 | open-dev.dingtalk.com → 创建企业内部应用 → 加「机器人」能力 |
| ③ 消息接收模式选 Stream | 机器人配置页选「Stream 模式」(这样不用公网回调地址) |
| ④ 开两个卡片权限 | 「权限管理」搜并开通 Card.Instance.Write(建卡)+ Card.Streaming.Write(流式写)。少一个卡片就空白/403 |
| ⑤ 拿 ClientID / ClientSecret | 「凭证与基础信息」复制,填进代码 |
| ⑥ 发布上线 + 拉进群 | 应用发布;钉钉群「群设置→智能群助手→添加机器人」选你这个(纯机器人不在工作台,群 @ 最稳) |
| ⑦ Python 依赖 | python3 -m venv ~/.venv-dingtalk && ~/.venv-dingtalk/bin/pip install dingtalk-stream requests |
④ 这两个权限是新手最容易漏的——卡片建出来但一直空白,八成就是少了
Card.Streaming.Write。
二、最小可运行版(完整代码,照抄能跑)
把下面存成 dingbot.py,填好 CLIENT_ID/CLIENT_SECRET,用 venv 跑:~/.venv-dingtalk/bin/python3 dingbot.py。然后在群里 @机器人 发句话,就能看到逐字回复。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""钉钉 ↔ Claude Code 最小可运行版:@机器人 → 本机 claude -p 无头流式 → 钉钉 AI 卡片打字机。"""
import json, time, uuid, asyncio, subprocess
import dingtalk_stream
from dingtalk_stream import AckMessage
CLIENT_ID = "<你的 ClientID>" # 开放平台「凭证与基础信息」
CLIENT_SECRET = "<你的 ClientSecret>"
CLAUDE = "claude" # claude CLI 路径,which claude 看;写绝对路径更稳
KNOWN = set() # 已建过的会话 id(决定 --session-id 新建 还是 --resume 续接)
def run_claude_stream(text, sid, on_delta):
"""无头流式跑 claude:逐字回调 on_delta(累计文本),返回完整文本。"""
flag = ["--resume", sid] if sid in KNOWN else ["--session-id", sid]
args = [CLAUDE, "-p"] + flag + [
"--output-format", "stream-json", "--include-partial-messages", "--verbose", text]
acc = []
proc = subprocess.Popen(args, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
evt = json.loads(line)
except json.JSONDecodeError:
continue
if evt.get("type") == "stream_event":
d = evt["event"].get("delta", {})
if d.get("type") == "text_delta" and d.get("text"):
acc.append(d["text"])
on_delta("".join(acc))
proc.wait()
KNOWN.add(sid)
return "".join(acc).strip()
class Handler(dingtalk_stream.ChatbotHandler):
async def process(self, callback):
msg = dingtalk_stream.ChatbotMessage.from_dict(callback.data)
text = " ".join(t for t in (msg.get_text_list() or []) if t and t.strip()).strip()
if not text:
return AckMessage.STATUS_OK, "OK"
# 立即 ack,答题丢后台跑(否则几十秒不 ack,钉钉超时重投)
asyncio.create_task(asyncio.to_thread(self.answer, msg, text))
return AckMessage.STATUS_OK, "OK"
def answer(self, msg, text):
conv = getattr(msg, "conversation_id", "") or "default"
sid = str(uuid.uuid5(uuid.NAMESPACE_DNS, "dingtalk:" + conv)) # 每会话一条独立 Claude 会话
card = self.ai_markdown_card_start(msg, title="Claude") # 内置 AI 卡片,免自建模板
card.set_order(["msgTitle", "msgContent"]) # 只留标题+正文
st = {"last": 0.0}
def on_delta(full):
now = time.time()
if now - st["last"] > 0.5: # 节流 ~0.5s 刷一次,防钉钉限流
try: card.ai_streaming(markdown=full, append=False)
except Exception: pass
st["last"] = now
out = run_claude_stream(text, sid, on_delta) or "(无输出)"
try: card.ai_streaming(markdown=out, append=False) # 补节流漏掉的最后一截(关键,见坑2)
except Exception: pass
try: card.ai_finish(markdown=out)
except Exception: pass
def main():
cred = dingtalk_stream.Credential(CLIENT_ID, CLIENT_SECRET)
client = dingtalk_stream.DingTalkStreamClient(cred)
client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, Handler())
client.start_forever()
if __name__ == "__main__":
main()
这一百来行就是完整闭环:收消息 → claude 无头流式 → 钉钉 AI 卡片逐字更新 + 会话记忆。下面拆解关键点和进阶。
三、它是怎么流式的

两端都要"流":
- claude 端:
--output-format stream-json --include-partial-messages --verbose让它吐逐字增量;逐字文本在事件stream_event → content_block_delta → text_delta里(见上面代码的解析)。 - 钉钉端:SDK 内置 AI Markdown 卡片
AIMarkdownCardInstance(自带模板 id,不用自建卡片模板),ai_start → ai_streaming → ai_finish就是打字机。每 ~0.5s 把累计全文全量刷进卡片(append=False)。
四、为什么 claude -p 能当大脑(不用 API key)
claude -p(--print)是 Claude Code 的非交互模式,复用你本机登录的订阅鉴权,不需要单独 API key;而且跑在本机、带本地全部工具(Bash/Read/Write/skill)。所以它不只是答问题——加上 --dangerously-skip-permissions 就能真动手(跑命令、改文件、出片),也就是"遥控"。
⚠️ 遥控有安全代价:群里发的话能在你电脑上执行命令。务必加发送者白名单(只认自己的钉钉 userid)、群保持私密。
五、进阶(在最小版上加什么)
- 新会话/清空记忆:给会话 id 派生加个"代数",发「新会话」就
gen+1换新 sid,记忆清空。 - 同会话串行锁:别并发对同一 sid 跑两个
--resume(连点按钮/边打字边点),会抢同一会话把卡片卡死——给每个 sid 加把锁排队。 - 可点的追问按钮:内置模板按钮加
"request": True就是回调按钮(点了把请求发回机器人,不是打开网页);点击走卡片回调 topic/v1.0/card/instances/callback,追问文字就在回调的content.cardPrivateData.params.text里。 - 看图:
msg.get_image_list()拿 downloadCode → 下载到本地 → 提示里给路径让 claude 用 Read 看图。 - 常驻:用 launchd / systemd 托管(掉了自拉),别 nohup。
踩过的坑
- 两个卡片权限:
Card.Instance.Write+Card.Streaming.Write,少一个卡片空白/403(最常见)。 - 回复被截断:流式按 0.5s 节流时最后一截可能没推上去,而
ai_finish刷不动流式显示 → finish 前先ai_streaming(完整全文)再 finish(上面代码已这么做)。 - 卡片卡死"输入中":同会话并发抢
--resume→ 给每个 session id 加串行锁;排队时先显示"⏳ 排队中"。 - 按钮一重启就点不动:别把"按钮→会话"映射只存内存(进程重启就丢)→ 追问文字从回调里取 + 卡片上下文落盘。
- 追问和内容无关:生成追问那次
claude -p会加载本机全局记忆/skill(CLAUDE.md/MEMORY.md)被带偏 → 在/tmp跑 +--setting-sources ""+ 基于真实答案生成。 - 不收文件:钉钉机器人 Stream 只收 text / 图片 / 富文本,收不了 PDF/Word。
- 群 webhook 关键词:若另用群自定义 webhook 主动推送,消息必须含设定关键词,否则
errcode:310000。 - 别 nohup 双开:用 launchd/systemd 托管;手动 nohup 容易和托管实例双开 → 重复回复。
快速参考
最小三件套:dingtalk-stream(Stream 收发)+ claude -p(无头大脑,复用订阅免 API key)+ AIMarkdownCardInstance(内置 AI 卡片流式)。
claude 流式:claude -p --resume <sid> --output-format stream-json --include-partial-messages --verbose,取 stream_event.event.delta.text_delta;首次用 --session-id 建。
会话记忆:sid = uuid5("dingtalk:"+conversationId)。
回调按钮:按钮加 "request": True;回调 topic /v1.0/card/instances/callback,文字在 content.cardPrivateData.params.text。
必开两权限:Card.Instance.Write + Card.Streaming.Write。
遥控:加 --dangerously-skip-permissions 才能动手;必加白名单 + 群保持私密。

浙公网安备 33010602011771号