简直了,让自己完全没时间摸鱼
我愿称作高级牛马的自我修养——自己做皮鞭
踩坑都是我自己遇到的,如果你没有遇到或者与i到别的可以飞书填写表单。
可以帮你安装Hooks,爱发电购买玉米粒(6.6R)就可以!
这篇文章记录一次从 0 到 1 搭建「VSCode Codex 执行完成后自动发送飞书单聊通知」的完整过程。
最终效果是:在 VSCode 中使用 Codex 执行任务,任务结束后,飞书会自动收到一条来自应用机器人的单聊消息。消息中可以包含执行时间、电脑名、项目路径、模型名、Hook 事件类型,以及 Codex 最后一条回复。
1. 总体思路与方案选择
1.1 最终目标
想实现的链路很简单:
Codex 执行结束 ↓ Codex Stop Hook 触发 ↓ 本地 Python 脚本执行 ↓ 获取飞书 tenant_access_token ↓ 调用飞书发送消息接口 ↓ 飞书应用机器人单聊通知我最终飞书收到的消息类似:
✅ Codex 执行完啦! 时间:2026-06-18 16:09:09 电脑:DESKTOP-XXXXXXX 项目:D:\YourProject 模型:gpt-5.5 事件:Stop 最后回复: Hi. What do you want to work on?1.2 为什么使用 Codex Stop Hook
Codex Hooks 是 Codex 提供的生命周期扩展机制,可以在特定事件发生时执行本地脚本。这里最适合使用的是Stop事件,因为它会在 Codex 当前回合停止时触发。
官方文档中提到,Hook 配置可以放在:
~/.codex/hooks.json <repo>/.codex/hooks.json对于本文场景,我使用的是用户全局配置:
C:\Users\YourName\.codex\hooks.json这样所有项目都可以复用同一套通知逻辑。
1.3 为什么不用群机器人 Webhook
一开始我考虑过飞书「自定义机器人 Webhook」。Webhook 方案很简单:
Codex Hook → 调用 Webhook → 飞书群收到消息但自定义机器人更偏向群聊通知,如果目标是「机器人直接单聊发给我」,更适合使用飞书开放平台的应用机器人能力。
所以最后采用的是:
飞书自建应用机器人 + tenant_access_token + open_id + 发送消息 API这样可以让应用机器人直接给指定用户发送单聊消息。
1.4 最终文件结构
最后本地会有两个关键文件:
C:\Users\YourName\.codex │ ├─ hooks.json │ └─ hooks └─ feishu_codex_done.py其中:
hooks.json负责告诉 Codex:当Stop事件触发时,运行哪个命令。
feishu_codex_done.py负责调用飞书接口,发送单聊通知。
2. 飞书开放平台配置
2.1 创建自建应用
进入飞书开放平台,创建一个自建应用,例如:
应用名称:Codex 应用类型:企业自建应用创建完成后,可以在「凭证与基础信息」中看到:
App ID App Secret这里要注意:
| 项目 | 说明 |
|---|---|
| App ID | 应用 ID,可以写入环境变量 |
| App Secret | 应用密钥,相当于密码,不要公开 |
| open_id | 用户在当前应用下的身份 ID,后面需要获取 |
如果 App Secret 曾经出现在截图、日志、博客草稿中,建议立刻重置。
2.2 添加机器人能力
在应用后台左侧找到:
添加应用能力 → 机器人添加完成后,可以在飞书客户端中看到这个应用机器人。此时它已经具备作为机器人出现的基础能力,但还不能保证可以主动发消息。
2.3 开通发送消息权限
进入:
权限管理 → 开通权限搜索:
im:message需要确认至少有这个权限:
以应用的身份发消息 im:message:send_as_bot如果已经显示「已添加」,就不需要重复添加。
2.4 发布应用版本
权限或机器人能力发生变更后,需要重新发布应用。
路径一般是:
应用发布 → 版本管理与发布 → 创建版本版本说明可以写:
添加机器人发送消息权限如果页面提示「本次发布免审核」,保存并提交发布后即可使用。
3. 获取接收人的 open_id
3.1 为什么不用邮箱
飞书发送消息接口支持多种receive_id_type,例如:
open_id user_id union_id email chat_id我这里不想用邮箱,而是希望机器人直接发给当前飞书用户,所以选择:
receive_id_type = open_id3.2 open_id 的坑
最开始我误用了 API 调试页面中的示例 open_id,结果发送失败,返回类似:
{"code":99992351,"msg":"The request you send is not a valid {open_id} or not exists","field_violations":[{"field":"receive_id","description":"id not exist"}]}这个错误的意思是:
receive_id 不是当前应用下真实存在的 open_id注意,open_id 是和应用相关的。不要随便复制别的页面、别的应用、示例代码里的ou_xxx。
3.3 正确获取 open_id
可以通过飞书开放平台的 API 调试台获取自己的 open_id。
常见方式是:
进入发送消息相关接口 ↓ 根据排查建议进入获取 open_id 的 API 调试台 ↓ 选择当前应用 ↓ 选择成员为自己 ↓ 调用接口 ↓ 复制返回的 open_id正确的 open_id 通常长这样:
ou_xxxxxxxxxxxxxxxxxxxxx3.4 在 API 调试台测试发送消息
在「发送消息」接口中测试:
POST /open-apis/im/v1/messages查询参数:
receive_id_type = open_id请求头:
Authorization = Bearer tenant_access_token请求体示例:
{"receive_id":"ou_xxxxxxxxxxxxxxxxxxxxx","msg_type":"text","content":"{\"text\":\"✅ Codex 单聊通知测试成功!\"}","uuid":"codex-test-001"}这里有一个很容易踩的坑:content必须是字符串,而不是普通 JSON 对象。
正确:
"content":"{\"text\":\"hello\"}"错误:
"content":{"text":"hello"}测试成功后,飞书单聊中会收到应用机器人发来的消息。
4. 本地环境准备
4.1 配置环境变量
本地脚本需要使用四个环境变量:
FEISHU_APP_ID FEISHU_APP_SECRET FEISHU_RECEIVE_ID FEISHU_RECEIVE_ID_TYPEPowerShell 中执行:
setx FEISHU_APP_ID"cli_xxxxxxxxxxxxxxxxx"setx FEISHU_APP_SECRET"你的 App Secret"setx FEISHU_RECEIVE_ID"ou_xxxxxxxxxxxxxxxxxxxxx"setx FEISHU_RECEIVE_ID_TYPE"open_id"执行后会看到:
成功: 指定的值已得到保存。4.2 setx 的坑
setx写入的是后续进程的环境变量,当前 PowerShell 窗口不一定能立刻读取到。
所以执行完setx后,需要:
关闭当前 PowerShell 重新打开 PowerShell 重启 VSCode否则脚本或 Codex 可能读不到环境变量。
4.3 安装 Python
一开始本机没有可用的 Python,运行下面命令都失败:
python--version python3--version py-3--version错误类似:
Python was not found; run without arguments to install from the Microsoft Store...可以使用 winget 安装 Python:
winget install--id Python.Python.3.12-e安装完成后,关闭 PowerShell,重新打开,再检查:
python--version成功时会显示类似:
Python 3.12.104.4 Windows Store Python 别名问题
如果安装后仍然提示 Microsoft Store,可以检查:
Windows 设置 → 应用 → 高级应用设置 → 应用执行别名关闭:
python.exe python3.exe5. 编写并测试 Python 通知脚本
5.1 创建脚本目录
创建 Hook 脚本目录:
mkdir"$env:USERPROFILE\.codex\hooks"-Force创建 Python 脚本:
notepad"$env:USERPROFILE\.codex\hooks\feishu_codex_done.py"5.2 完整 Python 脚本
写入下面代码(这里是最简单的,只有干完了和时间项目的信息,可以根据自己的需要拓展):
importdatetimeimportjsonimportosimportsysimporttracebackimporturllib.errorimporturllib.request APP_ID=os.environ.get("FEISHU_APP_ID","").strip()APP_SECRET=os.environ.get("FEISHU_APP_SECRET","").strip()RECEIVE_ID=os.environ.get("FEISHU_RECEIVE_ID","").strip()RECEIVE_ID_TYPE=os.environ.get("FEISHU_RECEIVE_ID_TYPE","open_id").strip()LOG_PATH=os.path.join(os.path.expanduser("~"),".codex","hooks","feishu_codex_done.log",)defsafe_unicode(text):iftextisNone:return""ifnotisinstance(text,str):text=str(text)returntext.encode("utf-8","replace").decode("utf-8","replace")defcut(text,max_len=2000):text=safe_unicode(text)ifnottext:return""returntextiflen(text)<=max_lenelsetext[:max_len]+"..."deflog(message):try:os.makedirs(os.path.dirname(LOG_PATH),exist_ok=True)now=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")message=safe_unicode(message)withopen(LOG_PATH,"a",encoding="utf-8",errors="replace")asf:f.write(f"[{now}]{message}\n")exceptException:passdefpost_json(url,payload,headers=None):data=json.dumps(payload,ensure_ascii=False).encode("utf-8","replace")req=urllib.request.Request(url,data=data,headers={"Content-Type":"application/json; charset=utf-8",**(headersor{}),},method="POST",)try:withurllib.request.urlopen(req,timeout=20)asresp:raw=resp.read().decode("utf-8",errors="replace")log(f"POST OK url={url}status={resp.status}body={cut(raw,3000)}")returnjson.loads(raw)excepturllib.error.HTTPErrorase:raw=e.read().decode("utf-8",errors="replace")log(f"POST HTTP ERROR url={url}status={e.code}body={cut(raw,3000)}")raiseexceptException:log("POST EXCEPTION:\n"+traceback.format_exc())raisedefrequire_feishu_ok(name,result):ifnotisinstance(result,dict):raiseRuntimeError(f"{name}失败:返回不是 JSON:{result}")ifresult.get("code")!=0:raiseRuntimeError(f"{name}失败:{result}")defget_tenant_access_token():result=post_json("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",{"app_id":APP_ID,"app_secret":APP_SECRET,},)require_feishu_ok("获取 tenant_access_token",result)returnresult["tenant_access_token"]defsend_feishu_message(token,text):text=safe_unicode(text)result=post_json(f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={RECEIVE_ID_TYPE}",{"receive_id":RECEIVE_ID,"msg_type":"text","content":json.dumps({"text":text},ensure_ascii=False),},headers={"Authorization":f"Bearer{token}",},)require_feishu_ok("发送飞书消息",result)returnresultdefread_hook_stdin():try:raw_stdin=sys.stdin.read()log(f"HOOK RAW STDIN:{cut(raw_stdin,3000)}")ifnotraw_stdin.strip():return{}returnjson.loads(raw_stdin)exceptException:log("HOOK STDIN JSON PARSE FAILED:\n"+traceback.format_exc())return{}defmain():try:hook_data=read_hook_stdin()ifnotAPP_IDornotAPP_SECRETornotRECEIVE_ID:log("MISSING ENV: "f"APP_ID={bool(APP_ID)}, "f"APP_SECRET={bool(APP_SECRET)}, "f"RECEIVE_ID={bool(RECEIVE_ID)}, "f"RECEIVE_ID_TYPE={RECEIVE_ID_TYPE}")print(json.dumps({"continue":True},ensure_ascii=False))returncwd=safe_unicode(hook_data.get("cwd",""))now=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")text=("尊敬的老大!小的干完了!!!🙇🏻♂️🙇🏻♂️🙇🏻♂️\n\n"f"时间:{now}\n"f"项目:{cwdifcwdelse'未知'}")token=get_tenant_access_token()result=send_feishu_message(token,text)log(f"FEISHU SEND SUCCESS:{result}")exceptException:log("FEISHU NOTIFY FAILED:\n"+traceback.format_exc())print(json.dumps({"continue":True},ensure_ascii=False))if__name__=="__main__":main()5.3 手动测试脚本
不要直接运行:
python"$env:USERPROFILE\.codex\hooks\feishu_codex_done.py"这样可能看起来「没反应」,因为脚本会等待stdin。
正确测试方式是传入一个空 JSON:
echo{}|python"$env:USERPROFILE\.codex\hooks\feishu_codex_done.py"如果飞书收到消息,说明脚本已经跑通。
5.4 模拟 Codex Hook 输入
可以用下面命令模拟 Codex 传入的数据:
'{"cwd":"D:\\TestProject","model":"codex","hook_event_name":"Stop","last_assistant_message":"Test from local PowerShell"}'|python"$env:USERPROFILE\.codex\hooks\feishu_codex_done.py"这样飞书通知里就会出现模拟项目路径、模型名和最后回复。
6. 配置 Codex Stop Hook
6.1 创建 hooks.json
创建或打开 Hook 配置文件:
notepad"$env:USERPROFILE\.codex\hooks.json"写入:
{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"python \"C:\\Users\\YourName\\.codex\\hooks\\feishu_codex_done.py\"","timeout":15,"statusMessage":"Sending Feishu notification"}]}]}}6.2 为什么建议写绝对路径
一开始我尝试写:
"command":"python \"%USERPROFILE%\\.codex\\hooks\\feishu_codex_done.py\""但在 Codex Hook 里,%USERPROFILE%不一定会像普通 cmd 那样展开。
最后改成绝对路径后稳定成功:
"command":"python \"C:\\Users\\YourName\\.codex\\hooks\\feishu_codex_done.py\""6.3 在 VSCode 中确认 Hook
重启 VSCode 后,在 Codex 输入框中输入:
/hooks如果配置被读取,会出现类似:
Stop command: python "C:\Users\YourName\.codex\hooks\feishu_codex_done.py" timeout: 15 statusMessage: Sending Feishu notification如果 Hook 没有被信任,需要在这个界面里启用或信任。
6.4 测试 Codex 自动通知
让 Codex 执行一个很小的任务:
请回复一句 hello,不要修改文件。等待 Codex 结束后,飞书应该自动收到一条通知。
这一步成功后,说明完整链路已经跑通:
Codex Stop Hook → Python 脚本 → 飞书应用机器人 → 单聊通知7. 踩坑复盘与安全收尾
7.1 这次踩过的坑
| 问题 | 原因 | 解决方式 |
|---|---|---|
| 自定义机器人不能单聊 | Webhook 更适合群聊通知 | 改用应用机器人发送消息 API |
id not exist | open_id 不是当前应用下真实 ID | 通过 API 调试台重新获取 |
| 权限已添加但不能发 | 权限变更后未重新发布 | 重新发布应用版本 |
content invalid | content 写成了 JSON 对象 | 改成 JSON 字符串 |
python不可用 | 本机未安装 Python 或被 Store 别名拦截 | 安装 Python 并检查执行别名 |
| 手动运行脚本没反应 | 脚本等待 stdin | 使用 `echo {} |
| PowerShell 脚本乱码 | 中文编码和引号复制问题 | 改用 Python 版脚本 |
| Hook 没触发 | Hook 未信任或路径未展开 | /hooks确认,改用绝对路径 |
7.2 App Secret 安全处理
如果 App Secret 曾经出现在截图、日志或文章草稿里,建议立即去飞书开放平台重置。
重置后重新写入环境变量:
setx FEISHU_APP_SECRET"新的 App Secret"然后重启 VSCode。
7.3 不要提交的内容
不要把下面内容提交到 Git:
真实 App Secret 真实 open_id 真实项目路径 包含密钥的截图如果要做成团队可复用工具,建议把敏感信息放在:
系统环境变量 .env 文件 凭据管理器 CI/CD Secret7.4 后续优化方向
当前版本只实现了「执行完成通知」,后续可以继续扩展:
- 通知中增加任务耗时。
- 通知中区分成功 / 失败。
- 通知中列出修改过的文件。
- 通知里只显示项目名,而不是完整路径。
- 不同项目发送到不同飞书会话。
- 把脚本封装成一个可复用小工具。
- 加入消息卡片,让通知更好看。
8. 官方文档索引
8.1 Codex 相关
- Codex Hooks 官方文档
- Codex IDE Extension Features
8.2 飞书机器人与消息
- 飞书机器人概述
- 自定义机器人使用指南
- 发送消息 API
- 消息常见问题
8.3 飞书鉴权与用户身份
- 自建应用获取 tenant_access_token
- 如何获取 Open ID
- 用户身份概述
- 发布与审核自建应用
结论
最终稳定方案是:
Codex Stop Hook + Python 本地脚本 + 飞书应用机器人单聊 API这套方案跑通后非常实用。Codex 在 VSCode 里执行任务时,我不用一直盯着窗口。只要它停下来,飞书就会给我发消息。
这次踩坑最多的地方不是代码本身,而是:
飞书 open_id 权限发布 Python 环境 Codex Hook 路径把这些点处理好以后,整个链路其实很清晰,也很适合继续扩展成个人自动化通知系统。
后话
后来我又迭代了,现在是这样的。可以简单指令执行“修改”,“回退”等功能。