ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Claude Code 跑完任务,它自己发钉钉通知我——我TM怎么没早想到

Claude Code 跑完任务,它自己发钉钉通知我——我TM怎么没早想到 1. 为什么我非要让 Claude Code 自己发钉钉用 Claude Code 跑复杂任务的人应该都有这个体验任务丢进去然后你去干别的。五分钟后回来不知道跑完没十分钟后回来不确定是正常结束还是报错了再回来翻了半天终端才搞明白上次到底做了什么。整个等待过程你的注意力是碎的。我试过用 tmux 做通知效果确实漂亮但它只支持 Mac。我两台电脑一台 Mac 一台 Windows跨平台工作是常态装一个只能在一台机器上用的通知工具换台电脑就失效比没有更烦。飞书有通知 bot但我不想为了这个专门装一个 App企业微信太重Slack 在国内根本用不了。我用的是钉钉——工作原因已经开着了两台电脑都有通知都能收到这才叫解决问题。解法其实不复杂但我一开始没往这个方向想。Claude Code 有个东西叫 Hooks允许你在对话生命周期的特定节点执行自定义脚本。其中有一个 Stop Hook会在每次对话结束时自动触发。这就是注入通知逻辑的位置。思路很清晰对话结束 → 触发脚本 → 脚本调钉钉 Webhook → 我手机收到消息。整套实现我直接让 Claude Code 自己写的描述需求它把脚本和配置一起交付。核心步骤就四个在~/.claude/settings.json里注册 Stop Hook指向通知脚本脚本从 stdin 读取 Hook JSON里面有transcript_path、session_id这些解析 transcript JSONL把 assistant 最后一条消息提取出来当摘要调钉钉 Webhook发一条带时间、项目名、摘要的 Markdown 消息。有一个细节必须处理脚本里要检查stop_hook_active字段。如果这个字段是true说明当前已经在 Stop Hook 执行中了要直接退出——不然会无限循环。另外脚本必须以sys.exit(0)结束通知失败了也不能阻塞 Claude Code 的正常退出流程。这篇就把这套东西从零到跑通讲清楚包括 settings.json 的 Hooks 配置骨架、钉钉机器人 Webhook 地址与加签参数的填写位置以及一次任务跑完后钉钉收到消息的完整验证动作。适合已经在用 Claude Code、想让任务结束自动通知的人也适合想搞明白 Hooks 到底怎么落地的人。2. 前置准备TaoToken 接入与钉钉机器人创建2.1 用 TaoToken 给 Claude Code 提供模型接入Claude Code 本身是个客户端它需要一个能跑 Claude 系列模型的入口。我这边用的是 TaoToken官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。它的作用是给 Claude Code 这类工具提供统一的模型调用入口你不用自己折腾底层接入把 Key 配好就能跑。如果你还没配过先去控制台建一个 API Key。控制台入口https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。建好之后在 Claude Code 的环境变量里填上就行通常是ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY两个。具体字段名以你本地 Claude Code 版本的文档为准但思路就是把请求指向 TaoToken 的 API 地址把 Key 填进去。配好之后你可以先用模型对话验证一下链路通不通https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。能正常对话说明模型接入没问题接下来再搞 Hooks 通知才有意义——不然任务都跑不起来通知谁呢。2.2 钉钉自定义机器人 Webhook 与加签打开钉钉进一个你常看的群建议单独建一个「Claude Code 通知」群别混在工作群里群设置 → 智能群助手 → 添加机器人 → 自定义通过 Webhook 接入。创建时会让你选安全设置三种方式自定义关键词、加签、IP 白名单。推荐用加签最稳。创建完成后你会拿到两样东西Webhook 地址形如https://oapi.dingtalk.com/robot/send?access_tokenxxxxxx加签密钥形如SECxxxxxx这个只在创建时显示一次记得存好加签的原理是发送时用timestamp \n secret做 HMAC-SHA256再 base64 urlencode拼到 URL 的timestampxxxsignxxx后面。这个逻辑我们放在脚本里做你只需要把 secret 填到配置文件里。注意钉钉机器人有频率限制每分钟最多 20 条。正常任务通知完全够用但别拿它当日志推送用。3. 可复制配置settings.json 与通知脚本3.1 settings.json 的 Hooks 配置骨架Claude Code 的 Hooks 配置放在~/.claude/settings.json全局或项目根目录的.claude/settings.json项目级。Stop Hook 的骨架长这样{ hooks: { Stop: [ { matcher: , hooks: [ { type: command, command: python3 ~/.claude/hooks/notify.py, async: true } ] } ] } }几个关键点解释一下。Stop是事件名对应对话结束。matcher留空表示匹配所有情况。type是command表示执行一条命令。async: true让通知异步发送不卡 Claude Code 退出——这个很重要不然每次对话结束都要等通知发出去才退体验很差。command指向你的通知脚本Windows 上如果python3不认换成python或写绝对路径。3.2 通知脚本 notify.py脚本要做四件事读 stdin 的 Hook JSON、解析 transcript、组装消息、调钉钉 Webhook。下面是一个能直接跑的版本#!/usr/bin/env python3 import sys import json import os import time import hmac import hashlib import base64 import urllib.parse import urllib.request CONFIG_PATH os.path.expanduser(~/.claude/hooks/config.json) def load_config(): with open(CONFIG_PATH, r, encodingutf-8) as f: return json.load(f) def read_hook_input(): raw sys.stdin.read() if not raw.strip(): return {} return json.loads(raw) def extract_summary(transcript_path): if not transcript_path or not os.path.exists(transcript_path): return 无 transcript, 0 last_assistant turns 0 with open(transcript_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue try: obj json.loads(line) except json.JSONDecodeError: continue if obj.get(type) assistant: turns 1 content obj.get(message, {}).get(content, []) texts [c.get(text, ) for c in content if isinstance(c, dict) and c.get(type) text] if texts: last_assistant \n.join(texts) if len(last_assistant) 300: last_assistant last_assistant[:300] ... return last_assistant or 无文本摘要, turns def build_dingtalk_url(webhook, secret): if not secret: return webhook timestamp str(round(time.time() * 1000)) string_to_sign f{timestamp}\n{secret} hmac_code hmac.new( secret.encode(utf-8), string_to_sign.encode(utf-8), digestmodhashlib.sha256 ).digest() sign urllib.parse.quote_plus(base64.b64encode(hmac_code)) return f{webhook}timestamp{timestamp}sign{sign} def send_dingtalk(webhook, secret, title, text): url build_dingtalk_url(webhook, secret) payload { msgtype: markdown, markdown: { title: title, text: text } } data json.dumps(payload).encode(utf-8) req urllib.request.Request( url, datadata, headers{Content-Type: application/json} ) with urllib.request.urlopen(req, timeout10) as resp: return resp.read().decode(utf-8) def main(): hook read_hook_input() # 防止 Stop Hook 递归触发 if hook.get(stop_hook_active): sys.exit(0) try: config load_config() except Exception as e: print(fconfig load failed: {e}, filesys.stderr) sys.exit(0) ding config.get(dingtalk, {}) if not ding.get(enabled): sys.exit(0) webhook ding.get(webhook, ) secret ding.get(secret, ) if not webhook: sys.exit(0) transcript_path hook.get(transcript_path, ) session_id hook.get(session_id, unknown) cwd hook.get(cwd, os.getcwd()) project os.path.basename(cwd.rstrip(/\\)) or cwd summary, turns extract_summary(transcript_path) now time.strftime(%Y-%m-%d %H:%M:%S) title fClaude Code 任务完成 - {project} text ( f### Claude Code 任务完成\n\n f- **项目**{project}\n f- **时间**{now}\n f- **会话**{session_id[:8]}\n f- **对话轮数**{turns}\n\n f**最后一条回复摘要**\n\n{summary} ) try: result send_dingtalk(webhook, secret, title, text) print(result, filesys.stderr) except Exception as e: print(fsend failed: {e}, filesys.stderr) sys.exit(0) if __name__ __main__: main()3.3 config.json 填写位置同目录下建config.json把钉钉的 Webhook 和加签密钥填进去{ dingtalk: { enabled: true, webhook: https://oapi.dingtalk.com/robot/send?access_token你的access_token, secret: SEC你的加签密钥 } }webhook填创建机器人时拿到的完整地址secret填加签密钥。如果你选的是「自定义关键词」而不是加签secret留空字符串即可但记得消息里要包含你设的关键词比如「Claude」否则钉钉会拒收。注意config.json里含密钥别提交到 Git。建议加进.gitignore或者用环境变量读取。4. 验证请求跑一次任务看钉钉是否收到配置好之后先手动测一下脚本本身能不能发消息别一上来就依赖 Claude Code 触发不然出问题你分不清是脚本错还是 Hook 没触发。手动测试造一个假的 Hook JSON 喂给脚本。echo {session_id:test1234,transcript_path:,cwd:/Users/me/project,stop_hook_active:false} | python3 ~/.claude/hooks/notify.py如果钉钉群里收到一条「Claude Code 任务完成 - project」的消息说明脚本和 Webhook 都通了。如果没收到看终端 stderr 输出的错误信息通常是加签算错或者 Webhook 地址不对。脚本通了之后再验证 Hook 是否真的被触发。随便在 Claude Code 里跑一个小任务比如让它读一个文件然后总结。任务结束后正常情况下几秒内钉钉就会收到通知。如果没收到检查settings.json的路径对不对、command里的 python 路径在你的 shell 里能不能执行。实测下来从任务结束到钉钉收到消息延迟通常在 1 到 3 秒取决于网络。异步发送不会影响 Claude Code 本身的退出速度你该干嘛干嘛。5. 本篇常见错排查5.1 钉钉收不到消息脚本也没报错最常见的原因是安全设置不匹配。如果你选的是「自定义关键词」消息文本里必须包含那个关键词否则钉钉返回310000错误但脚本可能没打印出来。检查一下你的关键词是什么确保text字段里有。如果选的是加签确认secret填的是SEC开头那串别把 access_token 当 secret 填了。5.2 Stop Hook 无限循环如果你发现 Claude Code 卡住或者反复触发通知八成是没处理stop_hook_active。这个字段为true时表示当前已经在 Stop Hook 执行流程里了必须直接sys.exit(0)。上面脚本里已经处理了如果你自己改脚本别把这行删了。5.3 Windows 上 python3 找不到Windows 默认可能只有python没有python3。把settings.json里的command改成python ~/.claude/hooks/notify.py或者写 python.exe 的绝对路径。另外路径分隔符注意一下Windows 上用正斜杠或者双反斜杠都行别用单反斜杠。5.4 transcript 解析出来是空的transcript_path指向的 JSONL 文件格式可能随 Claude Code 版本变化。如果摘要一直是空的先手动打开那个文件看看结构确认 assistant 消息的字段路径。上面脚本假设的是obj[message][content]里找type text如果你的版本结构不同改extract_summary里的解析逻辑就行。5.5 通知发出去了但内容不对检查cwd字段。有些场景下 Hook JSON 里的cwd可能不是你预期的项目目录导致项目名显示错误。可以在脚本里加一行print(hook, filesys.stderr)把原始 JSON 打出来看看确认字段值。6. 接下来怎么用从通知到工作流跑通之后这套东西的价值不只是「知道任务结束了」。你可以顺着这个思路往下扩第一多渠道路由。现在的脚本是钉钉单渠道但结构上很容易改成适配器模式——主入口读 Hook 数据调度多个 adapter每个 adapter 负责一个平台。想加企业微信或者飞书新建一个 adapter 文件在 config 里加一行开关核心逻辑一行不动。第二条件过滤。不是每个任务都值得通知。可以在脚本里加判断只有对话轮数超过 N、或者项目名在白名单里、或者任务耗时超过阈值时才发。不然通知太频繁会变成噪音你又会开始忽略它。第三区分正常结束和异常中止。Stop Hook 触发时不一定都是正常完成有时候是报错退出。可以在消息里带上退出状态或者错误摘要用不同颜色或标题级别区分这样你一眼就能看出是「跑完了」还是「挂了」。第四历史归档。每次通知的同时把摘要追加到本地日志文件按日期分文件。这样你回头想查「上周那个任务到底做了什么」不用翻终端 scrollback直接看日志就行。如果你还在用 Claude Code 跑长任务建议先把这套 Stop Hook 钉钉通知跑起来。配置不复杂脚本可以直接复制改改 Webhook 和 secret 就能用。跑通之后你会发现注意力不再被无谓的等待占用这才是工具该有的样子。需要长期跑编码任务或者 Agent 工作流的可以看看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite API Key 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。Claude Code 相关的接入说明可以看 https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进