This commit is contained in:
2026-04-08 11:00:31 +08:00
parent fb21a127b5
commit 7019cf180d
17 changed files with 960 additions and 1 deletions

2
.gitignore vendored
View File

@@ -174,3 +174,5 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
.DS_Store

View File

@@ -1,3 +1,16 @@
# Wechat-Bot # Wechat-Bot
企业微信消息分发中转站 企业微信消息分发中转站
企业微信dochttps://developer.work.weixin.qq.com/document/path/101463
企业微信sdk: aibot-python-sdk(wecom-aibot-python-sdk 1.0.2)
本项目为企业微信与agent的桥梁
企业微信通过创建智能机器人用socket长链接方式与本地服务进行数据交换。
后端agent为adk开发并使用fastapi形式部署所以可以使用adk中的client库进行交互。
全部项目均使用python编写
企业微信的userid与agent的userid可以保持一一对应关系直接使用。
所有配置均应放在.env文件中

24
wechat-bot/.env.example Normal file
View File

@@ -0,0 +1,24 @@
# ============================================
# 企业微信机器人配置
# ============================================
# 在企业微信管理后台 -> 智能机器人 -> API模式 -> 长连接 获取
WECHAT_BOT_ID=your_bot_id_here
WECHAT_BOT_SECRET=your_bot_secret_here
# ============================================
# 远程 Agent 服务配置ADK api_server
# ============================================
# Agent 服务地址adk api_server 启动地址)
AGENT_BASE_URL=http://your-agent-host:8000
# Agent 应用名称(对应 agents 目录名)
AGENT_APP_NAME=root_agent
# Agent 请求超时时间(秒)
AGENT_TIMEOUT=120
# ============================================
# 服务配置
# ============================================
# 日志级别: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
# 数据目录(用于存储日志数据库等)
DATA_DIR=./data

29
wechat-bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Dependencies
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.eggs/
# Environment
.env
venv/
.venv/
# Data & Logs
data/
logs/
*.db
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

23
wechat-bot/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim
WORKDIR /wechat-bot
# 安装系统依赖
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc curl && \
rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖(利用 Docker 层缓存)
COPY requirements.txt .
RUN pip install --no-cache-dir --break-system-packages -r requirements.txt
# 复制项目代码
COPY config.py .
COPY main.py .
COPY bot/ ./bot/
# 环境变量
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
CMD ["python", "main.py"]

137
wechat-bot/README.md Normal file
View File

@@ -0,0 +1,137 @@
# Wechat-Bot
企业微信消息分发中转站 —— 企业微信智能机器人与远程 Agent 的桥梁。
企业微信文档https://developer.work.weixin.qq.com/document/path/101463
## 架构说明
```
企业微信用户
企业微信服务器 (wss://openws.work.weixin.qq.com)
│ WebSocket 长连接
wecom-aibot-python-sdk (接收/发送消息)
│ SSE 流式 + HTTP 调用
远程 Agent 服务 (AGENT_BASE_URL)
```
- **企业微信侧**:通过 `wecom-aibot-python-sdk` 建立 WebSocket 长连接,接收用户消息
- **Agent 侧**:通过 HTTP 调用远程 Agent 的 REST API兼容 ADK `api_server` 格式)
- **流式回复**:优先使用 `/run_sse`SSE 流式)实现打字机效果,失败时降级为 `/run`(同步)
- **会话管理**:自动为每个用户创建 Agent 会话,企业微信 userid 与 Agent user_id 一一对应
- **思考过滤**:自动去除模型思考过程(`<think...>` 标签),只推送正式回复给用户
## 支持的消息类型
| 类型 | msgtype | 说明 |
|------|---------|------|
| 文本 | `text` | 直接转发给 Agent 处理 |
| 图片 | `image` | 下载并描述,转发给 Agent |
| 语音 | `voice` | 提取文本内容后处理 |
| 文件 | `file` | 下载并描述,转发给 Agent |
| 视频 | `video` | 下载并描述,转发给 Agent |
| 图文混排 | `mixed` | 提取文本内容后处理 |
## 项目结构
```
wechat-bot/
├── main.py # 主入口,启动 WebSocket 客户端
├── config.py # 全局配置(从 .env 加载)
├── requirements.txt # Python 依赖
├── Dockerfile # Docker 镜像构建
├── docker-compose.yml # Docker Compose 编排
├── .env.example # 环境变量模板
└── bot/
├── __init__.py
├── wecom_bot.py # 组装层:创建 WSClient + 注册事件
├── agent_client.py # Agent 通信HTTP 客户端、会话管理、SSE/同步调用
├── handlers.py # 消息处理:统一的 handle_message()
├── message_parser.py # 消息解析:提取文本内容和元数据
└── reply_parser.py # 回复解析:去除思考标签、去重
```
## 快速开始
### 1. 配置环境变量
```bash
cp .env.example .env
```
编辑 `.env` 文件,填写以下必要配置:
| 配置项 | 必填 | 说明 | 默认值 |
|--------|------|------|--------|
| `WECHAT_BOT_ID` | ✅ | 企业微信机器人 ID | - |
| `WECHAT_BOT_SECRET` | ✅ | 长连接密钥 | - |
| `AGENT_BASE_URL` | ✅ | Agent 服务地址 | `http://127.0.0.1:8000` |
| `AGENT_APP_NAME` | ❌ | Agent 应用名称 | `root_agent` |
| `AGENT_TIMEOUT` | ❌ | Agent 调用超时(秒) | `120` |
| `LOG_LEVEL` | ❌ | 日志级别 | `INFO` |
> `WECHAT_BOT_ID` 和 `WECHAT_BOT_SECRET` 获取方式:企业微信管理后台 → 智能机器人 → API 模式
### 2. 启动 Agent 服务
确保你的 Agent 服务已启动并监听对应端口。
### 3. 启动 Bot
**Docker 部署(推荐,适用于 NAS**
```bash
docker compose up -d --build
docker compose logs -f
```
**本地运行:**
```bash
pip install -r requirements.txt
python main.py
```
## Agent API 集成
本项目通过 HTTP 调用远程 Agent兼容 ADK `api_server` 接口格式:
| 接口 | 方法 | 用途 |
|------|------|------|
| `/apps/{app_name}/users/{user_id}/sessions` | POST | 为用户创建会话 |
| `/run_sse` | POST | SSE 流式对话(主要方式) |
| `/run` | POST | 同步对话(降级方案) |
### 请求格式
```json
{
"app_name": "root_agent",
"user_id": "企业微信用户ID",
"session_id": "会话ID",
"new_message": {
"role": "user",
"parts": [{"text": "用户消息"}]
}
}
```
### 响应处理
- SSE 流式:逐行解析 `data:` 事件,收集 `content.parts[].text`
- 自动过滤 `thought: true` 的思考内容
- 自动去除 `<think...</think` 标签(兼容 MiniMax 等模型的思考格式)
- 自动去重ADK 最后一个 event 会重复发送完整内容)
- `finishReason: "STOP"` 时提取正式回复并发送给用户
## 技术栈
- **Python** >= 3.10
- **wecom-aibot-python-sdk** - 企业微信智能机器人 SDK
- **httpx** - 异步 HTTP 客户端(支持 SSE 流式)
- **python-dotenv** - 环境变量管理
- **Docker** + **Docker Compose** - 容器化部署

View File

View File

@@ -0,0 +1,237 @@
"""
Agent HTTP 客户端模块
负责与远程 ADK Agent 服务通信,包括:
- HTTP 客户端生命周期管理
- 用户会话管理(创建、缓存、清除)
- SSE 流式调用(主方案)
- 同步调用(降级方案)
"""
import json
import logging
from typing import Optional
import httpx
from config import Config
from bot.reply_parser import extract_final_reply
logger = logging.getLogger(__name__)
# 用户会话映射: user_id -> session_id
_user_sessions: dict[str, str] = {}
# Agent HTTP 客户端
_http_client: Optional[httpx.AsyncClient] = None
def get_http_client() -> httpx.AsyncClient:
"""获取或创建 HTTP 客户端(全局单例)"""
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(
base_url=Config.AGENT_BASE_URL,
timeout=Config.AGENT_TIMEOUT,
)
return _http_client
async def close_http_client():
"""关闭 HTTP 客户端"""
global _http_client
if _http_client:
await _http_client.aclose()
_http_client = None
# ============================================================
# 会话管理
# ============================================================
async def get_or_create_session(user_id: str) -> str:
"""获取或创建用户的 Agent 会话"""
if user_id in _user_sessions:
return _user_sessions[user_id]
client = get_http_client()
try:
url = f"/apps/{Config.AGENT_APP_NAME}/users/{user_id}/sessions"
resp = await client.post(url, json={})
resp.raise_for_status()
data = resp.json()
session_id = data["id"]
_user_sessions[user_id] = session_id
logger.info("为用户 %s 创建 Agent 会话: %s", user_id, session_id)
return session_id
except Exception as e:
logger.error("创建 Agent 会话失败: %s", e)
raise
def clear_session(user_id: str):
"""清除用户的会话缓存(会话失效时调用)"""
_user_sessions.pop(user_id, None)
# ============================================================
# Agent 调用
# ============================================================
async def call_agent_stream(
ws_client,
frame: dict,
user_id: str,
message: str,
) -> str:
"""
通过 SSE 流式调用 Agent实现打字机效果。
失败时降级为同步调用。
返回完整的回复文本。
"""
from aibot import generate_req_id
session_id = await get_or_create_session(user_id)
client = get_http_client()
try:
req_body = {
"app_name": Config.AGENT_APP_NAME,
"user_id": user_id,
"session_id": session_id,
"new_message": {
"role": "user",
"parts": [{"text": message}],
},
"streaming": True,
}
async with client.stream(
"POST",
"/run_sse",
json=req_body,
timeout=Config.AGENT_TIMEOUT,
) as response:
if response.status_code != 200:
logger.warning(
"SSE 流式调用返回 %d,降级为同步调用",
response.status_code,
)
return await call_agent_sync(user_id, message)
stream_id = generate_req_id("stream")
full_text = ""
has_sent_final = False
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:]
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
# 错误处理
if "error" in data:
logger.error("Agent SSE 错误: %s", data["error"])
if "not found" in data["error"].lower():
clear_session(user_id)
return "抱歉,处理消息时出现错误,请稍后重试。"
# 收集所有文本(含思考过程)
parts = data.get("content", {}).get("parts", [])
for part in parts:
text = part.get("text", "")
if not text:
continue
if part.get("thought"):
continue
full_text += text
# 实时推送"正在思考"状态
if full_text and not has_sent_final:
try:
await ws_client.reply_stream(
frame, stream_id, "💭 正在思考...", False
)
except Exception:
pass
# 最终完成时提取正式回复并发送
is_final = data.get("finishReason") == "STOP"
if is_final and full_text and not has_sent_final:
clean_reply = extract_final_reply(full_text)
logger.info("Agent 回复完成,长度: %d", len(clean_reply))
try:
await ws_client.reply_stream(
frame, stream_id, clean_reply, True
)
has_sent_final = True
except Exception as e:
logger.warning("推送最终回复失败: %s", e)
# 兜底:如果流结束但未发送最终回复
if full_text and not has_sent_final:
clean_reply = extract_final_reply(full_text)
logger.info("Agent SSE 兜底发送,长度: %d", len(clean_reply))
try:
await ws_client.reply_stream(
frame, stream_id, clean_reply, True
)
except Exception as e:
logger.warning("推送兜底回复失败: %s", e)
return extract_final_reply(full_text) if full_text else ""
except httpx.ConnectError:
logger.error("无法连接到 Agent 服务: %s", Config.AGENT_BASE_URL)
return "抱歉Agent 服务连接失败,请稍后重试。"
except Exception as e:
logger.error("SSE 流式调用异常,降级为同步: %s", e)
return await call_agent_sync(user_id, message)
async def call_agent_sync(user_id: str, message: str) -> str:
"""
同步调用 Agent/run 接口),作为流式调用的降级方案。
"""
session_id = await get_or_create_session(user_id)
client = get_http_client()
def _build_body(sid: str) -> dict:
return {
"app_name": Config.AGENT_APP_NAME,
"user_id": user_id,
"session_id": sid,
"new_message": {
"role": "user",
"parts": [{"text": message}],
},
}
def _extract_reply(events: list) -> str:
"""从 Event[] 中提取最终回复(跳过 thought"""
for event in events:
parts = event.get("content", {}).get("parts", [])
for part in parts:
if part.get("text") and not part.get("thought"):
return part["text"]
return ""
try:
resp = await client.post("/run", json=_build_body(session_id))
resp.raise_for_status()
return _extract_reply(resp.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
clear_session(user_id)
new_session = await get_or_create_session(user_id)
resp = await client.post("/run", json=_build_body(new_session))
resp.raise_for_status()
return _extract_reply(resp.json())
logger.error("Agent HTTP 错误 [%d]: %s", e.response.status_code, e)
return "抱歉,服务暂时不可用,请稍后重试。"
except Exception as e:
logger.error("同步调用 Agent 失败: %s", e)
return "抱歉,处理消息时出现错误,请稍后重试。"

View File

@@ -0,0 +1,91 @@
"""
消息处理器模块
提供统一的消息处理流程,消除各消息类型处理器之间的重复代码。
处理流程:
1. 解析消息元数据和内容
2. (可选)下载附件(图片/文件/视频)
3. 调用 Agent 获取回复
"""
import logging
from aibot import generate_req_id
from bot.agent_client import call_agent_stream
from bot.message_parser import extract_metadata, extract_text_content
logger = logging.getLogger(__name__)
async def handle_message(ws_client, frame: dict, msg_type: str):
"""
通用消息处理器,适用于所有消息类型。
对于需要下载附件的消息类型image/file/video
会先尝试下载附件并附加描述信息。
"""
meta = extract_metadata(frame)
user_id = meta["user_id"]
# ---- 提取消息内容 ----
content = await _resolve_content(ws_client, frame, msg_type, meta)
logger.info("收到%s消息 - 用户: %s, 内容: %s", msg_type, user_id, content[:100])
# ---- 调用 Agent ----
reply = await call_agent_stream(ws_client, frame, user_id, content)
# ---- 兜底:未收到回复 ----
if not reply:
stream_id = generate_req_id("stream")
await ws_client.reply_stream(frame, stream_id, "(未收到回复)", True)
async def _resolve_content(
ws_client, frame: dict, msg_type: str, meta: dict
) -> str:
"""
解析消息内容。
对于文本类消息直接提取文本;对于附件类消息尝试下载并附加描述。
"""
if msg_type in ("text", "voice", "mixed"):
return extract_text_content(frame)
if msg_type == "image":
return await _download_and_describe(ws_client, frame, "image", "图片")
if msg_type == "file":
return await _download_and_describe(ws_client, frame, "file", "文件")
if msg_type == "video":
return await _download_and_describe(ws_client, frame, "video", "视频")
return extract_text_content(frame)
async def _download_and_describe(
ws_client, frame: dict, media_type: str, label: str
) -> str:
"""
尝试下载附件并返回描述文本。
下载失败时返回简单描述,不阻断流程。
"""
body = frame.get("body", {})
media_info = body.get(media_type, {})
if not media_info:
return f"[用户发送了一个{label}]"
url = media_info.get("url")
aeskey = media_info.get("aeskey")
if not url:
return f"[用户发送了一个{label}]"
try:
buffer, filename = await ws_client.download_file(url, aeskey)
desc = f"[用户发送了{label}: {filename}, 大小: {len(buffer)} bytes]"
logger.info("%s下载成功: %s, 大小: %d bytes", label, filename, len(buffer))
return desc
except Exception as e:
logger.warning("%s下载失败: %s", label, e)
return f"[用户发送了一个{label}]"

View File

@@ -0,0 +1,57 @@
"""
消息解析模块
负责从企业微信 SDK 的消息帧中提取结构化数据。
frame 结构: {cmd, headers, body: {msgtype, text, from, ...}}
注意msgtype 在 body 内部,不在 frame 顶层。
"""
def extract_text_content(frame: dict) -> str:
"""
从消息帧中提取文本内容。
根据不同的消息类型,提取可用于 Agent 处理的文本描述。
"""
body = frame.get("body", {})
msg_type = body.get("msgtype", "")
if msg_type == "text":
return body.get("text", {}).get("content", "")
elif msg_type == "image":
return "[用户发送了一张图片]"
elif msg_type == "voice":
return body.get("voice", {}).get("content", "[用户发送了一条语音消息]")
elif msg_type == "file":
filename = body.get("file", {}).get("filename", "未知文件")
return f"[用户发送了一个文件: {filename}]"
elif msg_type == "video":
return "[用户发送了一段视频]"
elif msg_type == "mixed":
mixed_items = body.get("mixed", {}).get("content", [])
text_parts = []
for item in mixed_items:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("content", ""))
elif isinstance(item, str):
text_parts.append(item)
return "\n".join(text_parts) if text_parts else "[用户发送了图文混排消息]"
return f"[未知消息类型: {msg_type}]"
def extract_metadata(frame: dict) -> dict:
"""提取消息的元数据msgtype 在 body 内部)"""
body = frame.get("body", {})
from_info = body.get("from", {})
return {
"user_id": from_info.get("userid", ""),
"chat_id": body.get("chatid", ""),
"chat_type": body.get("chattype", ""),
"msg_id": body.get("msgid", ""),
"msg_type": body.get("msgtype", ""),
}

View File

@@ -0,0 +1,84 @@
"""
回复文本解析模块
负责处理 Agent 返回的原始文本,提取最终正式回复。
模型思考标记格式(按优先级检测):
1. <think...</think 标签对MiniMax M2.7 等模型)
2. 💭 emoji 前缀(旧版格式,保留兼容)
ADK 最后一个 event 会重复发送完整内容,需要去重。
"""
import logging
import re
logger = logging.getLogger(__name__)
def extract_final_reply(text: str) -> str:
"""
从 SSE 收集的完整文本中提取最终正式回复。
策略:
1. 去除 <think...</think 标签及其内容
2. 兼容 💭 emoji 分割(旧版格式)
3. 去重
"""
if not text:
return text
# 步骤1去除 <think...</think 标签块
if "<think" in text:
text = re.sub(r"<think[\s\S]*?</think\s*>?", "", text)
# 步骤2兼容 💭 emoji 分割(旧版格式)
if "💭" in text:
text = text.split("💭")[-1]
# 清理前导空白
text = text.strip()
# 步骤3去重
text = _deduplicate(text)
return text
def _deduplicate(text: str) -> str:
"""
对回复文本去重。
ADK 最后一个 SSE event 会重复发送完整内容(含思考+回复),
去除 <think...> 后可能出现连续两份相同的正式回复。
支持行级和字符级去重。
"""
if not text:
return text
# 行级去重
lines = text.split("\n")
non_empty = [l for l in lines if l.strip()]
if len(non_empty) > 1:
mid = len(non_empty) // 2
if non_empty[:mid] == non_empty[mid:]:
return "\n".join(non_empty[:mid])
for overlap_len in range(min(len(non_empty) // 2, 20), 0, -1):
if non_empty[-overlap_len:] == non_empty[:overlap_len]:
return "\n".join(non_empty[overlap_len:])
# 字符级去重:检查文本是否由两个相同的子串拼接而成
length = len(text)
for sub_len in range(length // 2, 0, -1):
if length % sub_len == 0 and text[:sub_len] * (length // sub_len) == text:
return text[:sub_len]
# 部分重叠:前缀和后缀相同,中间有重复
for overlap in range(min(length // 2, 500), 0, -1):
if text[:overlap] == text[-overlap:]:
trimmed = text[overlap:]
trimmed_len = len(trimmed)
for sub_len in range(trimmed_len // 2, 0, -1):
if trimmed_len % sub_len == 0 and trimmed[:sub_len] * (trimmed_len // sub_len) == trimmed:
return trimmed[:sub_len]
return text

104
wechat-bot/bot/wecom_bot.py Normal file
View File

@@ -0,0 +1,104 @@
"""
企业微信桥接模块
使用 wecom-aibot-python-sdk 与企业微信建立 WebSocket 长连接,
接收用户消息后通过 ADK API 调用远程 Agent再将 Agent 回复发送给用户。
本模块仅负责:
- 创建 WSClient 实例
- 注册事件和消息处理器
"""
import json
import logging
import traceback
from aibot import WSClient, WSClientOptions
from config import Config
from bot.agent_client import close_http_client
from bot.handlers import handle_message
from bot.message_parser import extract_metadata
logger = logging.getLogger(__name__)
def create_bot_client() -> WSClient:
"""
创建企业微信机器人 WebSocket 客户端,并注册所有事件和消息处理器。
"""
ws_client = WSClient(
WSClientOptions(
bot_id=Config.WECHAT_BOT_ID,
secret=Config.WECHAT_BOT_SECRET,
)
)
# ---- 事件处理器 ----
@ws_client.on("authenticated")
def on_authenticated():
logger.info("企业微信机器人认证成功")
@ws_client.on("event.enter_chat")
async def on_enter_chat(frame):
"""用户进入会话,发送欢迎语"""
meta = extract_metadata(frame)
user_id = meta.get("user_id", "")
logger.info("用户进入会话: %s", user_id)
try:
await ws_client.reply_welcome(
frame,
{
"msgtype": "text",
"text": {
"content": "您好!我是智能助手,有什么可以帮您的吗?"
},
},
)
except Exception as e:
logger.error("发送欢迎语失败: %s", e)
@ws_client.on("event.feedback_event")
async def on_feedback(frame):
"""用户反馈事件"""
body = frame.get("body", {})
logger.info("收到用户反馈: %s", json.dumps(body, ensure_ascii=False))
@ws_client.on("disconnected")
def on_disconnected(reason):
logger.warning("连接被断开(可能被新连接踢掉): %s", reason)
# ---- 消息处理器 ----
@ws_client.on("message.text")
async def on_text(frame):
await _safe_handle(ws_client, frame, "text")
@ws_client.on("message.image")
async def on_image(frame):
await _safe_handle(ws_client, frame, "image")
@ws_client.on("message.voice")
async def on_voice(frame):
await _safe_handle(ws_client, frame, "voice")
@ws_client.on("message.file")
async def on_file(frame):
# file 类型可能包含视频
body = frame.get("body", {})
msg_type = "video" if body.get("video") else "file"
await _safe_handle(ws_client, frame, msg_type)
@ws_client.on("message.mixed")
async def on_mixed(frame):
await _safe_handle(ws_client, frame, "mixed")
return ws_client
async def _safe_handle(ws_client, frame: dict, msg_type: str):
"""安全地处理消息,捕获异常防止崩溃"""
try:
await handle_message(ws_client, frame, msg_type)
except Exception as e:
logger.error("处理%s消息异常: %s\n%s", msg_type, e, traceback.format_exc())

41
wechat-bot/config.py Normal file
View File

@@ -0,0 +1,41 @@
"""
全局配置模块
从 .env 文件加载所有配置项,提供统一的配置访问接口。
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# 项目根目录
BASE_DIR = Path(__file__).parent
# 加载 .env 文件
load_dotenv(BASE_DIR / ".env")
class Config:
"""全局配置类,所有配置项均从环境变量读取"""
# ---- 企业微信机器人 ----
WECHAT_BOT_ID: str = os.getenv("WECHAT_BOT_ID", "")
WECHAT_BOT_SECRET: str = os.getenv("WECHAT_BOT_SECRET", "")
# ---- 远程 Agent 服务 ----
AGENT_BASE_URL: str = os.getenv("AGENT_BASE_URL", "http://127.0.0.1:8000")
AGENT_APP_NAME: str = os.getenv("AGENT_APP_NAME", "root_agent")
AGENT_TIMEOUT: int = int(os.getenv("AGENT_TIMEOUT", "120"))
# ---- 服务配置 ----
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
@classmethod
def validate(cls) -> list[str]:
"""校验必要配置项,返回缺失项列表"""
missing = []
if not cls.WECHAT_BOT_ID:
missing.append("WECHAT_BOT_ID")
if not cls.WECHAT_BOT_SECRET:
missing.append("WECHAT_BOT_SECRET")
if not cls.AGENT_BASE_URL:
missing.append("AGENT_BASE_URL")
return missing

View File

@@ -0,0 +1,10 @@
services:
wechat-bot:
build: .
container_name: wechat-bot
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data
- ./logs:/app/logs

86
wechat-bot/main.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Wechat-Bot 主入口
启动企业微信 WebSocket 长连接,接收消息后转发给远程 Agent。
"""
import asyncio
import logging
import sys
from config import Config
from bot.wecom_bot import create_bot_client
from bot.agent_client import close_http_client
# 配置日志
logging.basicConfig(
level=getattr(logging, Config.LOG_LEVEL, logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
async def run_bot():
"""启动企业微信机器人 WebSocket 客户端"""
# 创建机器人客户端并注册事件处理器
ws_client = create_bot_client()
logger.info("正在连接企业微信...")
# 用 Event 等待认证完成
authenticated = asyncio.Event()
@ws_client.on("authenticated")
def _on_authenticated():
logger.info("企业微信认证成功,机器人已上线")
authenticated.set()
@ws_client.on("error")
def _on_error(err):
logger.error("SDK 错误: %s", err)
try:
await ws_client.connect()
# 等待认证结果(最多 30 秒)
try:
await asyncio.wait_for(authenticated.wait(), timeout=30)
except asyncio.TimeoutError:
logger.error("认证超时30秒请检查 BOT_ID 和 BOT_SECRET 是否正确")
return
# 认证成功,保持事件循环运行以持续接收消息
logger.info("正在监听消息...")
await asyncio.Future() # 永远挂起,直到事件循环被取消
except asyncio.CancelledError:
logger.info("任务被取消")
except Exception as e:
logger.error("企业微信连接异常: %s", e)
finally:
await close_http_client()
logger.info("机器人已停止")
def main():
"""主函数"""
# 校验配置
missing = Config.validate()
if missing:
logger.error("缺少必要配置项: %s", ", ".join(missing))
logger.error("请复制 .env.example 为 .env 并填写配置")
sys.exit(1)
logger.info("=" * 50)
logger.info(" Wechat-Bot 启动中")
logger.info(" Agent 地址: %s", Config.AGENT_BASE_URL)
logger.info(" 日志级别: %s", Config.LOG_LEVEL)
logger.info("=" * 50)
try:
asyncio.run(run_bot())
except KeyboardInterrupt:
logger.info("收到中断信号,正在停止...")
logger.info("Wechat-Bot 已退出")
if __name__ == "__main__":
main()

18
wechat-bot/pyproject.toml Normal file
View File

@@ -0,0 +1,18 @@
[project]
name = "wechat-bot"
version = "0.1.0"
description = "企业微信消息分发中转站 - 企业微信与 Agent 的桥梁"
requires-python = ">=3.10"
dependencies = [
"wecom-aibot-python-sdk>=1.0.1",
"python-dotenv>=1.0.0",
"httpx>=0.27.0",
"aiosqlite>=0.20.0",
]
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["bot*"]

View File

@@ -0,0 +1,3 @@
wecom-aibot-python-sdk>=1.0.1
python-dotenv>=1.0.0
httpx>=0.27.0