642 lines
24 KiB
Python
642 lines
24 KiB
Python
"""ArrayClient - 列阵平台客户端,封装所有 REST API 和 WebSocket 通信"""
|
||
import asyncio
|
||
import base64
|
||
import json
|
||
from typing import AsyncGenerator
|
||
|
||
import httpx
|
||
import websockets
|
||
|
||
from .models import Worker, Task, TaskParticipant, Message, Notification, Event, Organization, OrgMember, OrgInvite
|
||
|
||
|
||
class ArrayClient:
|
||
"""列阵平台客户端,封装所有 REST API 和 WebSocket 通信"""
|
||
|
||
PROTOCOL_VERSION = 5 # 协议版本,与 server/app/protocol.py 对应
|
||
|
||
def __init__(self, server_url: str, api_key: str):
|
||
self.server_url = server_url.rstrip("/")
|
||
self.api_key = api_key
|
||
self.worker_id: str | None = None
|
||
self._access_token: str | None = None
|
||
self._refresh_token: str | None = None
|
||
self._http: httpx.AsyncClient | None = None
|
||
self._ws_connected_event = asyncio.Event()
|
||
|
||
async def __aenter__(self):
|
||
self._http = httpx.AsyncClient(base_url=self.server_url, timeout=30.0, follow_redirects=True)
|
||
await self._authenticate()
|
||
return self
|
||
|
||
async def __aexit__(self, *args):
|
||
if self._http:
|
||
await self._http.aclose()
|
||
|
||
async def _authenticate(self):
|
||
"""用 api_key 获取 JWT token"""
|
||
resp = await self._http.post("/api/v1/workers/token", json={"api_key": self.api_key})
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
self._access_token = data["access_token"]
|
||
self._refresh_token = data["refresh_token"]
|
||
# 从 token 中解析 worker_id(解码JWT payload的sub字段)
|
||
payload_part = self._access_token.split(".")[1]
|
||
# 补齐 base64 padding
|
||
padding = 4 - len(payload_part) % 4
|
||
if padding != 4:
|
||
payload_part += "=" * padding
|
||
payload = json.loads(base64.urlsafe_b64decode(payload_part))
|
||
self.worker_id = payload["sub"]
|
||
|
||
async def _refresh_access_token(self):
|
||
"""用 refresh_token 刷新 JWT"""
|
||
resp = await self._http.post("/api/v1/workers/token/refresh", json={"refresh_token": self._refresh_token})
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
self._access_token = data["access_token"]
|
||
self._refresh_token = data["refresh_token"]
|
||
|
||
def _headers(self) -> dict:
|
||
return {
|
||
"Authorization": f"Bearer {self._access_token}",
|
||
"X-Protocol-Version": str(self.PROTOCOL_VERSION)
|
||
}
|
||
|
||
async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
||
"""统一请求方法,自动处理 token 刷新"""
|
||
resp = await self._http.request(method, path, headers=self._headers(), **kwargs)
|
||
if resp.status_code == 401:
|
||
# token 可能过期,尝试刷新
|
||
await self._refresh_access_token()
|
||
resp = await self._http.request(method, path, headers=self._headers(), **kwargs)
|
||
resp.raise_for_status()
|
||
return resp
|
||
|
||
# === Worker 相关 ===
|
||
|
||
async def get_worker(self, worker_id: str) -> Worker:
|
||
"""获取 Worker 详情"""
|
||
resp = await self._request("GET", f"/api/v1/workers/{worker_id}")
|
||
return Worker(**resp.json())
|
||
|
||
async def list_workers(self, type: str | None = None, status: str | None = None, org_id: str | None = None, page: int = 1, limit: int = 20) -> tuple[list[Worker], int]:
|
||
"""获取 Worker 列表
|
||
|
||
参数:
|
||
type: 按类型筛选
|
||
status: 按状态筛选
|
||
org_id: 按所属组织筛选
|
||
page: 页码,默认 1
|
||
limit: 每页数量,默认 20
|
||
"""
|
||
params = {"page": page, "limit": limit}
|
||
if type:
|
||
params["type"] = type
|
||
if status:
|
||
params["status"] = status
|
||
if org_id:
|
||
params["org_id"] = org_id
|
||
resp = await self._request("GET", "/api/v1/workers", params=params)
|
||
data = resp.json()
|
||
return [Worker(**w) for w in data["workers"]], data["total"]
|
||
|
||
async def update_worker(self, **kwargs) -> Worker:
|
||
"""更新自己的信息"""
|
||
resp = await self._request("PATCH", f"/api/v1/workers/{self.worker_id}", json=kwargs)
|
||
return Worker(**resp.json())
|
||
|
||
async def update_profile(self, **kwargs) -> Worker:
|
||
"""更新自己的简历信息。支持的字段:name, description, organization, location, timezone, tags, capabilities, max_concurrent_tasks, avatar_url"""
|
||
if self.worker_id is None:
|
||
raise ValueError("worker_id is not available, please authenticate first")
|
||
resp = await self._request("PATCH", f"/api/v1/workers/{self.worker_id}", json=kwargs)
|
||
return Worker(**resp.json())
|
||
|
||
async def get_worker_profile(self, worker_id: str) -> Worker:
|
||
"""获取任意 worker 的完整简历"""
|
||
resp = await self._request("GET", f"/api/v1/workers/{worker_id}")
|
||
return Worker(**resp.json())
|
||
|
||
async def get_worker_workload(self, worker_id: str) -> dict:
|
||
"""查询 worker 的工作负载"""
|
||
resp = await self._request("GET", f"/api/v1/workers/{worker_id}/workload")
|
||
return resp.json()
|
||
|
||
async def discover_workers(self, skill=None, domain=None, tag=None, keyword=None, available_only=False, status=None, type=None, page=1, limit=20) -> dict:
|
||
"""多条件能力发现
|
||
|
||
参数:
|
||
skill: 技能名称
|
||
domain: 领域名称
|
||
tag: 标签
|
||
keyword: 关键词搜索
|
||
available_only: 是否只返回可接受的 worker
|
||
status: 按状态筛选
|
||
type: 按类型筛选
|
||
page: 页码,默认 1
|
||
limit: 每页数量,默认 20
|
||
"""
|
||
params = {"page": page, "limit": limit}
|
||
if skill:
|
||
params["skill"] = skill
|
||
if domain:
|
||
params["domain"] = domain
|
||
if tag:
|
||
params["tag"] = tag
|
||
if keyword:
|
||
params["keyword"] = keyword
|
||
if available_only:
|
||
params["available_only"] = "true"
|
||
if status:
|
||
params["status"] = status
|
||
if type:
|
||
params["type"] = type
|
||
resp = await self._request("GET", "/api/v1/workers/discover", params=params)
|
||
return resp.json()
|
||
|
||
# === Task 相关 ===
|
||
|
||
async def create_task(self, title: str, description: str | None = None,
|
||
priority: str = "normal", parent_id: str | None = None,
|
||
tags: list[str] | None = None, deadline: str | None = None,
|
||
metadata: dict | None = None,
|
||
participant_ids: list[str] | None = None,
|
||
org_id: str | None = None) -> Task:
|
||
"""创建任务
|
||
|
||
参数:
|
||
title: 任务标题
|
||
description: 任务描述
|
||
priority: 优先级,默认 normal
|
||
parent_id: 父任务 ID
|
||
tags: 标签列表
|
||
deadline: 截止日期
|
||
metadata: 元数据字典
|
||
participant_ids: 参与者 ID 列表
|
||
org_id: 所属组织 ID
|
||
|
||
异常:
|
||
可能返回 409 状态码,表示创建者或某个参与者已达到最大并发任务数限制,
|
||
detail 字段会包含具体是谁超了(名字和 ID)
|
||
"""
|
||
payload = {"title": title, "priority": priority}
|
||
if description:
|
||
payload["description"] = description
|
||
if parent_id:
|
||
payload["parent_id"] = parent_id
|
||
if tags:
|
||
payload["tags"] = tags
|
||
if deadline:
|
||
payload["deadline"] = deadline
|
||
if metadata:
|
||
payload["metadata"] = metadata
|
||
if participant_ids:
|
||
payload["participant_ids"] = participant_ids
|
||
if org_id:
|
||
payload["org_id"] = org_id
|
||
resp = await self._request("POST", "/api/v1/tasks", json=payload)
|
||
return Task(**resp.json())
|
||
|
||
async def get_task(self, task_id: str) -> Task:
|
||
"""获取任务详情"""
|
||
resp = await self._request("GET", f"/api/v1/tasks/{task_id}")
|
||
return Task(**resp.json())
|
||
|
||
async def list_tasks(self, status: str | None = None, creator_id: str | None = None,
|
||
tag: str | None = None, org_id: str | None = None,
|
||
sort: str = "-created_at", page: int = 1, limit: int = 20) -> tuple[list[Task], int]:
|
||
"""获取任务列表
|
||
|
||
参数:
|
||
status: 按状态筛选
|
||
creator_id: 按创建者筛选
|
||
tag: 按标签筛选
|
||
org_id: 按所属组织筛选
|
||
sort: 排序方式,默认 -created_at
|
||
page: 页码,默认 1
|
||
limit: 每页数量,默认 20
|
||
"""
|
||
params = {"sort": sort, "page": page, "limit": limit}
|
||
if status:
|
||
params["status"] = status
|
||
if creator_id:
|
||
params["creator_id"] = creator_id
|
||
if tag:
|
||
params["tag"] = tag
|
||
if org_id:
|
||
params["org_id"] = org_id
|
||
resp = await self._request("GET", "/api/v1/tasks", params=params)
|
||
data = resp.json()
|
||
return [Task(**t) for t in data["tasks"]], data["total"]
|
||
|
||
async def update_task(self, task_id: str, **kwargs) -> Task:
|
||
"""更新任务(权限由服务端校验)"""
|
||
resp = await self._request("PATCH", f"/api/v1/tasks/{task_id}", json=kwargs)
|
||
return Task(**resp.json())
|
||
|
||
async def get_children(self, task_id: str) -> list[Task]:
|
||
"""获取子任务列表"""
|
||
resp = await self._request("GET", f"/api/v1/tasks/{task_id}/children")
|
||
return [Task(**t) for t in resp.json()]
|
||
|
||
# === 参与者管理 ===
|
||
|
||
async def add_participants(self, task_id: str, worker_ids: list[str]) -> list[TaskParticipant]:
|
||
"""添加参与者到任务
|
||
|
||
异常:
|
||
可能返回 409 状态码,表示某个参与者已达到最大并发任务数限制,
|
||
detail 字段会包含具体是谁超了(名字和 ID)
|
||
"""
|
||
payload = {"worker_ids": worker_ids}
|
||
resp = await self._request("POST", f"/api/v1/tasks/{task_id}/participants", json=payload)
|
||
return [TaskParticipant(**p) for p in resp.json()]
|
||
|
||
async def list_participants(self, task_id: str) -> list[TaskParticipant]:
|
||
"""获取任务的参与者列表"""
|
||
resp = await self._request("GET", f"/api/v1/tasks/{task_id}/participants")
|
||
return [TaskParticipant(**p) for p in resp.json()]
|
||
|
||
async def remove_participant(self, task_id: str, worker_id: str) -> None:
|
||
"""从任务中移除参与者"""
|
||
await self._request("DELETE", f"/api/v1/tasks/{task_id}/participants/{worker_id}")
|
||
|
||
# === Message 相关 ===
|
||
|
||
async def send_message(self, task_id: str, content: str, type: str = "comment",
|
||
reply_to_id: str | None = None,
|
||
mention_type: str = "none",
|
||
mention_ids: list[str] | None = None,
|
||
scope: str = "chat",
|
||
private_to_id: str | None = None) -> Message:
|
||
"""在任务上发消息
|
||
|
||
scope 参数:
|
||
- "chat"(默认):正常对话消息
|
||
- "trace":适配器响应/状态记录,不参与正常聊天流
|
||
"""
|
||
payload = {"content": content, "type": type, "scope": scope}
|
||
if reply_to_id:
|
||
payload["reply_to_id"] = reply_to_id
|
||
if mention_type:
|
||
payload["mention_type"] = mention_type
|
||
if mention_ids:
|
||
payload["mention_ids"] = mention_ids
|
||
if private_to_id:
|
||
payload["private_to_id"] = private_to_id
|
||
resp = await self._request("POST", f"/api/v1/tasks/{task_id}/messages", json=payload)
|
||
return Message(**resp.json())
|
||
|
||
async def list_messages(self, task_id: str, page: int = 1, limit: int = 20,
|
||
sort: str = "-created_at",
|
||
scope: str = "chat",
|
||
include_private: bool = False,
|
||
include_system: bool = False,
|
||
type_filter: str | None = None) -> tuple[list[Message], int]:
|
||
"""获取任务消息列表
|
||
|
||
scope 参数:
|
||
- "chat"(默认):只返回对话消息
|
||
- "trace":只返回适配器响应/状态记录
|
||
- "all":返回全部消息
|
||
"""
|
||
params = {"page": page, "limit": limit, "sort": sort, "scope": scope}
|
||
if include_private:
|
||
params["include_private"] = "true"
|
||
if include_system:
|
||
params["include_system"] = "true"
|
||
if type_filter:
|
||
params["type_filter"] = type_filter
|
||
resp = await self._request("GET", f"/api/v1/tasks/{task_id}/messages", params=params)
|
||
data = resp.json()
|
||
return [Message(**m) for m in data["messages"]], data["total"]
|
||
|
||
# === Notification 相关 ===
|
||
|
||
async def get_notifications(self, is_read: bool | None = None, page: int = 1,
|
||
limit: int = 50) -> tuple[list[Notification], int]:
|
||
"""获取通知列表"""
|
||
params = {"page": page, "limit": limit}
|
||
if is_read is not None:
|
||
params["is_read"] = str(is_read).lower()
|
||
resp = await self._request("GET", "/api/v1/notifications", params=params)
|
||
data = resp.json()
|
||
return [Notification(**n) for n in data["notifications"]], data["total"]
|
||
|
||
async def mark_notification_read(self, notification_id: str) -> None:
|
||
"""标记单条通知已读"""
|
||
await self._request("PATCH", f"/api/v1/notifications/{notification_id}/read")
|
||
|
||
async def mark_all_notifications_read(self) -> None:
|
||
"""标记所有通知已读"""
|
||
await self._request("POST", "/api/v1/notifications/read-all")
|
||
|
||
# === Review 相关 ===
|
||
|
||
async def create_review(self, worker_id: str, task_id: str, rating: int,
|
||
capability_tags: list[str] | None = None,
|
||
comment: str | None = None,
|
||
suggestion: str | None = None) -> dict:
|
||
"""为某个 worker 在某个任务中创建评价(调用者必须是任务创建者)"""
|
||
data = {"task_id": task_id, "rating": rating}
|
||
if capability_tags is not None:
|
||
data["capability_tags"] = capability_tags
|
||
if comment is not None:
|
||
data["comment"] = comment
|
||
if suggestion is not None:
|
||
data["suggestion"] = suggestion
|
||
resp = await self._request("POST", f"/api/v1/workers/{worker_id}/reviews", json=data)
|
||
return resp.json()
|
||
|
||
async def get_worker_reviews(self, worker_id: str, page: int = 1, limit: int = 20) -> dict:
|
||
"""获取某个 worker 收到的评价列表"""
|
||
resp = await self._request("GET", f"/api/v1/workers/{worker_id}/reviews",
|
||
params={"page": page, "limit": limit})
|
||
return resp.json()
|
||
|
||
async def get_review_summary(self, worker_id: str) -> dict:
|
||
"""获取某个 worker 的评价汇总"""
|
||
resp = await self._request("GET", f"/api/v1/workers/{worker_id}/reviews/summary")
|
||
return resp.json()
|
||
|
||
# === 组织管理 ===
|
||
|
||
async def create_organization(self, name: str, description: str | None = None) -> Organization:
|
||
"""创建新组织
|
||
|
||
参数:
|
||
name: 组织名称
|
||
description: 组织描述
|
||
|
||
返回:
|
||
创建的组织信息
|
||
"""
|
||
payload = {"name": name}
|
||
if description:
|
||
payload["description"] = description
|
||
resp = await self._request("POST", "/api/v1/organizations", json=payload)
|
||
return Organization(**resp.json())
|
||
|
||
async def list_my_organizations(self) -> list[Organization]:
|
||
"""获取我所属的所有组织
|
||
|
||
返回:
|
||
组织列表
|
||
"""
|
||
resp = await self._request("GET", "/api/v1/organizations")
|
||
return [Organization(**o) for o in resp.json()]
|
||
|
||
async def get_organization(self, org_id: str) -> Organization:
|
||
"""获取组织详情
|
||
|
||
参数:
|
||
org_id: 组织 ID
|
||
|
||
返回:
|
||
组织详细信息
|
||
"""
|
||
resp = await self._request("GET", f"/api/v1/organizations/{org_id}")
|
||
return Organization(**resp.json())
|
||
|
||
async def update_organization(self, org_id: str, name: str | None = None, description: str | None = None) -> Organization:
|
||
"""更新组织信息
|
||
|
||
参数:
|
||
org_id: 组织 ID
|
||
name: 新名称(可选)
|
||
description: 新描述(可选)
|
||
|
||
返回:
|
||
更新后的组织信息
|
||
|
||
注意:
|
||
需要组织管理员权限
|
||
"""
|
||
payload = {}
|
||
if name is not None:
|
||
payload["name"] = name
|
||
if description is not None:
|
||
payload["description"] = description
|
||
resp = await self._request("PATCH", f"/api/v1/organizations/{org_id}", json=payload)
|
||
return Organization(**resp.json())
|
||
|
||
# === 邀请码管理 ===
|
||
|
||
async def create_invite(self, org_id: str, expires_at: str | None = None, max_uses: int | None = None) -> OrgInvite:
|
||
"""创建组织邀请码
|
||
|
||
参数:
|
||
org_id: 组织 ID
|
||
expires_at: 过期时间(ISO 格式字符串,可选)
|
||
max_uses: 最大使用次数(可选)
|
||
|
||
返回:
|
||
创建的邀请码信息
|
||
|
||
注意:
|
||
需要组织管理员权限
|
||
"""
|
||
payload = {}
|
||
if expires_at:
|
||
payload["expires_at"] = expires_at
|
||
if max_uses is not None:
|
||
payload["max_uses"] = max_uses
|
||
resp = await self._request("POST", f"/api/v1/organizations/{org_id}/invites", json=payload)
|
||
return OrgInvite(**resp.json())
|
||
|
||
async def list_invites(self, org_id: str) -> list[OrgInvite]:
|
||
"""获取组织的邀请码列表
|
||
|
||
参数:
|
||
org_id: 组织 ID
|
||
|
||
返回:
|
||
邀请码列表
|
||
|
||
注意:
|
||
需要组织管理员权限
|
||
"""
|
||
resp = await self._request("GET", f"/api/v1/organizations/{org_id}/invites")
|
||
return [OrgInvite(**i) for i in resp.json()]
|
||
|
||
async def join_organization(self, invite_code: str) -> dict:
|
||
"""通过邀请码加入组织
|
||
|
||
参数:
|
||
invite_code: 邀请码
|
||
|
||
返回:
|
||
加入结果,包含 org_id、worker_id、role、joined_at
|
||
"""
|
||
payload = {"invite_code": invite_code}
|
||
resp = await self._request("POST", "/api/v1/organizations/join", json=payload)
|
||
return resp.json()
|
||
|
||
# === 组织成员管理 ===
|
||
|
||
async def list_org_members(self, org_id: str) -> list[OrgMember]:
|
||
"""获取组织成员列表
|
||
|
||
参数:
|
||
org_id: 组织 ID
|
||
|
||
返回:
|
||
成员列表
|
||
"""
|
||
resp = await self._request("GET", f"/api/v1/organizations/{org_id}/members")
|
||
return [OrgMember(**m) for m in resp.json()]
|
||
|
||
async def remove_org_member(self, org_id: str, worker_id: str) -> None:
|
||
"""移除组织成员
|
||
|
||
参数:
|
||
org_id: 组织 ID
|
||
worker_id: 要移除的成员 Worker ID
|
||
|
||
注意:
|
||
需要组织管理员权限,不能移除自己
|
||
"""
|
||
await self._request("DELETE", f"/api/v1/organizations/{org_id}/members/{worker_id}")
|
||
|
||
async def update_member_role(self, org_id: str, worker_id: str, role: str) -> OrgMember:
|
||
"""修改成员角色
|
||
|
||
参数:
|
||
org_id: 组织 ID
|
||
worker_id: 成员 Worker ID
|
||
role: 新角色("admin" 或 "member")
|
||
|
||
返回:
|
||
更新后的成员信息
|
||
|
||
注意:
|
||
需要组织管理员权限
|
||
"""
|
||
payload = {"role": role}
|
||
resp = await self._request("PATCH", f"/api/v1/organizations/{org_id}/members/{worker_id}/role", json=payload)
|
||
# 服务端返回的是简化格式,构造 OrgMember
|
||
data = resp.json()
|
||
return OrgMember(
|
||
worker_id=data["worker_id"],
|
||
role=data["role"],
|
||
worker_name="",
|
||
worker_type="",
|
||
joined_at=None
|
||
)
|
||
|
||
# === Admin 管理 ===
|
||
|
||
async def admin_rename_worker(self, worker_id: str, name: str, **kwargs):
|
||
"""Admin 修改 Worker 信息
|
||
|
||
仅 type="admin" 的 Worker 可调用。
|
||
|
||
Args:
|
||
worker_id: 目标 Worker ID
|
||
name: 新名称
|
||
**kwargs: 其他可选字段 (description, capabilities, organization, location, timezone, tags, avatar_url)
|
||
|
||
Returns:
|
||
修改后的 Worker 信息
|
||
|
||
Raises:
|
||
403: 调用者不是 admin
|
||
404: Worker 不存在
|
||
"""
|
||
data = {"name": name, **kwargs}
|
||
return await self._request("PATCH", f"/admin/workers/{worker_id}", json=data)
|
||
|
||
async def admin_delete_worker(self, worker_id: str):
|
||
"""Admin 软删除 Worker
|
||
|
||
将 Worker 状态设为 deleted,保留历史数据。被删除的 Worker 无法登录和出现在列表中。
|
||
仅 type="admin" 的 Worker 可调用。
|
||
|
||
Args:
|
||
worker_id: 目标 Worker ID
|
||
|
||
Returns:
|
||
删除确认信息
|
||
|
||
Raises:
|
||
403: 调用者不是 admin
|
||
404: Worker 不存在
|
||
"""
|
||
return await self._request("DELETE", f"/admin/workers/{worker_id}")
|
||
|
||
async def admin_create_worker(self, name: str, type: str, **kwargs):
|
||
"""Admin 代注册 Worker
|
||
|
||
Admin 代替他人注册 Worker,返回包含 API Key 的注册信息。
|
||
仅 type="admin" 的 Worker 可调用。
|
||
|
||
Args:
|
||
name: Worker 名称
|
||
type: Worker 类型 (human/ai/tool/admin)
|
||
**kwargs: 其他可选字段 (description, capabilities, organization, location, timezone, tags, avatar_url)
|
||
|
||
Returns:
|
||
注册信息,包含 id 和 api_key
|
||
|
||
Raises:
|
||
403: 调用者不是 admin
|
||
"""
|
||
data = {"name": name, "type": type, **kwargs}
|
||
return await self._request("POST", f"/admin/workers", json=data)
|
||
|
||
# === WebSocket 实时监听 ===
|
||
|
||
async def _ensure_authenticated(self):
|
||
"""确保已认证,如果未认证则进行认证"""
|
||
if self._access_token is None:
|
||
await self._authenticate()
|
||
|
||
def _build_ws_url(self) -> str:
|
||
"""构建 WebSocket URL"""
|
||
ws_url = self.server_url.replace("http://", "ws://").replace("https://", "wss://")
|
||
return f"{ws_url}/api/v1/ws?token={self._access_token}&protocol={self.PROTOCOL_VERSION}"
|
||
|
||
async def listen(self) -> AsyncGenerator[Event, None]:
|
||
"""连接 WebSocket 并 yield 实时事件。自动心跳保活,带断线重连。"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
delay = 2.0
|
||
while True:
|
||
try:
|
||
await self._ensure_authenticated()
|
||
ws_url = self._build_ws_url()
|
||
async with websockets.connect(ws_url) as ws:
|
||
delay = 2.0 # 重置退避
|
||
# 启动心跳任务
|
||
async def heartbeat():
|
||
while True:
|
||
try:
|
||
await asyncio.sleep(30)
|
||
await ws.send("ping")
|
||
except Exception:
|
||
break
|
||
|
||
heartbeat_task = asyncio.create_task(heartbeat())
|
||
# 通知 WebSocket 已连接
|
||
self._ws_connected_event.set()
|
||
try:
|
||
async for raw_message in ws:
|
||
if raw_message == "pong":
|
||
continue
|
||
data = json.loads(raw_message)
|
||
yield Event(**data)
|
||
finally:
|
||
heartbeat_task.cancel()
|
||
except asyncio.CancelledError:
|
||
return
|
||
except Exception as e:
|
||
logger.warning(f"WebSocket 断开: {e},{delay:.0f}s 后重连")
|
||
await asyncio.sleep(delay)
|
||
delay = min(delay * 2, 60.0)
|
||
|
||
async def wait_for_ws_connection(self, timeout: float = 10.0):
|
||
"""等待 WebSocket 连接建立"""
|
||
await asyncio.wait_for(self._ws_connected_event.wait(), timeout=timeout)
|