1. 为什么要在 Codex 执行完成后自动发飞书通知
在 VSCode 里用 Codex 跑任务,最难受的不是写代码,而是等它跑完。你切到浏览器看两眼,回来发现它早就停了;你去倒杯水,回来发现它卡在最后一步等你确认。尤其是让 Codex 做批量重构、跑测试、生成文档这类耗时任务时,人就被绑在窗口前,效率反而更低。
我想要的链路其实很朴素:Codex 在 VSCode 里执行结束,本机自动触发一个 Python 脚本,脚本调用飞书应用机器人,把「任务完成时间、项目路径、模型名、最后一条回复」推送到我的飞书单聊。这样我就可以把 VSCode 最小化,去干别的事,飞书响了再回来看结果。
这套方案适合三类人:一是经常用 Codex 做长任务的开发者;二是想把 Codex 接入自己自动化流程的人;三是已经在用飞书做团队协作、希望把本地开发动作同步到飞书的人。核心检索词就是 Codex、飞书、VSCode、Hook、Python,下面我会把整条链路拆成可复制的配置和脚本。
整条链路的关键在于 Codex 的 Hook 机制。Codex 提供了生命周期扩展点,其中 Stop 事件会在当前回合停止时触发,正好对应「任务执行完成」这个时机。我们只需要在 Hook 里挂一个本地命令,让它去跑 Python 脚本,脚本再走飞书的开放接口发消息。听起来步骤不少,但真正需要你手写的只有两个文件:一个hooks.json,一个feishu_codex_done.py。
另外,Codex 本身需要调用模型能力,如果你同时还在用别的 AI 编码工具,Key 管理会变得很乱。我这边统一用 TaoToken 做 Key 接入,Codex、其他 CLI 工具、脚本都走同一个 Key,省得每个工具配一遍。下面会先讲 TaoToken 的前置配置,再进入 Hook 和飞书部分。
2. TaoToken 统一 Key 前置配置
TaoToken 是一个面向开发者的模型接入平台,官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。它的作用是让你用一个 Key 接入多种模型能力,Codex、Claude Code、自己写的脚本都能复用同一套凭证,不用在多个平台之间来回切换。
对本文场景来说,TaoToken 解决的是「Codex 调用模型」这一环。Hook 和飞书通知是本地逻辑,但 Codex 执行任务本身需要模型服务,把这块统一到 TaoToken 之后,你只需要维护一个 Key,后面换工具、加脚本都不用重新配。
2.1 获取 API Key
登录 TaoToken 后进入控制台,找到 API Keys 页面:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。在这里创建一个新的 Key,建议按用途命名,比如codex-local,方便以后区分。
创建完成后立刻复制保存,页面刷新后就看不到完整 Key 了。这个 Key 后面会写进 Codex 的配置文件,也会被本地脚本读取,所以不要提交到 Git,也不要贴在公开聊天里。
2.2 在 Codex 中配置 TaoToken
Codex 的模型接入配置一般放在用户目录下的config.toml。Windows 路径是C:\Users\YourName\.codex\config.toml,macOS/Linux 是~/.codex/config.toml。下面是一个可复制的骨架,把YOUR_TAOTOKEN_KEY换成你刚创建的 Key:
# ~/.codex/config.toml model = "gpt-5.5" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"然后在系统环境变量里写入 Key。Windows PowerShell:
setx TAOTOKEN_API_KEY "YOUR_TAOTOKEN_KEY"macOS/Linux 写入~/.zshrc或~/.bashrc:
export TAOTOKEN_API_KEY="YOUR_TAOTOKEN_KEY"改完环境变量后,关闭当前终端重新打开,再重启 VSCode,让 Codex 扩展读到新变量。如果你不确定配置是否生效,可以在 Codex 对话里发一句简单指令,看它是否能正常返回,能返回就说明模型接入没问题。
2.3 为什么建议统一 Key
我试过同时维护三四个平台的 Key,结果就是每次换工具都要翻笔记,还容易把旧 Key 填到新工具里。统一到 TaoToken 之后,Codex、Claude Code、自己写的 Python 脚本都读同一个环境变量,换机器时也只需要配一次。对于本文这种「Codex + 本地脚本 + 飞书」的组合,Key 越少,排障路径越短。
3. 飞书应用机器人与 open_id 配置
飞书这边有两种机器人:自定义机器人 Webhook 和应用机器人。Webhook 更适合往群里发消息,配置简单但只能发群聊;应用机器人可以给指定用户发单聊,更适合「任务完成通知我本人」这个场景。本文用的是应用机器人。
3.1 创建自建应用并开通权限
进入飞书开放平台,创建一个企业自建应用,记下 App ID 和 App Secret。然后在「添加应用能力」里启用机器人。接着进入「权限管理」,搜索并开通im:message:send_as_bot,这个权限允许应用以机器人身份发消息。
权限变更后必须重新发布应用版本,否则权限不生效。路径是「应用发布 → 版本管理与发布 → 创建版本」,版本说明随便写,提交后如果提示免审核就直接生效。
3.2 获取当前应用下的 open_id
open_id 是用户在当前应用下的身份标识,不同应用之间不通用。很多人第一次失败就是因为复制了示例里的ou_xxx,结果返回:
{ "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" } ] }正确做法是在飞书开放平台的 API 调试台里,选择你刚创建的应用,选择成员为自己,调用获取 open_id 的接口,复制返回的ou_开头的值。这个值才是当前应用下真实存在的接收人 ID。
3.3 用 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 invalid。调试台能收到消息,说明应用配置和 open_id 都没问题,接下来才进入本地脚本环节。
4. 可复制的 Hook 与 Python 脚本配置
这一节是全文的核心,两个文件:hooks.json负责告诉 Codex 什么时候跑脚本,feishu_codex_done.py负责发飞书消息。
4.1 写入环境变量
脚本需要四个环境变量:App ID、App Secret、接收人 ID、接收人 ID 类型。Windows PowerShell:
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"setx只对之后启动的进程生效,所以执行完要关闭当前 PowerShell,重新打开,并重启 VSCode。否则 Codex 和脚本都读不到变量。
4.2 编写 Python 通知脚本
创建目录和脚本文件:
mkdir "$env:USERPROFILE\.codex\hooks" -Force notepad "$env:USERPROFILE\.codex\hooks\feishu_codex_done.py"写入下面这份脚本,它做了四件事:读取 Hook 传入的 stdin JSON、获取 tenant_access_token、发送飞书消息、把过程写进日志文件方便排障。
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") with open(LOG_PATH, "a", encoding="utf-8", errors="replace") as f: f.write(f"[{now}] {safe_unicode(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): 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": safe_unicode(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: APP_ID=%s APP_SECRET=%s RECEIVE_ID=%s" % ( bool(APP_ID), bool(APP_SECRET), bool(RECEIVE_ID))) print(json.dumps({"continue": True}, ensure_ascii=False)) return cwd = safe_unicode(hook_data.get("cwd", "")) model = safe_unicode(hook_data.get("model", "")) event = safe_unicode(hook_data.get("hook_event_name", "")) last_msg = cut(hook_data.get("last_assistant_message", ""), 500) now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") text = ( "Codex 执行完成\n\n" f"时间:{now}\n" f"项目:{cwd if cwd else '未知'}\n" f"模型:{model if model else '未知'}\n" f"事件:{event if event else 'Stop'}\n" f"最后回复:{last_msg if last_msg else '无'}" ) token = get_tenant_access_token() send_feishu_message(token, text) log("FEISHU SEND SUCCESS") except Exception: log("FEISHU NOTIFY FAILED:\n" + traceback.format_exc()) finally: print(json.dumps({"continue": True}, ensure_ascii=False)) if __name__ == "__main__": main()脚本最后输出{"continue": true}是给 Codex Hook 的约定返回,表示不阻断后续流程。
4.3 配置 hooks.json
创建 Hook 配置文件:
notepad "$env:USERPROFILE\.codex\hooks.json"写入下面内容,把YourName换成你的 Windows 用户名:
{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "python \"C:\\Users\\YourName\\.codex\\hooks\\feishu_codex_done.py\"", "timeout": 15, "statusMessage": "Sending Feishu notification" } ] } ] } }这里建议写绝对路径。我一开始用%USERPROFILE%变量,在 Codex Hook 里不一定会展开,改成绝对路径后稳定触发。
5. 验证请求与成功结果
配置写完后,先手动验证脚本,再验证 Hook。
5.1 手动测试脚本
不要直接python feishu_codex_done.py,脚本会等 stdin,看起来像卡住。正确方式是传一个空 JSON:
echo {} | python "$env:USERPROFILE\.codex\hooks\feishu_codex_done.py"如果飞书收到消息,说明脚本、环境变量、飞书应用配置都通了。再模拟一次 Codex 传入的数据:
'{"cwd":"D:\\TestProject","model":"gpt-5.5","hook_event_name":"Stop","last_assistant_message":"Test from local PowerShell"}' | python "$env:USERPROFILE\.codex\hooks\feishu_codex_done.py"这次飞书通知里应该能看到项目路径、模型名和最后回复。
5.2 在 VSCode 中确认 Hook
重启 VSCode,在 Codex 输入框里输入/hooks,如果配置被读取,会显示 Stop 事件对应的命令、超时和状态消息。如果提示未信任,在这个界面里启用信任。
5.3 触发一次真实任务
让 Codex 执行一个小任务,比如「请回复一句 hello,不要修改文件」。等它结束后,飞书应该自动收到通知。收到就说明整条链路跑通了:Codex Stop Hook → Python 脚本 → 飞书应用机器人 → 单聊通知。
6. 本篇常见错误排查
下面这些坑是我实际踩过的,按出现频率排序。
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 飞书返回 id not exist | open_id 不是当前应用下的真实 ID | 在 API 调试台重新获取 |
| 权限已添加但发不出消息 | 权限变更后未重新发布应用 | 创建版本并发布 |
| content invalid | content 写成了 JSON 对象 | 改成 JSON 字符串 |
| python 命令不可用 | 未安装 Python 或被 Store 别名拦截 | 安装 Python 并检查执行别名 |
| 手动运行脚本没反应 | 脚本在等 stdin | 用 `echo {} |
| Hook 不触发 | 未信任或路径未展开 | /hooks确认,改用绝对路径 |
| 环境变量读不到 | setx 后未重启终端和 VSCode | 关闭重开,重启 VSCode |
| 中文乱码 | 编码或引号问题 | 用脚本里的 safe_unicode 处理 |
如果脚本没发消息,先看日志文件C:\Users\YourName\.codex\hooks\feishu_codex_done.log,里面会记录 stdin 内容、HTTP 状态码和飞书返回体,比盲猜快很多。
另外提醒一句,App Secret 如果出现在截图或草稿里,建议立刻去飞书开放平台重置,然后重新setx并重启 VSCode。真实 App Secret、open_id、项目路径都不要提交到 Git。
7. 继续扩展与接入入口
跑通之后可以按需扩展:通知里加任务耗时、区分成功失败、列出修改过的文件、只显示项目名而不是完整路径、不同项目发到不同飞书会话、换成消息卡片让通知更好看。这些都是在 Python 脚本里改文本和字段,Hook 配置不用动。
如果你还没配 TaoToken 的 Key,可以从 API Keys 页面开始:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。想先验证模型是否正常返回,可以用模型对话页面:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。如果你打算长期用 Codex 做编码和 Agent 任务,Coding Plan 会更划算:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。接入过程中遇到配置问题,可以对照接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。用 Claude Code 的话,Anthropic 接入说明在这里:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite 。
整套方案里最容易卡住的不是代码,而是飞书 open_id、权限发布、Python 环境和 Hook 路径这四件事。把这四点处理好,剩下的就是复制粘贴。