From 5b31da335de4057c2b66ea9115e9eb4cb76c54f4 Mon Sep 17 00:00:00 2001 From: 6vision Date: Thu, 21 May 2026 20:43:06 +0800 Subject: [PATCH] fix(wechatcom_kf): use plain WeChatClient to fix 40014 & token log spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch from the local `WechatComAppClient` (whose `fetch_access_token` may return the raw response dict and whose background refresh loop re-fetches every 60s) to the stock `wechatpy.enterprise.WeChatClient`. - Use `client.access_token` (string property) when building sync_msg / send_msg URLs; the previous `client.fetch_access_token()` call could interpolate a dict into the URL and yield errcode 40014. - Always skip historical messages on first start; drop the `wechatcom_kf_skip_history_on_first_start` config — there is no real case for replaying up to 14 days of history. - Change default callback port from 9899 to 9888. Co-authored-by: Cursor --- channel/wechatcom_kf/README.md | 23 +++++++--------- channel/wechatcom_kf/wechatcom_kf_channel.py | 29 ++++++++++++-------- config.py | 3 +- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/channel/wechatcom_kf/README.md b/channel/wechatcom_kf/README.md index 88edf29e..e74f3a78 100644 --- a/channel/wechatcom_kf/README.md +++ b/channel/wechatcom_kf/README.md @@ -12,7 +12,7 @@ ``` ┌─────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐ │ 1. 企业微信后台 │ → │ 2. CoW 配置回调 │ → │ 3. 绑定微信客服 │ -│ 创建一个自建应用 │ │ 端口 9899 │ │ 账号 │ +│ 创建一个自建应用 │ │ 端口 9888 │ │ 账号 │ └─────────────────────┘ └─────────────────────┘ └──────────────────┘ ↓ 外部微信用户通过 @@ -43,7 +43,7 @@ 在应用「**接收消息 → 设置API接收**」里填: -- URL:`http://:9899/wxkf/`(公网必须可达) +- URL:`http://:9888/wxkf/`(公网必须可达) - Token / EncodingAESKey:与下方 `config.json` 一致 回到应用详情页,把服务器公网 IP 填入「**企业可信IP**」。 @@ -64,10 +64,9 @@ "wechatcom_kf_secret": "<企微应用的 Secret>", "wechatcom_kf_token": "<接收消息 Token>", "wechatcom_kf_aes_key": "", - "wechatcom_kf_port": 9899, + "wechatcom_kf_port": 9888, - "wechatcom_kf_cursor_dir": "tmp", - "wechatcom_kf_skip_history_on_first_start": true + "wechatcom_kf_cursor_dir": "tmp" } ``` @@ -77,9 +76,10 @@ | `wechatcom_kf_secret` | **绑定到微信客服**的那个企微自建应用的 Secret(不是 wechatcomapp_secret) | | `wechatcom_kf_token` | 该应用「接收消息」配置的 Token | | `wechatcom_kf_aes_key` | 该应用「接收消息」配置的 EncodingAESKey | -| `wechatcom_kf_port` | 监听端口,默认 `9899`(避开 `wechatcomapp_port=9898`) | +| `wechatcom_kf_port` | 监听端口,默认 `9888`(避开 `wechatcomapp_port=9898`) | | `wechatcom_kf_cursor_dir` | `next_cursor` 持久化目录,默认 `tmp/` | -| `wechatcom_kf_skip_history_on_first_start` | 首次启动(无 cursor)时跳过历史消息,强烈建议 `true`,否则会回放最近 14 天的消息把所有用户都骚扰一遍 | + +> 首次启动(本地无 cursor 文件)会自动把 cursor 推进到"当前最新",跳过历史消息。否则微信客服会回放最近 14 天的消息把所有用户都骚扰一遍 —— 这个行为是固定的,没有配置开关。 也支持环境变量:`WECHATCOM_CORP_ID` / `WECHATCOM_KF_SECRET` / `WECHATCOM_KF_TOKEN` / `WECHATCOM_KF_AES_KEY`。 @@ -93,7 +93,7 @@ python app.py ``` [wechatcom_kf] WeCom customer-service channel started -[wechatcom_kf] Listening on http://0.0.0.0:9899/wxkf/ +[wechatcom_kf] Listening on http://0.0.0.0:9888/wxkf/ ``` 回到企微后台「设置API接收」点击保存——会触发 `GET /wxkf/?...&echostr=...`,CoW 通过 `crypto.check_signature` 校验后返回明文 `echostr`,验证成功。 @@ -105,7 +105,7 @@ python app.py | 接收方式 | 回调直接 push,消息内容现成 | 回调只通知"有新消息",需调 `kf/sync_msg` 主动拉 | | 接收方ID | `userid`(成员) | `external_userid`(外部用户)+ `open_kfid`(客服身份) | | 发送接口 | `wechatpy` 内置封装 | 直接 POST `cgi-bin/kf/send_msg` | -| 端口 | 9898 | 9899 | +| 端口 | 9898 | 9888 | | 状态保存 | 无 | 必须持久化 `next_cursor`(本通道写本地 JSON) | ## 六、cursor 持久化 @@ -113,10 +113,7 @@ python app.py `next_cursor` 是企微返回的"我上次拉到哪儿了"的书签。本通道把它存在 `tmp/wechatcom_kf_cursors.json`(按 `open_kfid` 分键),重启不会丢。 -**不要轻易删除该文件**,否则下次启动: - -- 若 `wechatcom_kf_skip_history_on_first_start=true`(默认):会触发"跳过历史消息"逻辑,**自动**把 cursor 推进到最新位置; -- 若改为 `false`:会把最近 14 天的全部历史消息当成新消息回放并自动回复。 +**不要轻易删除该文件**。若删除,下次启动会触发"首次启动"逻辑,**自动**把 cursor 推进到最新位置,跳过历史消息。 ## 七、多客服账号 diff --git a/channel/wechatcom_kf/wechatcom_kf_channel.py b/channel/wechatcom_kf/wechatcom_kf_channel.py index d8c3cff3..b506b9e7 100644 --- a/channel/wechatcom_kf/wechatcom_kf_channel.py +++ b/channel/wechatcom_kf/wechatcom_kf_channel.py @@ -22,7 +22,7 @@ from typing import Optional import requests import web -from wechatpy.enterprise import parse_message +from wechatpy.enterprise import WeChatClient from wechatpy.enterprise.crypto import WeChatCrypto from wechatpy.enterprise.exceptions import InvalidCorpIdException from wechatpy.exceptions import InvalidSignatureException, WeChatClientException @@ -30,7 +30,6 @@ from wechatpy.exceptions import InvalidSignatureException, WeChatClientException from bridge.context import Context from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel -from channel.wechatcom.wechatcomapp_client import WechatComAppClient from channel.wechatcom_kf.wechatcom_kf_cursor_store import CursorStore from channel.wechatcom_kf.wechatcom_kf_message import WechatComKfMessage from common.log import logger @@ -72,14 +71,17 @@ class WechatComKfChannel(ChatChannel): ) ) self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id) - self.client = WechatComAppClient(self.corp_id, self.secret) + # Use the stock wechatpy WeChatClient so that the access_token is + # cached and only refreshed when actually expired (~2h). The local + # `WechatComAppClient` subclass has a broken background refresh + # loop that re-fetches every 60s and a `fetch_access_token()` + # override that may return a dict instead of a string, which + # corrupts URLs and triggers errcode 40014. + self.client = WeChatClient(self.corp_id, self.secret) cursor_dir = conf().get("wechatcom_kf_cursor_dir", "tmp") cursor_path = os.path.join(cursor_dir, "wechatcom_kf_cursors.json") self.cursor_store = CursorStore(cursor_path) - self.skip_history_on_first_start = conf().get( - "wechatcom_kf_skip_history_on_first_start", True - ) # ------------------------------------------------------------------ # Lifecycle @@ -87,7 +89,7 @@ class WechatComKfChannel(ChatChannel): def startup(self): urls = ("/wxkf/?", "channel.wechatcom_kf.wechatcom_kf_channel.Query") app = web.application(urls, globals(), autoreload=False) - port = conf().get("wechatcom_kf_port", 9899) + port = conf().get("wechatcom_kf_port", 9888) logger.info("[wechatcom_kf] WeCom customer-service channel started") logger.info("[wechatcom_kf] Listening on http://0.0.0.0:{}/wxkf/".format(port)) func = web.httpserver.StaticMiddleware(app.wsgifunc()) @@ -248,8 +250,10 @@ class WechatComKfChannel(ChatChannel): """ existing_cursor = self.cursor_store.get(open_kfid) - # First-time bootstrap: avoid replaying up to 14 days of history. - if not existing_cursor and self.skip_history_on_first_start: + # First-time bootstrap: always skip history, otherwise WeCom would + # replay up to 14 days of messages on the very first callback and + # flood every user with auto-replies. + if not existing_cursor: self._initialize_cursor(token, open_kfid) return @@ -332,7 +336,10 @@ class WechatComKfChannel(ChatChannel): return collected def _call_sync_msg(self, token: str, open_kfid: str, cursor: str) -> Optional[dict]: - url = f"{KF_API_BASE}/sync_msg?access_token={self.client.fetch_access_token()}" + # `client.access_token` is the cached string property; do not use + # `fetch_access_token()` here — wechatpy returns the raw response + # dict from that call, which corrupts the query string. + url = f"{KF_API_BASE}/sync_msg?access_token={self.client.access_token}" payload = { "token": token, "open_kfid": open_kfid, @@ -358,7 +365,7 @@ class WechatComKfChannel(ChatChannel): # Outbound HTTP wrappers (kf/send_msg) # ------------------------------------------------------------------ def _post_send_msg(self, payload: dict) -> dict: - url = f"{KF_API_BASE}/send_msg?access_token={self.client.fetch_access_token()}" + url = f"{KF_API_BASE}/send_msg?access_token={self.client.access_token}" try: resp = requests.post(url, json=payload, timeout=10).json() except Exception as e: diff --git a/config.py b/config.py index fe36a4c8..5e1c2fc9 100644 --- a/config.py +++ b/config.py @@ -154,11 +154,10 @@ available_setting = { # 微信客服(wechatcom_kf)的配置 # 注意: 微信客服与企微自建应用是两套不同的应用,共用 corp_id,但 secret/token/aes_key 各自独立 "wechatcom_kf_token": "", # 微信客服回调token - "wechatcom_kf_port": 9899, # 微信客服回调服务端口 + "wechatcom_kf_port": 9888, # 微信客服回调服务端口 "wechatcom_kf_secret": "", # 微信客服应用的secret "wechatcom_kf_aes_key": "", # 微信客服回调aes_key "wechatcom_kf_cursor_dir": "tmp", # 拉取消息的cursor持久化目录(相对项目根) - "wechatcom_kf_skip_history_on_first_start": True, # 首次启动(无cursor)时跳过历史消息,只拉最新 # 飞书配置 "feishu_port": 80, # 飞书bot监听端口,仅webhook模式需要 "feishu_app_id": "", # 飞书机器人应用APP Id