mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
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:
@@ -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 推进到最新位置,跳过历史消息。
|
||||
|
||||
## 七、多客服账号
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user