简直了,让自己完全没时间摸鱼
我愿称作高级牛马的自我修养——自己做皮鞭
踩坑都是我自己遇到的,如果你没有遇到或者与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_id

3.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_xxxxxxxxxxxxxxxxxxxxx

3.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"
}

测试成功后,飞书单聊中会收到应用机器人发来的消息。

2026-07-07T13:56:30.jpg


4. 本地环境准备

4.1 配置环境变量

本地脚本需要使用四个环境变量:

FEISHU_APP_ID
FEISHU_APP_SECRET
FEISHU_RECEIVE_ID
FEISHU_RECEIVE_ID_TYPE

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"

执行后会看到:

成功: 指定的值已得到保存。

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.10

4.4 Windows Store Python 别名问题

如果安装后仍然提示 Microsoft Store,可以检查:

Windows 设置
    → 应用
    → 高级应用设置
    → 应用执行别名

关闭:

python.exe
python3.exe

5. 编写并测试 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"

如果飞书收到消息,说明脚本已经跑通。

2026-07-07T13:57:15.png

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 Secret

7.4 后续优化方向

当前版本只实现了「执行完成通知」,后续可以继续扩展:

  • 通知中增加任务耗时。
  • 通知中区分成功 / 失败。
  • 通知中列出修改过的文件。
  • 通知里只显示项目名,而不是完整路径。
  • 不同项目发送到不同飞书会话。
  • 把脚本封装成一个可复用小工具。
  • 加入消息卡片,让通知更好看。

8. 官方文档索引

8.1 Codex 相关

8.2 飞书机器人与消息

8.3 飞书鉴权与用户身份


结论

最终稳定方案是:

Codex Stop Hook
    +
Python 本地脚本
    +
飞书应用机器人单聊 API

这套方案跑通后非常实用。Codex 在 VSCode 里执行任务时,我不用一直盯着窗口。只要它停下来,飞书就会给我发消息。

这次踩坑最多的地方不是代码本身,而是:

飞书 open_id
权限发布
Python 环境
Codex Hook 路径

把这些点处理好以后,整个链路其实很清晰,也很适合继续扩展成个人自动化通知系统。


后话

后来我又迭代了,现在是这样的。可以简单指令执行“修改”,“回退”等功能。
2026-07-07T13:58:07.jpg

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐