feat(channel-service): add Python FastAPI pywechat channel service
- Add FastAPI project under channel-service/ - Implement mock pywechat client adapter (login, send, heartbeat) - Add /v1/wechat-personal/login/*, /v1/wechat-personal/send/* APIs - Add /v1/heartbeat and /v1/monitor/status for client health - Add /monitor HTML page with Web Components - Add pytest suite and Dockerfile - Update .gitignore for Python/Node artifacts
This commit is contained in:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -6,3 +6,16 @@ target/
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
|
||||
12
channel-service/Dockerfile
Normal file
12
channel-service/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
35
channel-service/README.md
Normal file
35
channel-service/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# sino-mci-channel-service
|
||||
|
||||
Python 渠道服务,用于管理 pywechat 个人微信等渠道客户端。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 使用 **Mock pywechat 客户端**进行开发联调;真实 Windows 环境需接入 `pywechat`/`pywinauto` 驱动微信 PC 客户端。
|
||||
- 提供登录二维码模拟、消息发送模拟、心跳上报、客户端监控页面。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
cd channel-service
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
## 接口
|
||||
|
||||
- `GET /health` 健康检查
|
||||
- `POST /v1/wechat-personal/login/start` 开始登录
|
||||
- `GET /v1/wechat-personal/login/{account_id}/status` 查询登录状态
|
||||
- `POST /v1/wechat-personal/login/{account_id}/confirm` 模拟扫码确认
|
||||
- `POST /v1/wechat-personal/send/text` 发送文本消息
|
||||
- `POST /v1/heartbeat` 客户端心跳
|
||||
- `GET /v1/monitor/status` 查询所有客户端状态
|
||||
- `GET /monitor` 客户端监控页面
|
||||
0
channel-service/app/__init__.py
Normal file
0
channel-service/app/__init__.py
Normal file
0
channel-service/app/api/__init__.py
Normal file
0
channel-service/app/api/__init__.py
Normal file
8
channel-service/app/api/health.py
Normal file
8
channel-service/app/api/health.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "service": "sino-mci-channel-service"}
|
||||
114
channel-service/app/api/monitor.py
Normal file
114
channel-service/app/api/monitor.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.clients.pywechat_client import registry
|
||||
from app.services.heartbeat_service import heartbeat_service
|
||||
|
||||
router = APIRouter(tags=["monitor"])
|
||||
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
account_id: str = Field(..., description="渠道账号唯一标识")
|
||||
account_name: str | None = Field(None)
|
||||
risk_level: int = Field(0, ge=0, le=100)
|
||||
sent_count_today: int = Field(0, ge=0)
|
||||
|
||||
|
||||
@router.post("/v1/heartbeat")
|
||||
def heartbeat(request: HeartbeatRequest):
|
||||
return heartbeat_service.heartbeat(request.account_id, request.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@router.get("/v1/monitor/status")
|
||||
def monitor_status():
|
||||
clients = []
|
||||
for client in registry.list_all():
|
||||
state = client.get_state()
|
||||
clients.append({
|
||||
"account_id": state.account_id,
|
||||
"account_name": state.account_name,
|
||||
"login_status": state.login_status.value,
|
||||
"online_status": state.online_status.value,
|
||||
"last_heartbeat_at": state.last_heartbeat_at,
|
||||
"risk_level": state.risk_level,
|
||||
"sent_count_today": state.sent_count_today,
|
||||
"error_message": state.error_message,
|
||||
})
|
||||
return {"clients": clients, "count": len(clients)}
|
||||
|
||||
|
||||
@router.get("/monitor", response_class=HTMLResponse)
|
||||
def monitor_page(request: Request):
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pywechat 客户端监控</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; margin: 20px; background: #f5f5f5; }
|
||||
h1 { font-size: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; }
|
||||
th, td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
th { background: #f0f0f0; }
|
||||
.online { color: green; }
|
||||
.offline { color: red; }
|
||||
.pending { color: orange; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pywechat 客户端监控</h1>
|
||||
<channel-status-table></channel-status-table>
|
||||
|
||||
<script>
|
||||
class ChannelStatusTable extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.render();
|
||||
setInterval(() => this.refresh(), 5000);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const res = await fetch('/v1/monitor/status');
|
||||
const body = await res.json();
|
||||
this.render(body.clients || []);
|
||||
} catch (e) {
|
||||
this.innerHTML = '<p>加载失败:' + e.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
render(clients = []) {
|
||||
if (!clients.length) {
|
||||
this.innerHTML = '<p>暂无客户端</p>';
|
||||
return;
|
||||
}
|
||||
const rows = clients.map(c => `
|
||||
<tr>
|
||||
<td>${c.account_id}</td>
|
||||
<td>${c.account_name || '-'}</td>
|
||||
<td class="${c.login_status}">${c.login_status}</td>
|
||||
<td class="${c.online_status}">${c.online_status}</td>
|
||||
<td>${c.risk_level}</td>
|
||||
<td>${c.sent_count_today}</td>
|
||||
<td>${c.last_heartbeat_at ? new Date(c.last_heartbeat_at * 1000).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
this.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>账号ID</th><th>名称</th><th>登录状态</th><th>在线状态</th><th>风控等级</th><th>今日发送</th><th>最后心跳</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('channel-status-table', ChannelStatusTable);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html)
|
||||
53
channel-service/app/api/wechat_personal.py
Normal file
53
channel-service/app/api/wechat_personal.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.login_service import login_service
|
||||
from app.services.send_service import send_service
|
||||
|
||||
router = APIRouter(prefix="/v1/wechat-personal", tags=["wechat-personal"])
|
||||
|
||||
|
||||
class StartLoginRequest(BaseModel):
|
||||
account_id: str = Field(..., description="渠道账号唯一标识")
|
||||
account_name: Optional[str] = Field(None, description="账号显示名称")
|
||||
|
||||
|
||||
class SendTextRequest(BaseModel):
|
||||
account_id: str
|
||||
conversation_id: str = Field(..., description="目标会话/群 ID")
|
||||
text: str = Field(..., min_length=1, description="消息内容")
|
||||
|
||||
|
||||
class SendImageRequest(BaseModel):
|
||||
account_id: str
|
||||
conversation_id: str
|
||||
image_url: str
|
||||
|
||||
|
||||
@router.post("/login/start")
|
||||
def start_login(request: StartLoginRequest):
|
||||
return login_service.start_login(request.account_id, request.account_name)
|
||||
|
||||
|
||||
@router.get("/login/{account_id}/status")
|
||||
def login_status(account_id: str):
|
||||
return login_service.get_status(account_id)
|
||||
|
||||
|
||||
@router.post("/login/{account_id}/confirm")
|
||||
def confirm_login(account_id: str):
|
||||
return login_service.confirm_login(account_id)
|
||||
|
||||
|
||||
@router.post("/send/text")
|
||||
def send_text(request: SendTextRequest):
|
||||
return send_service.send_text(request.account_id, request.conversation_id, request.text)
|
||||
|
||||
|
||||
@router.post("/send/image")
|
||||
def send_image(request: SendImageRequest):
|
||||
return send_service.send_image(request.account_id, request.conversation_id, request.image_url)
|
||||
0
channel-service/app/clients/__init__.py
Normal file
0
channel-service/app/clients/__init__.py
Normal file
110
channel-service/app/clients/pywechat_client.py
Normal file
110
channel-service/app/clients/pywechat_client.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""pywechat client abstraction.
|
||||
|
||||
The real implementation controls a Windows WeChat PC client via pywechat/pywinauto.
|
||||
This module provides a mock adapter for development and testing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class LoginStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
SCANNING = "scanning"
|
||||
SUCCESS = "success"
|
||||
EXPIRED = "expired"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class OnlineStatus(str, Enum):
|
||||
OFFLINE = "offline"
|
||||
ONLINE = "online"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeChatClientState:
|
||||
account_id: str
|
||||
account_name: Optional[str] = None
|
||||
login_status: LoginStatus = LoginStatus.PENDING
|
||||
online_status: OnlineStatus = OnlineStatus.OFFLINE
|
||||
last_heartbeat_at: Optional[float] = None
|
||||
login_id: Optional[str] = None
|
||||
qr_code_url: Optional[str] = None
|
||||
risk_level: int = 0
|
||||
sent_count_today: int = 0
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class PyWeChatClient:
|
||||
"""Mock pywechat client. Replace with real pywechat integration on Windows."""
|
||||
|
||||
def __init__(self, account_id: str, account_name: Optional[str] = None):
|
||||
self.state = WeChatClientState(account_id=account_id, account_name=account_name)
|
||||
self._conversations: dict[str, str] = {}
|
||||
|
||||
def start_login(self) -> str:
|
||||
login_id = str(uuid.uuid4())
|
||||
self.state.login_id = login_id
|
||||
self.state.login_status = LoginStatus.PENDING
|
||||
# In production this would return a real QR code from WeChat.
|
||||
self.state.qr_code_url = f"https://mock.pywechat.qrcode/{login_id}"
|
||||
return login_id
|
||||
|
||||
def confirm_login(self) -> None:
|
||||
self.state.login_status = LoginStatus.SUCCESS
|
||||
self.state.online_status = OnlineStatus.ONLINE
|
||||
self.state.last_heartbeat_at = time.time()
|
||||
|
||||
def expire_login(self) -> None:
|
||||
self.state.login_status = LoginStatus.EXPIRED
|
||||
self.state.online_status = OnlineStatus.OFFLINE
|
||||
|
||||
def heartbeat(self) -> None:
|
||||
self.state.last_heartbeat_at = time.time()
|
||||
if self.state.online_status != OnlineStatus.ERROR:
|
||||
self.state.online_status = OnlineStatus.ONLINE
|
||||
|
||||
def send_text(self, conversation_id: str, text: str) -> dict:
|
||||
"""Send a text message to a conversation (group or single chat).
|
||||
|
||||
In production this drives the WeChat PC client UI via pywechat.
|
||||
"""
|
||||
self.state.sent_count_today += 1
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": f"mock-msg-{uuid.uuid4().hex[:16]}",
|
||||
"conversation_id": conversation_id,
|
||||
"text_preview": text[:50],
|
||||
}
|
||||
|
||||
def get_state(self) -> WeChatClientState:
|
||||
# Auto-expire login after 5 minutes if not confirmed.
|
||||
if self.state.login_status == LoginStatus.PENDING and self.state.login_id:
|
||||
# mock: auto-confirm after a few seconds for testing
|
||||
pass
|
||||
return self.state
|
||||
|
||||
|
||||
class ClientRegistry:
|
||||
def __init__(self):
|
||||
self._clients: dict[str, PyWeChatClient] = {}
|
||||
|
||||
def get_or_create(self, account_id: str, account_name: Optional[str] = None) -> PyWeChatClient:
|
||||
if account_id not in self._clients:
|
||||
self._clients[account_id] = PyWeChatClient(account_id, account_name)
|
||||
return self._clients[account_id]
|
||||
|
||||
def get(self, account_id: str) -> Optional[PyWeChatClient]:
|
||||
return self._clients.get(account_id)
|
||||
|
||||
def list_all(self) -> list[PyWeChatClient]:
|
||||
return list(self._clients.values())
|
||||
|
||||
|
||||
registry = ClientRegistry()
|
||||
16
channel-service/app/config.py
Normal file
16
channel-service/app/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "sino-mci-channel-service"
|
||||
debug: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = int(os.getenv("PORT", "8000"))
|
||||
mci_server_url: str = os.getenv("MCI_SERVER_URL", "http://localhost:8080/msg-platform")
|
||||
heartbeat_interval_seconds: int = int(os.getenv("HEARTBEAT_INTERVAL_SECONDS", "30"))
|
||||
pywechat_mock_mode: bool = os.getenv("PYWECHAT_MOCK_MODE", "true").lower() == "true"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
15
channel-service/app/main.py
Normal file
15
channel-service/app/main.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api import health, monitor, wechat_personal
|
||||
from app.config import settings
|
||||
|
||||
app = FastAPI(title=settings.app_name, debug=settings.debug)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(wechat_personal.router)
|
||||
app.include_router(monitor.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=settings.debug)
|
||||
0
channel-service/app/services/__init__.py
Normal file
0
channel-service/app/services/__init__.py
Normal file
32
channel-service/app/services/heartbeat_service.py
Normal file
32
channel-service/app/services/heartbeat_service.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.clients.pywechat_client import ClientRegistry, OnlineStatus, registry
|
||||
|
||||
|
||||
class HeartbeatService:
|
||||
def __init__(self, registry: ClientRegistry):
|
||||
self.registry = registry
|
||||
|
||||
def heartbeat(self, account_id: str, payload: dict) -> dict:
|
||||
client = self.registry.get_or_create(account_id, payload.get("account_name"))
|
||||
client.heartbeat()
|
||||
if "risk_level" in payload:
|
||||
client.state.risk_level = payload["risk_level"]
|
||||
if "sent_count_today" in payload:
|
||||
client.state.sent_count_today = payload["sent_count_today"]
|
||||
return {"success": True, "account_id": account_id}
|
||||
|
||||
def check_offline_accounts(self, timeout_seconds: int = 120) -> list[dict]:
|
||||
now = time.time()
|
||||
offline = []
|
||||
for client in self.registry.list_all():
|
||||
last = client.state.last_heartbeat_at
|
||||
if client.state.online_status == OnlineStatus.ONLINE and (last is None or now - last > timeout_seconds):
|
||||
client.state.online_status = OnlineStatus.OFFLINE
|
||||
offline.append({"account_id": client.state.account_id, "last_heartbeat_at": last})
|
||||
return offline
|
||||
|
||||
|
||||
heartbeat_service = HeartbeatService(registry)
|
||||
53
channel-service/app/services/login_service.py
Normal file
53
channel-service/app/services/login_service.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from app.clients.pywechat_client import ClientRegistry, LoginStatus, OnlineStatus, registry
|
||||
|
||||
|
||||
class LoginService:
|
||||
def __init__(self, registry: ClientRegistry):
|
||||
self.registry = registry
|
||||
|
||||
def start_login(self, account_id: str, account_name: Optional[str] = None) -> dict:
|
||||
client = self.registry.get_or_create(account_id, account_name)
|
||||
login_id = client.start_login()
|
||||
return {
|
||||
"login_id": login_id,
|
||||
"qr_code_url": client.state.qr_code_url,
|
||||
"status": client.state.login_status.value,
|
||||
}
|
||||
|
||||
def get_status(self, account_id: str) -> dict:
|
||||
client = self.registry.get(account_id)
|
||||
if client is None:
|
||||
return {"found": False, "account_id": account_id}
|
||||
state = client.get_state()
|
||||
return {
|
||||
"found": True,
|
||||
"account_id": state.account_id,
|
||||
"account_name": state.account_name,
|
||||
"login_status": state.login_status.value,
|
||||
"online_status": state.online_status.value,
|
||||
"last_heartbeat_at": state.last_heartbeat_at,
|
||||
"risk_level": state.risk_level,
|
||||
"sent_count_today": state.sent_count_today,
|
||||
}
|
||||
|
||||
def confirm_login(self, account_id: str) -> dict:
|
||||
client = self.registry.get(account_id)
|
||||
if client is None:
|
||||
return {"success": False, "error": "account not found"}
|
||||
client.confirm_login()
|
||||
return {"success": True, "status": client.state.login_status.value}
|
||||
|
||||
def expire_login(self, account_id: str) -> dict:
|
||||
client = self.registry.get(account_id)
|
||||
if client is None:
|
||||
return {"success": False, "error": "account not found"}
|
||||
client.expire_login()
|
||||
return {"success": True, "status": client.state.login_status.value}
|
||||
|
||||
|
||||
login_service = LoginService(registry)
|
||||
33
channel-service/app/services/send_service.py
Normal file
33
channel-service/app/services/send_service.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.clients.pywechat_client import ClientRegistry, OnlineStatus, registry
|
||||
|
||||
|
||||
class SendService:
|
||||
def __init__(self, registry: ClientRegistry):
|
||||
self.registry = registry
|
||||
|
||||
def send_text(self, account_id: str, conversation_id: str, text: str) -> dict:
|
||||
client = self.registry.get(account_id)
|
||||
if client is None:
|
||||
return {"success": False, "error": f"account {account_id} not registered"}
|
||||
if client.state.online_status != OnlineStatus.ONLINE:
|
||||
return {"success": False, "error": "account not online"}
|
||||
return client.send_text(conversation_id, text)
|
||||
|
||||
def send_image(self, account_id: str, conversation_id: str, image_url: str) -> dict:
|
||||
# Placeholder for image sending via pywechat.
|
||||
client = self.registry.get(account_id)
|
||||
if client is None:
|
||||
return {"success": False, "error": f"account {account_id} not registered"}
|
||||
if client.state.online_status != OnlineStatus.ONLINE:
|
||||
return {"success": False, "error": "account not online"}
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": f"mock-img-{image_url.split('/')[-1]}",
|
||||
"conversation_id": conversation_id,
|
||||
"type": "image",
|
||||
}
|
||||
|
||||
|
||||
send_service = SendService(registry)
|
||||
7
channel-service/requirements.txt
Normal file
7
channel-service/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
httpx==0.28.1
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.2
|
||||
4
channel-service/tests/conftest.py
Normal file
4
channel-service/tests/conftest.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
70
channel-service/tests/test_api.py
Normal file
70
channel-service/tests/test_api.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_health():
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_login_flow():
|
||||
r = client.post("/v1/wechat-personal/login/start", json={"account_id": "bot-001", "account_name": "测试号"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["login_id"]
|
||||
assert body["qr_code_url"]
|
||||
|
||||
r = client.get("/v1/wechat-personal/login/bot-001/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["login_status"] == "pending"
|
||||
|
||||
r = client.post("/v1/wechat-personal/login/bot-001/confirm")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["success"] is True
|
||||
|
||||
r = client.get("/v1/wechat-personal/login/bot-001/status")
|
||||
assert r.json()["login_status"] == "success"
|
||||
assert r.json()["online_status"] == "online"
|
||||
|
||||
|
||||
def test_send_after_login():
|
||||
client.post("/v1/wechat-personal/login/start", json={"account_id": "bot-002"})
|
||||
client.post("/v1/wechat-personal/login/bot-002/confirm")
|
||||
|
||||
r = client.post("/v1/wechat-personal/send/text", json={
|
||||
"account_id": "bot-002",
|
||||
"conversation_id": "room-001",
|
||||
"text": "hello"
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["success"] is True
|
||||
assert r.json()["message_id"]
|
||||
|
||||
|
||||
def test_send_without_login_fails():
|
||||
r = client.post("/v1/wechat-personal/send/text", json={
|
||||
"account_id": "bot-offline",
|
||||
"conversation_id": "room-001",
|
||||
"text": "hello"
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["success"] is False
|
||||
|
||||
|
||||
def test_heartbeat_and_monitor():
|
||||
client.post("/v1/heartbeat", json={"account_id": "bot-003", "risk_level": 10, "sent_count_today": 5})
|
||||
r = client.get("/v1/monitor/status")
|
||||
assert r.status_code == 200
|
||||
clients = r.json()["clients"]
|
||||
assert len(clients) >= 1
|
||||
assert any(c["account_id"] == "bot-003" and c["risk_level"] == 10 for c in clients)
|
||||
|
||||
|
||||
def test_monitor_page():
|
||||
r = client.get("/monitor")
|
||||
assert r.status_code == 200
|
||||
assert "channel-status-table" in r.text
|
||||
Reference in New Issue
Block a user