fix(wechatcom_kf): use plain WeChatClient to fix 40014 & token log spam

- 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 <cursoragent@cursor.com>
This commit is contained in:
6vision
2026-05-21 20:43:06 +08:00
parent 11d92bb22a
commit 5b31da335d
3 changed files with 29 additions and 26 deletions

View File

@@ -12,7 +12,7 @@
```
┌─────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ 1. 企业微信后台 │ → │ 2. CoW 配置回调 │ → │ 3. 绑定微信客服 │
│ 创建一个自建应用 │ │ 端口 9899 │ │ 账号 │
│ 创建一个自建应用 │ │ 端口 9888 │ │ 账号 │
└─────────────────────┘ └─────────────────────┘ └──────────────────┘
外部微信用户通过
@@ -43,7 +43,7 @@
在应用「**接收消息 → 设置API接收**」里填:
- URL`http://<your-host>:9899/wxkf/`(公网必须可达)
- URL`http://<your-host>:9888/wxkf/`(公网必须可达)
- Token / EncodingAESKey与下方 `config.json` 一致
回到应用详情页,把服务器公网 IP 填入「**企业可信IP**」。
@@ -64,10 +64,9 @@
"wechatcom_kf_secret": "<企微应用的 Secret>",
"wechatcom_kf_token": "<接收消息 Token>",
"wechatcom_kf_aes_key": "<EncodingAESKey>",
"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 推进到最新位置,跳过历史消息。
## 七、多客服账号

View File

@@ -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: