简直了,让自己完全没时间摸鱼
我愿称作高级牛马的自我修养——自己做皮鞭
踩坑都是我自己遇到的,如果你没有遇到或者与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 脚本
写入下面代码(这里是最简单的,只有干完了和时间项目的信息,可以根据自己的需要拓展):
import datetime
import json
import os
import sys
import traceback
import urllib.error
import urllib.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",
)
def safe_unicode(text):
if text is None:
return ""
if not isinstance(text, str):
text = str(text)
return text.encode("utf-8", "replace").decode("utf-8", "replace")
def cut(text, max_len=2000):
text = safe_unicode(text)
if not text:
return ""
return text if len(text) <= max_len else text[:max_len] + "..."
def log(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)
with open(LOG_PATH, "a", encoding="utf-8", errors="replace") as f:
f.write(f"[{now}] {message}\n")
except Exception:
pass
def post_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",
**(headers or {}),
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
raw = resp.read().decode("utf-8", errors="replace")
log(f"POST OK url={url} status={resp.status} body={cut(raw, 3000)}")
return json.loads(raw)
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
log(f"POST HTTP ERROR url={url} status={e.code} body={cut(raw, 3000)}")
raise
except Exception:
log("POST EXCEPTION:\n" + traceback.format_exc())
raise
def require_feishu_ok(name, result):
if not isinstance(result, dict):
raise RuntimeError(f"{name} 失败:返回不是 JSON:{result}")
if result.get("code") != 0:
raise RuntimeError(f"{name} 失败:{result}")
def get_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)
return result["tenant_access_token"]
def send_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)
return result
def read_hook_stdin():
try:
raw_stdin = sys.stdin.read()
log(f"HOOK RAW STDIN: {cut(raw_stdin, 3000)}")
if not raw_stdin.strip():
return {}
return json.loads(raw_stdin)
except Exception:
log("HOOK STDIN JSON PARSE FAILED:\n" + traceback.format_exc())
return {}
def main():
try:
hook_data = read_hook_stdin()
if not APP_ID or not APP_SECRET or not RECEIVE_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))
return
cwd = safe_unicode(hook_data.get("cwd", ""))
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
text = (
"尊敬的老大!小的干完了!!!🙇🏻♂️🙇🏻♂️🙇🏻♂️\n\n"
f"时间:{now}\n"
f"项目:{cwd if cwd else '未知'}"
)
token = get_tenant_access_token()
result = send_feishu_message(token, text)
log(f"FEISHU SEND SUCCESS: {result}")
except Exception:
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 {} | python ...` |
| 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 相关
8.2 飞书机器人与消息
8.3 飞书鉴权与用户身份
结论
最终稳定方案是:
Codex Stop Hook
+
Python 本地脚本
+
飞书应用机器人单聊 API这套方案跑通后非常实用。Codex 在 VSCode 里执行任务时,我不用一直盯着窗口。只要它停下来,飞书就会给我发消息。
这次踩坑最多的地方不是代码本身,而是:
飞书 open_id
权限发布
Python 环境
Codex Hook 路径把这些点处理好以后,整个链路其实很清晰,也很适合继续扩展成个人自动化通知系统。
后话
后来我又迭代了,现在是这样的。可以简单指令执行“修改”,“回退”等功能。