前言 这次折腾的目标很明确:让 Codex 能辅助操作 Windows 版个人微信。
我想要的不是一个完全托管的微信机器人,而是一个更安全的本地微信助手:
打开 Windows 微信主窗口
搜索指定联系人或群聊
进入聊天窗口
截图查看最新可见消息
让 Codex 帮我组织回复
把回复填进输入框
不自动发送
个人微信没有稳定的官方开放 API,扫码登录型机器人又有登录态、风控和隐私风险。最后我采用的是更贴近桌面使用习惯的方案:个人微信 + Windows 微信客户端 + WinAutoWx + 本地 MCP 。
本文记录完整实现方式,包括我做过哪些修改、完整代码怎么写、Codex 怎么配置,以及读者如何按步骤复现。
最终效果 最终调用链是这样的:
1 2 3 4 5 6 7 Codex ↓ MCP WinAutoWx MCP Server ↓ HTTP WinAutoWx FastAPI Server ↓ pywinauto / clipboard / screenshot Windows 微信客户端
实际使用流程大概是:
1 2 3 4 5 open_chat("某联系人") capture_current_window() Codex 根据截图理解聊天上下文 Codex 生成回复 draft_message("回复内容")
最后一步只把内容粘贴到微信输入框,不会按回车,不会点发送按钮。
用到的工具 这次主要用到:
Codex:MCP 客户端和 AI 助手
MCP:把本地微信自动化能力暴露给 Codex
pmhw/WinAutoWx :Windows 微信自动化项目
pywinauto:操作 Windows 桌面应用
FastAPI:提供本地 HTTP API
fastmcp:提供 MCP stdio 服务
Pillow ImageGrab:截图微信窗口
pyperclip:把回复写入剪贴板再粘贴
个人微信自动化请谨慎使用。本文的重点是本地辅助和草稿模式,不建议做群发、营销、长期无人值守自动回复。
目录占位说明 下面示例里会用占位符,读者需要替换成自己的实际路径:
1 2 3 <你的用户名> 替换成自己的 Windows 用户名 <你的工作目录> 替换成自己准备用来存放项目的目录 <WinAutoWx目录> 替换成 WinAutoWx 克隆后的完整目录
比如 Codex 配置文件路径应该按自己的用户名替换:
1 C:\Users\<你的用户名>\.codex\config.toml
如果你的 Windows 用户名是 zhangsan,那就是:
1 C:\Users\zhangsan\.codex\config.toml
第一步:克隆 WinAutoWx 先把 pmhw/WinAutoWx 克隆到本地。
1 git clone https://github.com/pmhw/WinAutoWx.git "<你的工作目录>\WinAutoWx"
例如:
1 git clone https://github.com/pmhw/WinAutoWx.git "D:\Tools\WinAutoWx"
进入项目目录:
第二步:安装依赖 如果你本机已经安装了 Python 3.8-3.12,可以直接用:
1 python -m pip install --user -r requirements.txt
如果你想使用 Codex 自带的 Python,可以参考下面这种写法:
1 "C:\Users\<你的用户名>\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" -m pip install --user -r "<WinAutoWx目录>\requirements.txt"
注意:路径里的 <你的用户名> 和 <WinAutoWx目录> 都要替换。
第三步:修改 script/wechat_sender.py pmhw/WinAutoWx 原项目可以连接微信窗口,但我遇到一个问题:如果微信同时存在音视频通话窗口和主聊天窗口,top_window() 可能拿到的是通话窗口,而不是主聊天窗口。
典型窗口类似这样:
1 2 mmui::VOIPWindow # 音视频通话窗口 mmui::MainWindow # 微信主聊天窗口
所以需要在 script/wechat_sender.py 里改 attach_wechat 函数,让它连接到枚举出来的主窗口后,直接返回这个句柄对应的窗口。
找到下面这段逻辑:
1 2 3 chosen = _find_weixin_main_window() if chosen is not None : app.connect(handle=chosen.handle, timeout=2 )
改成:
1 2 3 4 chosen = _find_weixin_main_window() if chosen is not None : app.connect(handle=chosen.handle, timeout=2 ) return app.window(handle=chosen.handle)
完整的 attach_wechat 函数可以参考下面这版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 def attach_wechat (timeout: float = 20.0 ): """Attach to WeChat main window and return (app, main_window).""" app = Application(backend=BACKEND) def _get_window (): chosen = _find_weixin_main_window() if chosen is not None : app.connect(handle=chosen.handle, timeout=2 ) return app.window(handle=chosen.handle) else : try : app.connect(title_re="微信|WeChat|Weixin" , timeout=2 ) except Exception: app.connect(path_re=r"Weixin\.exe|WeChat\.exe" , timeout=2 ) win = app.top_window() name = (win.element_info.name or "" ).lower() if not ("微信" in name or "wechat" in name or "weixin" in name): win = app.window(title_re="微信|WeChat|Weixin" ) return win main_win = wait_until_passes(timeout, 1.0 , _get_window) main_win.wait("ready" , timeout=timeout) wrapper = main_win.wrapper_object() try : if getattr (wrapper, "is_minimized" , None ) and wrapper.is_minimized(): _log("正在还原已最小化的窗口 ..." ) wrapper.restore() except Exception: pass _log("正在将焦点置于 Weixin 窗口 ..." ) wrapper.set_focus() try : if hasattr (wrapper, "set_keyboard_focus" ): wrapper.set_keyboard_focus() except Exception: pass time.sleep(0.3 ) return app, main_win
这个修改的作用是:即使通话窗口在前台,自动化也尽量回到微信主聊天窗口。
第四步:替换 server.py 原项目的 server.py 主要提供发送消息和 dump 控件能力。我在它的基础上增加了三个安全接口:
/open_chat:搜索并进入聊天
/capture:截图当前微信窗口
/draft:把消息粘贴到输入框,但不发送
其中 /draft 是最关键的接口。它不会复用原来的发送函数,因为原发送函数会按 Enter 或 Ctrl+Enter,存在误发送风险。
把 <WinAutoWx目录>\server.py 替换为下面完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 from fastapi import FastAPIfrom pydantic import BaseModel, Fieldfrom typing import List from datetime import datetimefrom pathlib import Pathfrom PIL import ImageGrabimport pyperclipfrom pywinauto import keyboardfrom script import wechat_sender as wsapp = FastAPI(title="Weixin Auto Sender API" , version="2.0" ) CAPTURE_DIR = Path(__file__).resolve().parent / "captures" class SendRequest (BaseModel ): friends: List [str ] = Field(..., description="好友/群聊名称列表" ) messages: List [str ] = Field(..., description="要发送的消息列表" ) backend: str = Field("uia" , description="后端:uia 或 win32" ) ctrl_enter: bool = Field(False , description="是否使用 Ctrl+Enter 发送" ) friend_delay: float = Field(0.5 , description="切换聊天后的等待秒数" ) message_delay: float = Field(0.2 , description="每条消息的等待秒数" ) no_launch: bool = Field(False , description="不自动启动微信/Weixin" ) verbose: bool = Field(False , description="中文详细日志" ) class DumpRequest (BaseModel ): backend: str = Field("uia" , description="后端:uia 或 win32" ) verbose: bool = Field(True , description="中文详细日志" ) class OpenChatRequest (BaseModel ): friend: str = Field(..., description="好友或群聊名称" ) backend: str = Field("uia" , description="后端:uia 或 win32" ) friend_delay: float = Field(0.5 , description="切换聊天后的等待秒数" ) no_launch: bool = Field(False , description="不自动启动微信/Weixin" ) verbose: bool = Field(False , description="中文详细日志" ) class DraftRequest (BaseModel ): message: str = Field(..., description="要填入输入框但不发送的消息" ) backend: str = Field("uia" , description="后端:uia 或 win32" ) message_delay: float = Field(0.2 , description="输入后的等待秒数" ) no_launch: bool = Field(False , description="不自动启动微信/Weixin" ) verbose: bool = Field(False , description="中文详细日志" ) class CaptureRequest (BaseModel ): backend: str = Field("uia" , description="后端:uia 或 win32" ) no_launch: bool = Field(False , description="不自动启动微信/Weixin" ) verbose: bool = Field(False , description="中文详细日志" ) @app.post("/send" ) async def send_messages (req: SendRequest ): ws.BACKEND = req.backend ws.VERBOSE = req.verbose ws.ensure_wechat_running(start_if_needed=not req.no_launch) _, main_win = ws.attach_wechat() for friend in req.friends: ws.focus_search_and_open_chat(main_win, friend) ws.time.sleep(req.friend_delay) for msg in req.messages: ws.send_message_to_current_chat( main_win, msg, delay=req.message_delay, press_enter_to_send=(not req.ctrl_enter), ) return {"ok" : True } @app.post("/dump" ) async def dump_controls (req: DumpRequest ): ws.BACKEND = req.backend ws.VERBOSE = req.verbose ws.ensure_wechat_running(start_if_needed=True ) _, main_win = ws.attach_wechat() out = [] try : ctrls = main_win.descendants() except Exception: return {"ok" : False , "error" : "enumerate_failed" } count = 0 for c in ctrls: try : ei = c.element_info out.append({ "type" : getattr (ei, "control_type" , "" ), "name" : getattr (ei, "name" , "" ), "class" : getattr (ei, "class_name" , "" ), }) except Exception: pass count += 1 if count >= 80 : break return {"ok" : True , "controls" : out} @app.post("/open_chat" ) async def open_chat (req: OpenChatRequest ): ws.BACKEND = req.backend ws.VERBOSE = req.verbose ws.ensure_wechat_running(start_if_needed=not req.no_launch) _, main_win = ws.attach_wechat() ws.focus_search_and_open_chat(main_win, req.friend) ws.time.sleep(req.friend_delay) return {"ok" : True , "friend" : req.friend} @app.post("/draft" ) async def draft_message (req: DraftRequest ): ws.BACKEND = req.backend ws.VERBOSE = req.verbose ws.ensure_wechat_running(start_if_needed=not req.no_launch) _, main_win = ws.attach_wechat() focused = ws._focus_message_input(main_win) if not focused: ws._click_bottom_chat_area(main_win, clicks=2 ) focused = ws._focus_message_input(main_win) pyperclip.copy(req.message) keyboard.send_keys("^v" ) ws.time.sleep(req.message_delay) return {"ok" : True , "drafted" : True , "sent" : False } @app.post("/capture" ) async def capture_window (req: CaptureRequest ): ws.BACKEND = req.backend ws.VERBOSE = req.verbose ws.ensure_wechat_running(start_if_needed=not req.no_launch) _, main_win = ws.attach_wechat() rect = main_win.element_info.rectangle CAPTURE_DIR.mkdir(parents=True , exist_ok=True ) path = CAPTURE_DIR / f"wechat-{datetime.now().strftime('%Y%m%d-%H%M%S' )} .png" image = ImageGrab.grab(bbox=(rect.left, rect.top, rect.right, rect.bottom)) image.save(path) return {"ok" : True , "screenshot" : str (path)}
第五步:替换 mcp_server.py 接着把 HTTP 能力暴露给 Codex 的 MCP。
把 <WinAutoWx目录>\mcp_server.py 替换为下面完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 import osfrom typing import List from fastmcp import FastMCPimport httpxAPI_URL = os.environ.get("WEIXIN_API_URL" , "http://127.0.0.1:8000" ) app = FastMCP( name="weixin-auto-sender" , version="2.0.0" , ) @app.tool() async def send_messages ( friends: List [str ], messages: List [str ], backend: str = "win32" , ctrl_enter: bool = False , friend_delay: float = 0.5 , message_delay: float = 0.2 , no_launch: bool = False , verbose: bool = False , ) -> dict : """向好友/群聊发送消息。注意:这个工具会真的发送,建议谨慎启用。""" async with httpx.AsyncClient(timeout=60.0 , trust_env=False ) as client: resp = await client.post( f"{API_URL} /send" , json={ "friends" : friends, "messages" : messages, "backend" : backend, "ctrl_enter" : ctrl_enter, "friend_delay" : friend_delay, "message_delay" : message_delay, "no_launch" : no_launch, "verbose" : verbose, }, ) resp.raise_for_status() return resp.json() @app.tool() async def dump_controls ( backend: str = "win32" , verbose: bool = True , ) -> dict : """导出 Weixin 主窗口的前若干个控件信息。""" async with httpx.AsyncClient(timeout=60.0 , trust_env=False ) as client: resp = await client.post( f"{API_URL} /dump" , json={ "backend" : backend, "verbose" : verbose, }, ) resp.raise_for_status() return resp.json() @app.tool() async def open_chat ( friend: str , backend: str = "win32" , friend_delay: float = 0.5 , no_launch: bool = False , verbose: bool = False , ) -> dict : """搜索好友/群聊并进入聊天窗口,不发送消息。""" async with httpx.AsyncClient(timeout=60.0 , trust_env=False ) as client: resp = await client.post( f"{API_URL} /open_chat" , json={ "friend" : friend, "backend" : backend, "friend_delay" : friend_delay, "no_launch" : no_launch, "verbose" : verbose, }, ) resp.raise_for_status() return resp.json() @app.tool() async def draft_message ( message: str , backend: str = "win32" , message_delay: float = 0.2 , no_launch: bool = False , verbose: bool = False , ) -> dict : """将消息粘贴到当前微信聊天输入框,但绝不发送。""" async with httpx.AsyncClient(timeout=60.0 , trust_env=False ) as client: resp = await client.post( f"{API_URL} /draft" , json={ "message" : message, "backend" : backend, "message_delay" : message_delay, "no_launch" : no_launch, "verbose" : verbose, }, ) resp.raise_for_status() return resp.json() @app.tool() async def capture_current_window ( backend: str = "win32" , no_launch: bool = False , verbose: bool = False , ) -> dict : """截取当前微信窗口,供 Codex 查看最新可见消息。""" async with httpx.AsyncClient(timeout=60.0 , trust_env=False ) as client: resp = await client.post( f"{API_URL} /capture" , json={ "backend" : backend, "no_launch" : no_launch, "verbose" : verbose, }, ) resp.raise_for_status() return resp.json() if __name__ == "__main__" : app.run()
这里保留了 send_messages,因为原项目就有这个能力。但如果你的目标和我一样是安全草稿模式,日常只使用:
1 2 3 open_chat capture_current_window draft_message
第六步:创建启动脚本 为了方便启动本地 HTTP API,可以在 <WinAutoWx目录> 下新建:
内容如下:
1 2 3 4 5 6 7 $ErrorActionPreference = "Stop" $Python = "C:\Users\<你的用户名>\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" $Repo = "<WinAutoWx目录>" Set-Location -LiteralPath $Repo & $Python -m uvicorn server:app --host 127.0 .0.1 --port 8000
如果你使用系统 Python,可以改成:
1 2 3 4 5 6 7 $ErrorActionPreference = "Stop" $Python = "python" $Repo = "<WinAutoWx目录>" Set-Location -LiteralPath $Repo & $Python -m uvicorn server:app --host 127.0 .0.1 --port 8000
启动服务:
1 powershell -ExecutionPolicy Bypass -File "<WinAutoWx目录>\start-winautowx-api.ps1"
看到类似输出就说明 HTTP 服务起来了:
1 Uvicorn running on http://127.0.0.1:8000
第七步:配置 Codex MCP 编辑 Codex 配置文件:
1 C:\Users\<你的用户名>\.codex\config.toml
加入下面配置:
1 2 3 4 5 6 7 [mcp_servers.winautowx] command = "C:\\Users\\<你的用户名>\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe" args = ["<WinAutoWx目录>\\mcp_server.py" ]env = { WEIXIN_API_URL = "http://127.0.0.1:8000" }startup_timeout_sec = 20 tool_timeout_sec = 60 default_tools_approval_mode = "prompt"
如果你使用系统 Python,可以写成:
1 2 3 4 5 6 7 [mcp_servers.winautowx] command = "python" args = ["<WinAutoWx目录>\\mcp_server.py" ]env = { WEIXIN_API_URL = "http://127.0.0.1:8000" }startup_timeout_sec = 20 tool_timeout_sec = 60 default_tools_approval_mode = "prompt"
这里建议保留:
1 default_tools_approval_mode = "prompt"
微信属于对外沟通工具,建议让 Codex 在关键动作前保留确认。
配置完成后,重启 Codex。
第八步:验证功能 先确认本地 API 在监听:
1 Get-NetTCPConnection -LocalPort 8000 -State Listen
然后可以用 HTTP 方式先测一下:
1 2 3 4 5 Invoke-RestMethod ` -Method Post ` -Uri "http://127.0.0.1:8000/dump" ` -ContentType "application/json" ` -Body '{"backend":"uia","verbose":false}'
截图测试:
1 2 3 4 5 Invoke-RestMethod ` -Method Post ` -Uri "http://127.0.0.1:8000/capture" ` -ContentType "application/json" ` -Body '{"backend":"uia","no_launch":true,"verbose":false}'
如果返回类似下面这样,就说明截图成功:
1 2 3 4 { "ok" : true , "screenshot" : "<WinAutoWx目录>\\captures\\wechat-20260602-000020.png" }
Codex 里能看到 MCP 工具后,可以让它调用:
1 2 3 open_chat("文件传输助手") capture_current_window() draft_message("这是一条测试草稿,不会自动发送")
建议第一次用 文件传输助手 测试,不要直接对真实联系人试。
实用说明 这套方案日常使用时,不建议把它当成“全自动微信机器人”,更适合当成一个本地微信副驾驶。我的常用方式是先让 Codex 打开指定聊天并截图,然后根据可见上下文帮我写回复,最后只把回复放进输入框,由我自己检查后手动发送。
一个比较稳妥的提示词可以这样写:
1 2 打开“文件传输助手”,截图查看当前聊天内容,然后帮我写一段自然一点的回复。 只生成草稿,不要发送。
如果是给真实联系人或群聊回复,我会把要求说得更具体:
1 2 3 打开“某联系人”,截图查看最新消息。 请根据截图里的上下文,帮我写一条简短、礼貌、不过度承诺的回复。 写入微信输入框作为草稿,不要发送。
实际使用时有几个小习惯很重要:
先确认聊天对象 :open_chat 是通过搜索进入聊天,如果好友、群聊、公众号名称很像,建议先截图确认当前窗口确实是目标会话。
先用文件传输助手测试 :改完代码、重启服务或升级微信后,先用 文件传输助手 跑一遍 open_chat、capture_current_window、draft_message。
让 Codex 明确停在草稿状态 :提示词里直接写“只生成草稿,不要发送”,同时 MCP 配置里保留 default_tools_approval_mode = "prompt"。
不要在敏感场景自动处理 :验证码、付款、地址、账号、隐私、工作承诺、投诉争议等内容,只让 Codex 辅助组织文字,最终判断和发送必须自己来。
截图只代表当前可见内容 :如果聊天记录很长,Codex 只能根据当前窗口截图理解上下文。需要更多上下文时,先手动滚动到关键位置,再让它重新截图。
微信窗口尽量保持正常大小 :窗口太小、被遮挡、最小化或刚弹出通话窗口时,截图和焦点定位都可能不稳定。出问题时先手动把微信主窗口点出来。
如果你只是想让 Codex 帮你润色一段话,也可以不用截图,直接让它写入当前输入框:
1 2 把下面这段话改得更礼貌一点,然后写入当前微信输入框作为草稿,不要发送: 今天可能来不及处理,我晚点看完再回复你。
这时 Codex 只需要调用 draft_message,适合用于快速润色、改语气、压缩长消息。
另外,建议把“会真正发送”的 send_messages 当成保留接口,而不是日常入口。如果只是个人辅助,最常用的三个工具应该是:
1 2 3 open_chat capture_current_window draft_message
这样既能让 Codex 参与理解和表达,又不会把最后的发送权交出去。
安全边界 我给这套方案设了几个边界:
默认只截图、读上下文、生成草稿
不让 AI 自动发送微信消息
不自动处理付款、地址、账号、验证码等敏感内容
涉及承诺、投诉、金钱、隐私时,只生成草稿
最终发送动作由自己手动完成
一句话:让 AI 做副驾驶,不让它自己开走。
常见问题 1. 为什么不用扫码登录型微信机器人? 个人微信扫码登录型方案更像机器人,登录态和风控都更敏感。本文方案只操作本机已经登录的 Windows 微信,更像桌面助手。
2. 为什么要截图,而不是直接读取消息文本? 新版 Windows 微信的控件树暴露不完整,很多聊天内容无法稳定通过 UI Automation 读出来。截图反而更通用,Codex 可以直接根据截图理解最新可见消息。
3. 为什么 draft_message 不复用发送函数? 因为发送函数可能会按 Enter 或 Ctrl+Enter。草稿能力必须独立实现,只做粘贴,不触发发送。
4. 如果截图到的是通话窗口怎么办? 检查 script/wechat_sender.py 里 attach_wechat 是否已经加上:
1 return app.window(handle=chosen.handle)
并重启本地 FastAPI 服务。
总结 这次最终跑通的是:
1 2 3 4 5 6 7 8 9 10 11 个人微信 + Windows 微信客户端 + WinAutoWx + 本地 FastAPI + MCP + Codex
它不是一个完全自动回复机器人,而是一个更可控的本地微信助手。
我觉得最关键的设计不是“能自动发”,而是“只自动写草稿”。这样既能用上 Codex 的理解和表达能力,又把微信沟通里最重要的发送动作留给自己。