上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。 现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 → 后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。 后端 - wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage, bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文 - wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题 - wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复; message_id 去重;errcode -14 转 expired 让用户重新扫 - api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息 - 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子) Web - 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息 验证 - pytest 149 passed(新增 test_wechat_bot 9 例) - ruff check / ruff format --check 干净;tsc 0;vite build ✓ - web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码) 教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
This commit is contained in:
@@ -1,207 +0,0 @@
|
||||
"""微信(clawbot)接入:一条消息进来,变成任务或知识。
|
||||
|
||||
测的是**整条链路**:令牌 → 租户上下文 → 建事项 → 留痕 → 重复推送幂等。
|
||||
webhook 不带登录态,所以这里故意不用 `auth()` 头,只有管理接口才带。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
|
||||
async def _channel(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations",
|
||||
json={"name": "我的微信"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _post(client: AsyncClient, token: str, payload: dict[str, Any]):
|
||||
return await client.post(f"/api/v1/integrations/clawbot/{token}", json=payload)
|
||||
|
||||
|
||||
async def test_plain_message_becomes_task(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信接入")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
resp = await _post(
|
||||
client,
|
||||
channel["token"],
|
||||
{"msg_id": "wx-1", "from": "steven", "text": "给客户回电话\n顺便确认下报价"},
|
||||
)
|
||||
data = unwrap(resp)
|
||||
assert data["action"] == "task"
|
||||
assert data["duplicate"] is False
|
||||
assert "给客户回电话" in data["reply"]
|
||||
|
||||
items = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
task = next(i for i in items if i["title"] == "给客户回电话")
|
||||
assert task["kind"] == "task"
|
||||
assert task["status"] == "inbox"
|
||||
assert "顺便确认下报价" in task["body_md"]
|
||||
|
||||
|
||||
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信知识")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
data = unwrap(
|
||||
await _post(
|
||||
client,
|
||||
channel["token"],
|
||||
{"text": "知识:回滚前先停灰度 #发布 #回滚\n1. 停流量\n2. 回滚镜像"},
|
||||
)
|
||||
)
|
||||
assert data["action"] == "knowledge"
|
||||
|
||||
docs = unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/knowledge?status=draft", headers=user["headers"]
|
||||
)
|
||||
)["items"]
|
||||
doc = next(d for d in docs if d["title"] == "回滚前先停灰度")
|
||||
assert doc["knowledge"]["status_hint"] == "draft"
|
||||
assert doc["knowledge"]["doc_type"] == "note"
|
||||
assert {t["name"] for t in doc["tags"]} == {"发布", "回滚"}
|
||||
# 标签从标题摘出去以后,正文里不该再留 # 号
|
||||
assert "#发布" not in doc["body_md"]
|
||||
|
||||
|
||||
async def test_help_reply_creates_nothing(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信帮助")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
data = unwrap(await _post(client, channel["token"], {"text": "帮助"}))
|
||||
assert data["action"] == "help"
|
||||
assert "记成任务" in data["reply"]
|
||||
items = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
assert items == []
|
||||
|
||||
|
||||
async def test_unknown_token_is_404(client: AsyncClient) -> None:
|
||||
resp = await client.post("/api/v1/integrations/clawbot/nope", json={"text": "x"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["code"] == 9002
|
||||
|
||||
|
||||
async def test_disabled_channel_refuses_messages(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信停用")
|
||||
channel = await _channel(client, user, ws)
|
||||
await client.patch(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}",
|
||||
json={"status": "disabled"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
resp = await _post(client, channel["token"], {"text": "这条不该进去"})
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] == 9003
|
||||
|
||||
|
||||
async def test_same_message_id_is_idempotent(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信重推")
|
||||
channel = await _channel(client, user, ws)
|
||||
payload = {"msg_id": "dup-1", "text": "只该有一条"}
|
||||
|
||||
first = unwrap(await _post(client, channel["token"], payload))
|
||||
second = unwrap(await _post(client, channel["token"], payload))
|
||||
assert second["duplicate"] is True
|
||||
assert second["reply"] == first["reply"]
|
||||
|
||||
items = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
assert [i["title"] for i in items] == ["只该有一条"]
|
||||
|
||||
|
||||
async def test_get_verify_echoes_challenge(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信校验")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
resp = await client.get(f"/api/v1/integrations/clawbot/{channel['token']}?echostr=abc123")
|
||||
assert resp.status_code == 200
|
||||
assert resp.text == "abc123"
|
||||
|
||||
|
||||
async def test_token_belongs_to_one_workspace(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws_a = await create_workspace(client, user, "A 区")
|
||||
ws_b = await create_workspace(client, user, "B 区")
|
||||
channel = await _channel(client, user, ws_a)
|
||||
|
||||
await _post(client, channel["token"], {"text": "只该进 A 区"})
|
||||
|
||||
items_a = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws_a['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
items_b = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws_b['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
assert [i["title"] for i in items_a] == ["只该进 A 区"]
|
||||
assert items_b == []
|
||||
|
||||
|
||||
async def test_rotate_invalidates_old_token(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信换令牌")
|
||||
channel = await _channel(client, user, ws)
|
||||
rotated = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}/rotate",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert rotated["token"] != channel["token"]
|
||||
old = await _post(client, channel["token"], {"text": "旧令牌"})
|
||||
assert old.status_code == 404
|
||||
new = unwrap(await _post(client, rotated["token"], {"text": "新令牌能用"}))
|
||||
assert new["action"] == "task"
|
||||
|
||||
|
||||
async def test_member_cannot_manage_channels(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "微信权限")
|
||||
member = await login(client)
|
||||
code = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": code["code"]},
|
||||
headers=member["headers"],
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations",
|
||||
json={"name": "偷加的通道"},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] in (3004, 3003)
|
||||
|
||||
|
||||
async def test_webhook_url_carries_api_prefix(client: AsyncClient) -> None:
|
||||
"""回调地址必须带 /api/v1:生产上应用挂在域名根,漏了前缀就是 405。"""
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信地址")
|
||||
channel = await _channel(client, user, ws)
|
||||
assert channel["webhook_url"].endswith(f"/api/v1/integrations/clawbot/{channel['token']}")
|
||||
@@ -0,0 +1,326 @@
|
||||
"""微信 clawbot:扫码登录 + 长轮询 + 消息落成任务/知识。
|
||||
|
||||
不联网:iLink 的四个接口都用 monkeypatch 顶掉,验的是我们这侧的流程与落库:
|
||||
取二维码 → 等扫码 → 存加密登录态 → 轮询拿到消息 → 建事项 → 留痕 → 回复 → 去重。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.db import SessionFactory, bind_tenant
|
||||
from app.models_integrations import BotChannel, BotMessage
|
||||
from app.models_task import Item
|
||||
from app.services import wechat_bot, wechat_ilink
|
||||
from app.services.wechat_intake import handle_text
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_background_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""别在测试里真起轮询任务:这里只验"该不该起",任务体另测。"""
|
||||
|
||||
async def fake_start(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
|
||||
return None
|
||||
|
||||
async def fake_stop(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wechat_bot.BotManager, "start", fake_start)
|
||||
monkeypatch.setattr(wechat_bot.BotManager, "stop", fake_stop)
|
||||
|
||||
|
||||
async def _begin(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _patch_qr(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_qr() -> wechat_ilink.QrCode:
|
||||
return wechat_ilink.QrCode(qrcode_id="qr-1", payload="https://wx.example/qr/1")
|
||||
|
||||
monkeypatch.setattr(wechat_ilink, "get_qrcode", fake_qr)
|
||||
|
||||
|
||||
def _patch_status(monkeypatch: pytest.MonkeyPatch, status: wechat_ilink.QrStatus) -> None:
|
||||
async def fake_status(qrcode_id: str) -> wechat_ilink.QrStatus: # noqa: ARG001
|
||||
return status
|
||||
|
||||
monkeypatch.setattr(wechat_ilink, "qrcode_status", fake_status)
|
||||
|
||||
|
||||
async def test_begin_login_returns_scannable_qr(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
|
||||
data = await _begin(client, user, ws)
|
||||
|
||||
assert data["qrcode_id"] == "qr-1"
|
||||
assert data["payload"] == "https://wx.example/qr/1"
|
||||
assert data["image"].startswith("data:image/svg+xml;base64,")
|
||||
|
||||
channels = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
|
||||
)
|
||||
assert channels["items"][0]["status"] == "pending"
|
||||
assert channels["items"][0]["connected"] is False
|
||||
|
||||
|
||||
async def test_scan_confirms_and_stores_encrypted_token(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(
|
||||
monkeypatch,
|
||||
wechat_ilink.QrStatus(
|
||||
status="confirmed", bot_token="bot-token-abc", ilink_user_id="wx-user-9"
|
||||
),
|
||||
)
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
started = await _begin(client, user, ws)
|
||||
|
||||
result = unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert result["status"] == "confirmed"
|
||||
assert result["channel"]["connected"] is True
|
||||
assert result["channel"]["ilink_user_id"] == "wx-user-9"
|
||||
|
||||
channel_id = uuid.UUID(started["channel_id"])
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
|
||||
)
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
assert channel is not None
|
||||
assert channel.bot_token_enc is not None
|
||||
# 落库的是密文,且能解回原值
|
||||
assert "bot-token-abc" not in channel.bot_token_enc
|
||||
assert wechat_ilink.decrypt_token(channel.bot_token_enc) == "bot-token-abc"
|
||||
|
||||
|
||||
async def test_stale_qr_poll_is_reported_as_expired(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
await _begin(client, user, ws)
|
||||
|
||||
data = unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/another-qr",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert data["status"] == "expired"
|
||||
|
||||
|
||||
async def test_disconnect_clears_login_state(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
started = await _begin(client, user, ws)
|
||||
unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
data = unwrap(
|
||||
await client.delete(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/{started['channel_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert data["channel"]["connected"] is False
|
||||
assert data["channel"]["status"] == "pending"
|
||||
|
||||
|
||||
async def test_plain_text_becomes_task(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "收消息")
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
|
||||
)
|
||||
session.add(channel)
|
||||
await session.flush()
|
||||
result = await handle_text(
|
||||
session, channel, "给客户回电话\n顺便确认报价", base_url="http://site"
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert result["action"] == "task"
|
||||
assert "已记下任务" in result["reply"]
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
item = await session.get(Item, result["item_id"])
|
||||
assert item is not None
|
||||
assert item.title == "给客户回电话"
|
||||
assert item.kind == "task"
|
||||
assert item.status == "inbox"
|
||||
|
||||
|
||||
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "收消息")
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
|
||||
)
|
||||
session.add(channel)
|
||||
await session.flush()
|
||||
result = await handle_text(
|
||||
session,
|
||||
channel,
|
||||
"知识:回滚前先停灰度 #发布 #回滚\n先停流量,再退镜像版本",
|
||||
base_url="http://site",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert result["action"] == "knowledge"
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
item = await session.get(Item, result["item_id"])
|
||||
assert item is not None
|
||||
assert item.kind == "knowledge"
|
||||
assert item.status == "draft"
|
||||
assert item.title == "回滚前先停灰度"
|
||||
assert "#" not in item.title
|
||||
|
||||
|
||||
async def test_help_does_not_create_anything(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "收消息")
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
|
||||
)
|
||||
session.add(channel)
|
||||
await session.flush()
|
||||
result = await handle_text(session, channel, "帮助", base_url="http://site")
|
||||
await session.commit()
|
||||
|
||||
assert result["action"] == "help"
|
||||
assert result["item_id"] is None
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
items = (
|
||||
(await session.execute(select(Item).where(Item.workspace_id == ws_id))).scalars().all()
|
||||
)
|
||||
assert items == []
|
||||
|
||||
|
||||
async def test_poller_handles_message_once_and_replies(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""同一个外部消息 id 到两次,只能有一条任务、一条留痕、一次回复。"""
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "轮询")
|
||||
started = await _begin(client, user, ws)
|
||||
unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
channel_id = uuid.UUID(started["channel_id"])
|
||||
|
||||
replies: list[tuple[str, str]] = []
|
||||
|
||||
async def fake_send(
|
||||
token: str, to: str, context: str | None, text: str, *, base_url: str | None = None
|
||||
) -> None: # noqa: ARG001
|
||||
replies.append((to, text))
|
||||
|
||||
monkeypatch.setattr(wechat_ilink, "send_text", fake_send)
|
||||
|
||||
message = wechat_ilink.Inbound(
|
||||
message_id="msg-1",
|
||||
sender="wx-user-9",
|
||||
text="整理一下本周排期",
|
||||
context_token="ctx-1",
|
||||
)
|
||||
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
|
||||
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
|
||||
|
||||
assert len(replies) == 1
|
||||
assert replies[0][0] == "wx-user-9"
|
||||
assert "已记下任务" in replies[0][1]
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
|
||||
)
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
assert channel is not None
|
||||
assert channel.context_token == "ctx-1"
|
||||
assert channel.last_seen_at is not None
|
||||
# bot_message 走标准租户隔离,验它得先绑定工作区
|
||||
await bind_tenant(session, uuid.UUID(ws["id"]), uuid.UUID(user["user_id"]))
|
||||
messages = (
|
||||
(await session.execute(select(BotMessage).where(BotMessage.channel_id == channel_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].action == "task"
|
||||
|
||||
|
||||
async def test_stale_token_marks_channel_expired(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "轮询")
|
||||
started = await _begin(client, user, ws)
|
||||
unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
channel_id = uuid.UUID(started["channel_id"])
|
||||
|
||||
await wechat_bot.manager._mark_relogin(channel_id, "登录态已失效,需要重新扫码")
|
||||
|
||||
channels = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
|
||||
)
|
||||
assert channels["items"][0]["status"] == "expired"
|
||||
assert channels["items"][0]["connected"] is False
|
||||
assert "重新扫码" in channels["items"][0]["last_error"]
|
||||
Reference in New Issue
Block a user