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. 绑定微信客服 │ │ 1. 企业微信后台 │ → │ 2. CoW 配置回调 │ → │ 3. 绑定微信客服 │
│ 创建一个自建应用 │ │ 端口 9899 │ │ 账号 │ │ 创建一个自建应用 │ │ 端口 9888 │ │ 账号 │
└─────────────────────┘ └─────────────────────┘ └──────────────────┘ └─────────────────────┘ └─────────────────────┘ └──────────────────┘
外部微信用户通过 外部微信用户通过
@@ -43,7 +43,7 @@
在应用「**接收消息 → 设置API接收**」里填: 在应用「**接收消息 → 设置API接收**」里填:
- URL`http://<your-host>:9899/wxkf/`(公网必须可达) - URL`http://<your-host>:9888/wxkf/`(公网必须可达)
- Token / EncodingAESKey与下方 `config.json` 一致 - Token / EncodingAESKey与下方 `config.json` 一致
回到应用详情页,把服务器公网 IP 填入「**企业可信IP**」。 回到应用详情页,把服务器公网 IP 填入「**企业可信IP**」。
@@ -64,10 +64,9 @@
"wechatcom_kf_secret": "<企微应用的 Secret>", "wechatcom_kf_secret": "<企微应用的 Secret>",
"wechatcom_kf_token": "<接收消息 Token>", "wechatcom_kf_token": "<接收消息 Token>",
"wechatcom_kf_aes_key": "<EncodingAESKey>", "wechatcom_kf_aes_key": "<EncodingAESKey>",
"wechatcom_kf_port": 9899, "wechatcom_kf_port": 9888,
"wechatcom_kf_cursor_dir": "tmp", "wechatcom_kf_cursor_dir": "tmp"
"wechatcom_kf_skip_history_on_first_start": true
} }
``` ```
@@ -77,9 +76,10 @@
| `wechatcom_kf_secret` | **绑定到微信客服**的那个企微自建应用的 Secret不是 wechatcomapp_secret | | `wechatcom_kf_secret` | **绑定到微信客服**的那个企微自建应用的 Secret不是 wechatcomapp_secret |
| `wechatcom_kf_token` | 该应用「接收消息」配置的 Token | | `wechatcom_kf_token` | 该应用「接收消息」配置的 Token |
| `wechatcom_kf_aes_key` | 该应用「接收消息」配置的 EncodingAESKey | | `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_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` 也支持环境变量:`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] 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`,验证成功。 回到企微后台「设置API接收」点击保存——会触发 `GET /wxkf/?...&echostr=...`CoW 通过 `crypto.check_signature` 校验后返回明文 `echostr`,验证成功。
@@ -105,7 +105,7 @@ python app.py
| 接收方式 | 回调直接 push消息内容现成 | 回调只通知"有新消息",需调 `kf/sync_msg` 主动拉 | | 接收方式 | 回调直接 push消息内容现成 | 回调只通知"有新消息",需调 `kf/sync_msg` 主动拉 |
| 接收方ID | `userid`(成员) | `external_userid`(外部用户)+ `open_kfid`(客服身份) | | 接收方ID | `userid`(成员) | `external_userid`(外部用户)+ `open_kfid`(客服身份) |
| 发送接口 | `wechatpy` 内置封装 | 直接 POST `cgi-bin/kf/send_msg` | | 发送接口 | `wechatpy` 内置封装 | 直接 POST `cgi-bin/kf/send_msg` |
| 端口 | 9898 | 9899 | | 端口 | 9898 | 9888 |
| 状态保存 | 无 | 必须持久化 `next_cursor`(本通道写本地 JSON | | 状态保存 | 无 | 必须持久化 `next_cursor`(本通道写本地 JSON |
## 六、cursor 持久化 ## 六、cursor 持久化
@@ -113,10 +113,7 @@ python app.py
`next_cursor` 是企微返回的"我上次拉到哪儿了"的书签。本通道把它存在 `next_cursor` 是企微返回的"我上次拉到哪儿了"的书签。本通道把它存在
`tmp/wechatcom_kf_cursors.json`(按 `open_kfid` 分键),重启不会丢。 `tmp/wechatcom_kf_cursors.json`(按 `open_kfid` 分键),重启不会丢。
**不要轻易删除该文件**,否则下次启动: **不要轻易删除该文件**。若删除,下次启动会触发"首次启动"逻辑,**自动**把 cursor 推进到最新位置,跳过历史消息。
-`wechatcom_kf_skip_history_on_first_start=true`(默认):会触发"跳过历史消息"逻辑,**自动**把 cursor 推进到最新位置;
- 若改为 `false`:会把最近 14 天的全部历史消息当成新消息回放并自动回复。
## 七、多客服账号 ## 七、多客服账号

View File

@@ -22,7 +22,7 @@ from typing import Optional
import requests import requests
import web import web
from wechatpy.enterprise import parse_message from wechatpy.enterprise import WeChatClient
from wechatpy.enterprise.crypto import WeChatCrypto from wechatpy.enterprise.crypto import WeChatCrypto
from wechatpy.enterprise.exceptions import InvalidCorpIdException from wechatpy.enterprise.exceptions import InvalidCorpIdException
from wechatpy.exceptions import InvalidSignatureException, WeChatClientException from wechatpy.exceptions import InvalidSignatureException, WeChatClientException
@@ -30,7 +30,6 @@ from wechatpy.exceptions import InvalidSignatureException, WeChatClientException
from bridge.context import Context from bridge.context import Context
from bridge.reply import Reply, ReplyType from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel 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_cursor_store import CursorStore
from channel.wechatcom_kf.wechatcom_kf_message import WechatComKfMessage from channel.wechatcom_kf.wechatcom_kf_message import WechatComKfMessage
from common.log import logger from common.log import logger
@@ -72,14 +71,17 @@ class WechatComKfChannel(ChatChannel):
) )
) )
self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id) 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_dir = conf().get("wechatcom_kf_cursor_dir", "tmp")
cursor_path = os.path.join(cursor_dir, "wechatcom_kf_cursors.json") cursor_path = os.path.join(cursor_dir, "wechatcom_kf_cursors.json")
self.cursor_store = CursorStore(cursor_path) self.cursor_store = CursorStore(cursor_path)
self.skip_history_on_first_start = conf().get(
"wechatcom_kf_skip_history_on_first_start", True
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Lifecycle # Lifecycle
@@ -87,7 +89,7 @@ class WechatComKfChannel(ChatChannel):
def startup(self): def startup(self):
urls = ("/wxkf/?", "channel.wechatcom_kf.wechatcom_kf_channel.Query") urls = ("/wxkf/?", "channel.wechatcom_kf.wechatcom_kf_channel.Query")
app = web.application(urls, globals(), autoreload=False) 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] WeCom customer-service channel started")
logger.info("[wechatcom_kf] Listening on http://0.0.0.0:{}/wxkf/".format(port)) logger.info("[wechatcom_kf] Listening on http://0.0.0.0:{}/wxkf/".format(port))
func = web.httpserver.StaticMiddleware(app.wsgifunc()) func = web.httpserver.StaticMiddleware(app.wsgifunc())
@@ -248,8 +250,10 @@ class WechatComKfChannel(ChatChannel):
""" """
existing_cursor = self.cursor_store.get(open_kfid) existing_cursor = self.cursor_store.get(open_kfid)
# First-time bootstrap: avoid replaying up to 14 days of history. # First-time bootstrap: always skip history, otherwise WeCom would
if not existing_cursor and self.skip_history_on_first_start: # 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) self._initialize_cursor(token, open_kfid)
return return
@@ -332,7 +336,10 @@ class WechatComKfChannel(ChatChannel):
return collected return collected
def _call_sync_msg(self, token: str, open_kfid: str, cursor: str) -> Optional[dict]: 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 = { payload = {
"token": token, "token": token,
"open_kfid": open_kfid, "open_kfid": open_kfid,
@@ -358,7 +365,7 @@ class WechatComKfChannel(ChatChannel):
# Outbound HTTP wrappers (kf/send_msg) # Outbound HTTP wrappers (kf/send_msg)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _post_send_msg(self, payload: dict) -> dict: 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: try:
resp = requests.post(url, json=payload, timeout=10).json() resp = requests.post(url, json=payload, timeout=10).json()
except Exception as e: except Exception as e:

View File

@@ -154,11 +154,10 @@ available_setting = {
# 微信客服(wechatcom_kf)的配置 # 微信客服(wechatcom_kf)的配置
# 注意: 微信客服与企微自建应用是两套不同的应用,共用 corp_id,但 secret/token/aes_key 各自独立 # 注意: 微信客服与企微自建应用是两套不同的应用,共用 corp_id,但 secret/token/aes_key 各自独立
"wechatcom_kf_token": "", # 微信客服回调token "wechatcom_kf_token": "", # 微信客服回调token
"wechatcom_kf_port": 9899, # 微信客服回调服务端口 "wechatcom_kf_port": 9888, # 微信客服回调服务端口
"wechatcom_kf_secret": "", # 微信客服应用的secret "wechatcom_kf_secret": "", # 微信客服应用的secret
"wechatcom_kf_aes_key": "", # 微信客服回调aes_key "wechatcom_kf_aes_key": "", # 微信客服回调aes_key
"wechatcom_kf_cursor_dir": "tmp", # 拉取消息的cursor持久化目录(相对项目根) "wechatcom_kf_cursor_dir": "tmp", # 拉取消息的cursor持久化目录(相对项目根)
"wechatcom_kf_skip_history_on_first_start": True, # 首次启动(无cursor)时跳过历史消息,只拉最新
# 飞书配置 # 飞书配置
"feishu_port": 80, # 飞书bot监听端口仅webhook模式需要 "feishu_port": 80, # 飞书bot监听端口仅webhook模式需要
"feishu_app_id": "", # 飞书机器人应用APP Id "feishu_app_id": "", # 飞书机器人应用APP Id