feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

View File

@ -0,0 +1,23 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs npm ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml ./
COPY package.json ./
RUN pip install --no-cache-dir \
"fastapi>=0.115.0,<1.0" \
"httpx>=0.27.0,<1.0" \
"pydantic>=2.7.0,<3.0" \
"qrcode>=8.0,<9.0" \
"uvicorn[standard]>=0.30.0,<1.0" \
&& npm install --omit=dev --package-lock=false
COPY external_connector ./external_connector
ENV CONNECTOR_HOME=/var/lib/external-connector
EXPOSE 8787
CMD ["python", "-m", "external_connector.main"]

View File

@ -0,0 +1 @@
"""Generic external connector sidecar."""

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
from external_connector.models import ConnectorSessionRequest, SendRequest
from external_connector.providers.base import ConnectorProvider
def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI:
app = FastAPI(title="External Connector")
def require_auth(authorization: str | None) -> None:
if api_token and authorization != f"Bearer {api_token}":
raise HTTPException(status_code=401, detail="Invalid connector token")
@app.get("/health")
def health() -> dict[str, Any]:
return provider.health()
@app.get("/connectors")
def connectors(authorization: str | None = Header(default=None)) -> list[dict[str, Any]]:
require_auth(authorization)
return provider.connectors()
@app.post("/connector-sessions")
def start_session(
payload: ConnectorSessionRequest,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
require_auth(authorization)
return provider.start_session(payload.model_dump(by_alias=True))
@app.get("/connector-sessions/{session_id}")
def get_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
require_auth(authorization)
try:
return provider.get_session(session_id)
except KeyError:
raise HTTPException(status_code=404, detail="Connector session not found")
@app.post("/connector-sessions/{session_id}/cancel")
def cancel_session(session_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
require_auth(authorization)
provider.cancel_session(session_id)
return {"ok": True}
@app.post("/connections/{connection_id}/logout")
def logout(connection_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]:
require_auth(authorization)
provider.logout(connection_id)
return {"ok": True}
@app.post("/send", response_model=None)
def send(payload: SendRequest, authorization: str | None = Header(default=None)) -> JSONResponse | dict[str, Any]:
require_auth(authorization)
result = dict(provider.send(payload.model_dump(by_alias=True)))
status_code = int(result.pop("httpStatus", 200))
if status_code != 200:
return JSONResponse(status_code=status_code, content=result)
return result
if hasattr(provider, "handle_event"):
@app.post("/feishu/events", response_model=None)
def feishu_events(payload: dict[str, Any]) -> JSONResponse | dict[str, Any]:
result = dict(provider.handle_event(payload)) # type: ignore[attr-defined]
status_code = int(result.pop("httpStatus", 200))
if status_code != 200:
return JSONResponse(status_code=status_code, content=result)
return result
return app

View File

@ -0,0 +1,62 @@
from __future__ import annotations
import os
from pathlib import Path
import uvicorn
from external_connector.app import create_app
from external_connector.providers.composite import CompositeProvider
from external_connector.providers.fake import FakeProvider
from external_connector.providers.feishu_bot import FeishuBotProvider
from external_connector.providers.vendor_cli import VendorCliProvider
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
from external_connector.state import SidecarStateStore
def build_app():
home = Path(os.getenv("CONNECTOR_HOME", "/var/lib/external-connector"))
store = SidecarStateStore(home / "state.json")
provider_name = os.getenv("CONNECTOR_PROVIDER", "fake")
if provider_name == "official":
provider = CompositeProvider([
_weixin_provider(store),
_feishu_provider(store),
])
elif provider_name == "weixin_ilink":
provider = _weixin_provider(store)
elif provider_name == "feishu_bot":
provider = _feishu_provider(store)
elif provider_name == "vendor_cli":
provider = VendorCliProvider(store=store, env=os.environ)
else:
provider = FakeProvider(store)
return create_app(provider=provider, api_token=os.getenv("CONNECTOR_API_TOKEN", ""))
def _weixin_provider(store: SidecarStateStore) -> WeixinIlinkProvider:
return WeixinIlinkProvider(
store=store,
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
)
def _feishu_provider(store: SidecarStateStore) -> FeishuBotProvider:
return FeishuBotProvider(
store=store,
bridge_base_url=os.getenv("BEAVER_BRIDGE_BASE_URL", ""),
public_base_url=os.getenv("CONNECTOR_PUBLIC_BASE_URL", ""),
bridge_token=os.getenv("BEAVER_BRIDGE_TOKEN", ""),
)
app = build_app()
def main() -> None:
uvicorn.run(app, host="0.0.0.0", port=8787)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ConnectorSessionRequest(BaseModel):
kind: str
connection_id: str = Field(alias="connectionId")
channel_id: str = Field(alias="channelId")
display_name: str = Field(alias="displayName")
callback_base_url: str = Field(alias="callbackBaseUrl")
options: dict[str, Any] = Field(default_factory=dict)
class SendRequest(BaseModel):
request_id: str = Field(alias="requestId")
connection_id: str = Field(alias="connectionId")
channel_id: str = Field(alias="channelId")
kind: str
target: dict[str, Any]
content: str
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@ -0,0 +1,149 @@
const Lark = require("@larksuiteoapi/node-sdk");
const appId = requireEnv("FEISHU_APP_ID");
const appSecret = requireEnv("FEISHU_APP_SECRET");
const connectionId = requireEnv("FEISHU_CONNECTION_ID");
const channelId = requireEnv("FEISHU_CHANNEL_ID");
const accountId = process.env.FEISHU_ACCOUNT_ID || `feishu:${appId}`;
const bridgeBaseUrl = requireEnv("BEAVER_BRIDGE_BASE_URL").replace(/\/+$/, "");
const bridgeToken = requireEnv("BEAVER_BRIDGE_TOKEN");
const domain = (process.env.FEISHU_DOMAIN || "feishu").toLowerCase() === "lark"
? Lark.Domain.Lark
: Lark.Domain.Feishu;
const wsClient = new Lark.WSClient({
appId,
appSecret,
domain,
loggerLevel: Lark.LoggerLevel.info,
onReady: () => log("feishu_ws_ready", {}),
onError: (error) => log("feishu_ws_error", { error: redact(String(error && error.message ? error.message : error)) }),
onReconnecting: () => log("feishu_ws_reconnecting", {}),
onReconnected: () => log("feishu_ws_reconnected", {}),
handshakeTimeoutMs: 15000,
wsConfig: { pingTimeout: 10 },
});
const dispatcher = new Lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
const event = bridgeEventFromFeishu(data);
log("feishu_inbound_message", {
connectionId,
eventId: event.eventId,
messageId: event.messageId,
peerId: event.peerId,
textLength: event.content.length,
});
await postJson(`${bridgeBaseUrl}/api/channel-connector-bridge/events`, event);
},
});
process.on("SIGTERM", () => {
wsClient.close({ force: true });
process.exit(0);
});
process.on("SIGINT", () => {
wsClient.close({ force: true });
process.exit(0);
});
wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
log("feishu_ws_start_failed", { error: redact(String(error && error.message ? error.message : error)) });
process.exit(1);
});
setInterval(() => {
if (typeof wsClient.getConnectionStatus === "function") {
log("feishu_ws_status", wsClient.getConnectionStatus());
}
}, 60000).unref();
function bridgeEventFromFeishu(data) {
const message = objectValue(data.message);
const sender = objectValue(data.sender);
const senderId = objectValue(sender.sender_id);
const peerId = stringValue(senderId.open_id || senderId.user_id || "");
const messageId = stringValue(message.message_id || randomId());
const eventId = stringValue(data.event_id || data.eventId || `${channelId}:${messageId}`);
return {
eventId,
timestamp: new Date().toISOString(),
deliveryAttempt: 1,
connectionId,
channelId,
kind: "feishu",
accountId,
peerId,
peerType: message.chat_type === "group" ? "group" : "dm",
userId: peerId,
threadId: stringValue(message.chat_id || "") || null,
messageId,
messageType: stringValue(message.message_type || "text"),
content: extractText(message),
metadata: {
chatId: message.chat_id || null,
rawMessageType: message.message_type || null,
},
};
}
function extractText(message) {
const content = message.content;
if (typeof content !== "string") {
return "";
}
try {
const parsed = JSON.parse(content);
if (parsed && parsed.text != null) {
return String(parsed.text);
}
} catch (_error) {
return content;
}
return content;
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${bridgeToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`bridge post failed ${response.status}: ${text.slice(0, 300)}`);
}
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function objectValue(value) {
return value && typeof value === "object" ? value : {};
}
function stringValue(value) {
return value == null ? "" : String(value);
}
function randomId() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function log(event, fields) {
console.log(JSON.stringify({ event, ...fields }));
}
function redact(text) {
return text
.replace(appSecret, "***")
.replace(bridgeToken, "***");
}

View File

@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any, Protocol
class ConnectorProvider(Protocol):
provider_id: str
def connectors(self) -> list[dict[str, Any]]:
...
def health(self) -> dict[str, Any]:
...
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
...
def get_session(self, session_id: str) -> dict[str, Any]:
...
def cancel_session(self, session_id: str) -> None:
...
def logout(self, connection_id: str) -> None:
...
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
...

View File

@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
from external_connector.providers.base import ConnectorProvider
class CompositeProvider:
provider_id = "composite"
def __init__(self, providers: list[ConnectorProvider]) -> None:
self.providers = providers
self._by_kind: dict[str, ConnectorProvider] = {}
for provider in providers:
for connector in provider.connectors():
self._by_kind[str(connector["kind"])] = provider
def connectors(self) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for provider in self.providers:
items.extend(provider.connectors())
return items
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id, "providers": [provider.health() for provider in self.providers]}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
return self._provider(str(payload["kind"])).start_session(payload)
def get_session(self, session_id: str) -> dict[str, Any]:
for provider in self.providers:
try:
return provider.get_session(session_id)
except KeyError:
continue
raise KeyError(session_id)
def cancel_session(self, session_id: str) -> None:
for provider in self.providers:
try:
provider.cancel_session(session_id)
return None
except KeyError:
continue
raise KeyError(session_id)
def logout(self, connection_id: str) -> None:
for provider in self.providers:
provider.logout(connection_id)
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
return self._provider(str(payload["kind"])).send(payload)
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
for provider in self.providers:
handler = getattr(provider, "handle_event", None)
if handler is None:
continue
try:
return dict(handler(payload))
except KeyError:
continue
raise KeyError("No event provider matched")
def _provider(self, kind: str) -> ConnectorProvider:
provider = self._by_kind.get(kind)
if provider is None:
raise KeyError(f"Unsupported connector kind: {kind}")
return provider

View File

@ -0,0 +1,119 @@
from __future__ import annotations
import base64
from typing import Any
from uuid import uuid4
from external_connector.state import ConnectorSessionState, SidecarStateStore
def _session_view(session: ConnectorSessionState) -> dict[str, Any]:
return {
"sessionId": session.session_id,
"kind": session.kind,
"status": session.status,
"qrCode": session.qr_code,
"qrImage": session.qr_image,
"instructions": list(session.instructions),
"accountId": session.account_id,
"displayName": session.display_name if session.account_id else None,
"error": session.error,
"metadata": dict(session.metadata),
}
def _fake_qr_image() -> str:
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">'
'<rect width="256" height="256" fill="#fff"/>'
'<rect x="16" y="16" width="58" height="58" fill="#111"/>'
'<rect x="28" y="28" width="34" height="34" fill="#fff"/>'
'<rect x="184" y="16" width="58" height="58" fill="#111"/>'
'<rect x="196" y="28" width="34" height="34" fill="#fff"/>'
'<rect x="16" y="184" width="58" height="58" fill="#111"/>'
'<rect x="28" y="196" width="34" height="34" fill="#fff"/>'
'<rect x="96" y="96" width="18" height="18" fill="#111"/>'
'<rect x="132" y="96" width="18" height="18" fill="#111"/>'
'<rect x="168" y="96" width="18" height="18" fill="#111"/>'
'<rect x="96" y="132" width="18" height="18" fill="#111"/>'
'<rect x="150" y="132" width="18" height="18" fill="#111"/>'
'<rect x="204" y="132" width="18" height="18" fill="#111"/>'
'<rect x="114" y="168" width="18" height="18" fill="#111"/>'
'<rect x="168" y="168" width="18" height="18" fill="#111"/>'
'<rect x="204" y="168" width="18" height="18" fill="#111"/>'
'<rect x="96" y="204" width="18" height="18" fill="#111"/>'
'<rect x="132" y="204" width="18" height="18" fill="#111"/>'
'<rect x="186" y="204" width="18" height="18" fill="#111"/>'
'<text x="128" y="248" text-anchor="middle" font-family="monospace" font-size="14" fill="#444">FAKE QR</text>'
"</svg>"
)
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
class FakeProvider:
provider_id = "fake"
def __init__(self, store: SidecarStateStore) -> None:
self.store = store
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "weixin",
"displayName": "Weixin",
"authType": "qr",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"],
},
{
"kind": "feishu",
"displayName": "Feishu/Lark",
"authType": "plugin_install",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "groups"],
},
]
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
session = self.store.create_session(
kind=str(payload["kind"]),
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=dict(payload.get("options") or {}),
)
session = self.store.update_session(
session.session_id,
status="qr_ready" if session.kind == "weixin" else "waiting_for_user",
qr_image=_fake_qr_image() if session.kind == "weixin" else None,
instructions=["Run the provider install flow and finish verification"] if session.kind == "feishu" else [],
)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
return _session_view(self.store.get_session(session_id))
def cancel_session(self, session_id: str) -> None:
self.store.update_session(session_id, status="cancelled")
def logout(self, connection_id: str) -> None:
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
if not begin.should_send:
if begin.http_status == 409:
return {
"ok": False,
"status": begin.status,
"retryAfterSeconds": begin.retry_after_seconds,
"httpStatus": 409,
}
return {"ok": True, "providerMessageId": begin.provider_message_id}
provider_message_id = f"fake_{uuid4().hex}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}

View File

@ -0,0 +1,542 @@
from __future__ import annotations
import base64
import json
import os
import subprocess
import threading
import time
from collections.abc import Callable
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from uuid import uuid4
import httpx
import qrcode
import qrcode.image.svg
from external_connector.providers.fake import _session_view
from external_connector.state import ConnectorSessionState, SidecarStateStore
BridgePoster = Callable[[str, dict[str, Any], dict[str, str]], None]
ReceiverStart = Callable[[ConnectorSessionState], object]
class FeishuBotProvider:
provider_id = "feishu_bot"
def __init__(
self,
*,
store: SidecarStateStore,
http_client: Any | None = None,
bridge_base_url: str,
public_base_url: str | None = None,
bridge_token: str,
bridge_post: BridgePoster | None = None,
api_base_url: str = "",
start_receivers: bool = True,
receiver_start: ReceiverStart | None = None,
) -> None:
self.store = store
self.http = http_client or httpx.Client(timeout=30)
self.bridge_base_url = bridge_base_url.rstrip("/")
self.public_base_url = (public_base_url or bridge_base_url).rstrip("/")
self.bridge_token = bridge_token
self.bridge_post = bridge_post or self._default_bridge_post
self.api_base_url = api_base_url.rstrip("/")
self.start_receivers = start_receivers
self.receiver_start = receiver_start or self._start_receiver_process
self._receiver_processes: dict[str, object] = {}
self._receiver_lock = threading.Lock()
self._start_existing_connected_receivers()
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "feishu",
"displayName": "Feishu/Lark",
"authType": "qr_or_bot_app",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "groups"],
}
]
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
kind = str(payload["kind"])
if kind != "feishu":
raise KeyError(f"Unsupported connector kind: {kind}")
options = dict(payload.get("options") or {})
session = self.store.create_session(
kind=kind,
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=options,
)
metadata = {
"eventCallbackPath": "/feishu/events",
"eventCallbackUrl": f"{self.public_base_url}/feishu/events",
}
app_id = str(options.get("appId") or options.get("app_id") or "").strip()
app_secret = str(options.get("appSecret") or options.get("app_secret") or "").strip()
verification_token = str(options.get("verificationToken") or options.get("verification_token") or "").strip()
mode = str(options.get("mode") or "create").strip().lower()
domain = _domain(options)
if not app_id or not app_secret:
if mode != "link":
return self._start_registration_session(session, metadata=metadata, domain=domain)
session = self.store.update_session(
session.session_id,
status="waiting_for_user",
instructions=_link_instructions(metadata["eventCallbackUrl"]),
metadata=metadata,
)
return _session_view(session)
token_data = self._tenant_token(app_id, app_secret, domain=domain)
metadata.update(
{
"appId": app_id,
"appSecret": app_secret,
"verificationToken": verification_token,
"tenantAccessToken": token_data["token"],
"tenantTokenExpiresAt": token_data["expires_at"],
"domain": domain,
}
)
session = self.store.update_session(
session.session_id,
status="connected",
account_id=f"feishu:{app_id}",
metadata=metadata,
instructions=_connected_instructions(),
)
self._ensure_receiver(session)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
session = self.store.get_session(session_id)
if session.kind != "feishu":
raise KeyError(session_id)
if session.kind == "feishu" and session.status in {"qr_ready", "scanned", "confirmed", "waiting_for_user"} and session.metadata.get("deviceCode"):
session = self._poll_registration_session(session)
if session.status == "connected":
self._ensure_receiver(session)
return _session_view(session)
def cancel_session(self, session_id: str) -> None:
session = self.store.get_session(session_id)
if session.kind != "feishu":
raise KeyError(session_id)
self.store.update_session(session_id, status="cancelled")
self._stop_receiver(session.connection_id)
def logout(self, connection_id: str) -> None:
try:
session = self.store.find_session_by_connection_id(connection_id)
except KeyError:
return None
self._stop_receiver(connection_id)
self.store.update_session(session.session_id, status="cancelled")
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
if not begin.should_send:
if begin.http_status == 409:
return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409}
return {"ok": True, "providerMessageId": begin.provider_message_id}
session = self.store.find_session_by_connection_id(str(payload["connectionId"]))
token = self._tenant_token_for_session(session)
target = dict(payload.get("target") or {})
metadata = dict(session.metadata)
api_base = _open_api_base_url(str(metadata.get("domain") or "feishu"), self.api_base_url)
try:
response = self.http.post(
f"{api_base}/open-apis/im/v1/messages?receive_id_type=open_id",
json={
"receive_id": str(target.get("peerId") or ""),
"msg_type": "text",
"content": json.dumps({"text": str(payload.get("content") or "")}, ensure_ascii=False),
},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=20,
)
response.raise_for_status()
except Exception as exc:
error = str(exc)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
data = dict(response.json())
if int(data.get("code") or 0) != 0:
error = str(data.get("msg") or data)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
provider_message_id = str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}")
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
challenge = payload.get("challenge")
if challenge:
return {"challenge": challenge}
header = dict(payload.get("header") or {})
event = dict(payload.get("event") or {})
app_id = str(header.get("app_id") or "")
session = self._session_for_app_id(app_id)
expected_token = str(session.metadata.get("verificationToken") or "")
received_token = str(header.get("token") or payload.get("token") or "")
if expected_token and received_token != expected_token:
return {"ok": False, "error": "invalid verification token", "httpStatus": 401}
bridge_event = _bridge_event_from_feishu(session, header, event)
self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
bridge_event,
{"Authorization": f"Bearer {self.bridge_token}"},
)
return {"ok": True}
def _tenant_token_for_session(self, session: ConnectorSessionState) -> str:
metadata = dict(session.metadata)
expires_at = float(metadata.get("tenantTokenExpiresAt") or 0)
token = str(metadata.get("tenantAccessToken") or "")
if token and expires_at - time.time() > 60:
return token
app_id = str(metadata.get("appId") or "")
app_secret = str(metadata.get("appSecret") or "")
token_data = self._tenant_token(app_id, app_secret, domain=str(metadata.get("domain") or "feishu"))
metadata.update({"tenantAccessToken": token_data["token"], "tenantTokenExpiresAt": token_data["expires_at"]})
self.store.update_session(session.session_id, metadata=metadata)
return str(token_data["token"])
def _tenant_token(self, app_id: str, app_secret: str, *, domain: str = "feishu") -> dict[str, Any]:
response = self.http.post(
f"{_open_api_base_url(domain, self.api_base_url)}/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
timeout=20,
)
response.raise_for_status()
data = dict(response.json())
if int(data.get("code") or 0) != 0:
raise RuntimeError(str(data.get("msg") or data))
return {"token": str(data["tenant_access_token"]), "expires_at": time.time() + int(data.get("expire") or 7200)}
def _session_for_app_id(self, app_id: str) -> ConnectorSessionState:
sessions = self.store.list_sessions()
for session in sorted(sessions, key=lambda item: item.updated_at, reverse=True):
if session.kind == "feishu" and session.status == "connected" and session.metadata.get("appId") == app_id:
return session
raise KeyError(app_id)
def _default_bridge_post(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> None:
response = self.http.post(url, json=payload, headers=headers, timeout=20)
response.raise_for_status()
def _start_registration_session(
self,
session: ConnectorSessionState,
*,
metadata: dict[str, Any],
domain: str,
) -> dict[str, Any]:
try:
init_data = self._registration_post(domain, {"action": "init"})
supported = init_data.get("supported_auth_methods") or []
if "client_secret" not in supported:
session = self.store.update_session(
session.session_id,
status="error",
error="Current Feishu/Lark environment does not support client_secret bot registration",
metadata=metadata,
)
return _session_view(session)
begin_data = self._registration_post(
domain,
{
"action": "begin",
"archetype": "PersonalAgent",
"auth_method": "client_secret",
"request_user_info": "open_id",
},
)
except Exception as exc:
session = self.store.update_session(
session.session_id,
status="error",
error=str(exc),
metadata=metadata,
)
return _session_view(session)
qr_code = _with_onboard_from(str(begin_data.get("verification_uri_complete") or ""))
if not qr_code:
session = self.store.update_session(
session.session_id,
status="error",
error="Feishu/Lark registration did not return a QR URL",
metadata=metadata,
)
return _session_view(session)
metadata.update(
{
"domain": domain,
"registrationBaseUrl": _registration_base_url(domain),
"deviceCode": str(begin_data.get("device_code") or ""),
"pollIntervalSeconds": int(begin_data.get("interval") or 5),
"expiresAt": time.time() + int(begin_data.get("expire_in") or 600),
}
)
session = self.store.update_session(
session.session_id,
status="qr_ready",
qr_code=qr_code,
qr_image=_qr_svg_data_uri(qr_code),
instructions=_create_instructions(metadata["eventCallbackUrl"]),
metadata=metadata,
)
return _session_view(session)
def _poll_registration_session(self, session: ConnectorSessionState) -> ConnectorSessionState:
metadata = dict(session.metadata)
expires_at = float(metadata.get("expiresAt") or 0)
if expires_at and time.time() > expires_at:
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
domain = str(metadata.get("domain") or "feishu")
device_code = str(metadata.get("deviceCode") or "")
if not device_code:
return session
try:
poll_data = self._registration_post(domain, {"action": "poll", "device_code": device_code})
except Exception as exc:
return self.store.update_session(session.session_id, status="error", error=str(exc), metadata=metadata)
user_info = dict(poll_data.get("user_info") or {})
if user_info.get("tenant_brand") == "lark":
domain = "lark"
metadata["domain"] = domain
app_id = str(poll_data.get("client_id") or "").strip()
app_secret = str(poll_data.get("client_secret") or "").strip()
if app_id and app_secret:
token_data = self._tenant_token(app_id, app_secret, domain=domain)
metadata.update(
{
"appId": app_id,
"appSecret": app_secret,
"tenantAccessToken": token_data["token"],
"tenantTokenExpiresAt": token_data["expires_at"],
"registrationOpenId": str(user_info.get("open_id") or ""),
}
)
return self.store.update_session(
session.session_id,
status="connected",
account_id=f"feishu:{app_id}",
metadata=metadata,
instructions=_connected_instructions(),
)
error = str(poll_data.get("error") or "")
if error == "authorization_pending":
return session
if error == "slow_down":
metadata["pollIntervalSeconds"] = int(metadata.get("pollIntervalSeconds") or 5) + 5
return self.store.update_session(session.session_id, metadata=metadata)
if error == "expired_token":
return self.store.update_session(session.session_id, status="expired", metadata=metadata)
if error == "access_denied":
return self.store.update_session(session.session_id, status="error", error="Feishu/Lark authorization was denied", metadata=metadata)
if error:
description = str(poll_data.get("error_description") or error)
return self.store.update_session(session.session_id, status="error", error=description, metadata=metadata)
return session
def _registration_post(self, domain: str, values: dict[str, str]) -> dict[str, Any]:
response = self.http.post(
f"{_registration_base_url(domain)}/oauth/v1/app/registration",
data=urlencode(values),
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=20,
)
response.raise_for_status()
return dict(response.json())
def _start_existing_connected_receivers(self) -> None:
if not self.start_receivers:
return None
for session in self.store.list_sessions():
if session.kind == "feishu" and session.status == "connected":
self._ensure_receiver(session)
return None
def _ensure_receiver(self, session: ConnectorSessionState) -> None:
if not self.start_receivers or not _has_receiver_material(session):
return None
with self._receiver_lock:
existing = self._receiver_processes.get(session.connection_id)
if existing is not None and _receiver_is_alive(existing):
return None
receiver = self.receiver_start(session)
self._receiver_processes[session.connection_id] = receiver
return None
def _stop_receiver(self, connection_id: str) -> None:
with self._receiver_lock:
receiver = self._receiver_processes.pop(connection_id, None)
if receiver is None:
return None
terminate = getattr(receiver, "terminate", None)
if callable(terminate):
terminate()
wait = getattr(receiver, "wait", None)
if callable(wait):
try:
wait(timeout=5)
except subprocess.TimeoutExpired:
kill = getattr(receiver, "kill", None)
if callable(kill):
kill()
return None
def _start_receiver_process(self, session: ConnectorSessionState) -> subprocess.Popen[bytes]:
metadata = dict(session.metadata)
script = Path(__file__).resolve().parents[1] / "node" / "feishu_ws_receiver.js"
env = os.environ.copy()
env.update(
{
"FEISHU_APP_ID": str(metadata.get("appId") or ""),
"FEISHU_APP_SECRET": str(metadata.get("appSecret") or ""),
"FEISHU_DOMAIN": str(metadata.get("domain") or "feishu"),
"FEISHU_CONNECTION_ID": session.connection_id,
"FEISHU_CHANNEL_ID": session.channel_id,
"FEISHU_ACCOUNT_ID": str(session.account_id or ""),
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url,
"BEAVER_BRIDGE_TOKEN": self.bridge_token,
}
)
return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent))
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
message = dict(event.get("message") or {})
sender = dict(event.get("sender") or {})
sender_id = dict(sender.get("sender_id") or {})
peer_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
message_id = str(message.get("message_id") or uuid4().hex)
event_id = str(header.get("event_id") or f"{session.channel_id}:{message_id}")
return {
"eventId": event_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"deliveryAttempt": 1,
"connectionId": session.connection_id,
"channelId": session.channel_id,
"kind": "feishu",
"accountId": session.account_id,
"peerId": peer_id,
"peerType": "group" if message.get("chat_type") == "group" else "dm",
"userId": peer_id,
"threadId": str(message.get("chat_id") or "") or None,
"messageId": message_id,
"messageType": str(message.get("message_type") or "text"),
"content": _extract_text(message),
"metadata": {"chatId": message.get("chat_id"), "rawMessageType": message.get("message_type")},
}
def _has_receiver_material(session: ConnectorSessionState) -> bool:
metadata = dict(session.metadata)
return bool(
session.status == "connected"
and str(metadata.get("appId") or "").strip()
and str(metadata.get("appSecret") or "").strip()
and session.connection_id
and session.channel_id
)
def _receiver_is_alive(receiver: object) -> bool:
poll = getattr(receiver, "poll", None)
if callable(poll):
return poll() is None
return True
def _extract_text(message: dict[str, Any]) -> str:
content = message.get("content")
if isinstance(content, str):
try:
parsed = json.loads(content)
except json.JSONDecodeError:
return content
text = parsed.get("text")
if text is not None:
return str(text)
return content
return ""
def _domain(options: dict[str, Any]) -> str:
domain = str(options.get("domain") or "feishu").strip().lower()
return "lark" if domain == "lark" else "feishu"
def _registration_base_url(domain: str) -> str:
return "https://accounts.larksuite.com" if domain == "lark" else "https://accounts.feishu.cn"
def _open_api_base_url(domain: str, configured_base_url: str) -> str:
if configured_base_url:
return configured_base_url.rstrip("/")
return "https://open.larksuite.com" if domain == "lark" else "https://open.feishu.cn"
def _with_onboard_from(value: str) -> str:
if not value:
return ""
parts = urlsplit(value)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.setdefault("from", "onboard")
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _qr_svg_data_uri(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
buffer = BytesIO()
image.save(buffer)
return "data:image/svg+xml;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
def _create_instructions(event_callback_url: str) -> list[str]:
return [
"使用飞书客户端扫描二维码,选择一键创建飞书机器人。",
"创建完成后,点击打开机器人,在飞书中向机器人发送任意消息即可开始对话。",
"在飞书开放平台启用接收消息事件 im.message.receive_v1并选择长连接事件通道。",
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}",
"如需用户身份授权,在飞书对话中发送 /feishu auth。",
"建议发送:学习一下我安装的新飞书插件,列出有哪些能力。",
"验证安装:在飞书对话中发送 /feishu start返回版本号代表安装成功。",
"如果 Windows 设备扫码异常,通常是终端二维码分辨率问题,可换用 Cmder 或使用本窗口二维码。",
]
def _link_instructions(event_callback_url: str) -> list[str]:
return [
"选择关联已有机器人时,请输入正确的 App ID 和 App Secret。",
"如果提示无效的 App ID 或 App Secret请回到飞书开放平台复制最新应用凭证。",
"在飞书开放平台启用接收消息事件 im.message.receive_v1并选择长连接事件通道。",
f"如果使用 HTTP 回调模式,事件请求 URL 可设为 {event_callback_url}",
]
def _connected_instructions() -> list[str]:
return [
"飞书机器人凭据已连接。",
"sidecar 会通过飞书长连接保持应用在线。",
"请确认飞书开放平台已启用接收消息事件 im.message.receive_v1。",
"在飞书对话中发送 /feishu start 验证插件返回版本号;发送任意消息验证 Beaver 是否收到。",
]

View File

@ -0,0 +1,281 @@
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
from external_connector.providers.fake import _session_view
from external_connector.state import SidecarStateStore
Runner = Callable[[list[str], str, float], tuple[int, str, str]]
def default_runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False, timeout=timeout)
return completed.returncode, completed.stdout, completed.stderr
class VendorCliProvider:
provider_id = "vendor_cli"
def __init__(
self,
*,
store: SidecarStateStore,
env: Mapping[str, str] | None = None,
runner: Runner = default_runner,
) -> None:
self.store = store
self.env = env or os.environ
self.runner = runner
self.command_timeout_seconds = float(self.env.get("CONNECTOR_COMMAND_TIMEOUT_SECONDS") or 120)
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "weixin",
"displayName": "Weixin",
"authType": "qr",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "direct_messages"],
},
{
"kind": "feishu",
"displayName": "Feishu/Lark",
"authType": "plugin_install",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "receive_media", "groups"],
},
]
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
kind = str(payload["kind"])
session = self.store.create_session(
kind=kind,
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=dict(payload.get("options") or {}),
)
try:
command_template = self._command_template(kind)
except RuntimeError as exc:
session = self.store.update_session(session.session_id, status="error", error=str(exc))
return _session_view(session)
connector_home = Path(self.store.path).parent
state_dir = connector_home / kind / session.connection_id
state_dir.mkdir(parents=True, exist_ok=True)
command = shlex.split(command_template.format(state_dir=str(state_dir), connection_id=session.connection_id))
try:
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
except subprocess.TimeoutExpired:
session = self.store.update_session(session.session_id, status="error", error="Provider command timed out")
return _session_view(session)
except Exception as exc:
session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc)))
return _session_view(session)
if code != 0:
session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout))
return _session_view(session)
status = "connected" if "connected" in stdout.lower() else "waiting_for_user"
account_id = _extract_account_id(stdout)
session = self.store.update_session(
session.session_id,
status=status,
account_id=account_id,
metadata={"stateRef": str(state_dir)},
instructions=["Complete the vendor install or verification flow"] if status != "connected" else [],
)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
session = self.store.get_session(session_id)
if session.status in {"cancelled", "error"}:
return _session_view(session)
command_template = self._optional_command_template(session.kind, "STATUS")
if not command_template:
return _session_view(session)
connector_home = Path(self.store.path).parent
state_dir = str(session.metadata.get("stateRef") or connector_home / session.kind / session.connection_id)
command = shlex.split(
command_template.format(
state_dir=state_dir,
connection_id=session.connection_id,
session_id=session.session_id,
)
)
try:
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
except subprocess.TimeoutExpired:
session = self.store.update_session(session.session_id, status="error", error="Provider status command timed out")
return _session_view(session)
except Exception as exc:
session = self.store.update_session(session.session_id, status="error", error=_redact(str(exc)))
return _session_view(session)
if code != 0:
session = self.store.update_session(session.session_id, status="error", error=_redact(stderr or stdout))
return _session_view(session)
updates = _provider_output_updates(stdout)
if updates:
metadata = dict(session.metadata)
metadata.update(dict(updates.pop("metadata", {}) or {}))
updates["metadata"] = metadata
session = self.store.update_session(session.session_id, **updates)
return _session_view(session)
def cancel_session(self, session_id: str) -> None:
self.store.update_session(session_id, status="cancelled")
def logout(self, connection_id: str) -> None:
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
if not begin.should_send:
if begin.http_status == 409:
return {
"ok": False,
"status": begin.status,
"retryAfterSeconds": begin.retry_after_seconds,
"httpStatus": 409,
}
return {"ok": True, "providerMessageId": begin.provider_message_id}
command_template = self._optional_command_template(str(payload["kind"]), "SEND")
if not command_template:
provider_message_id = f"vendor_{payload['requestId']}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
connector_home = Path(self.store.path).parent
state_dir = connector_home / str(payload["kind"]) / str(payload["connectionId"])
payload_path = _write_send_payload(state_dir, str(payload["requestId"]), payload)
command = shlex.split(
command_template.format(
state_dir=str(state_dir),
payload_path=str(payload_path),
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
request_id=str(payload["requestId"]),
kind=str(payload["kind"]),
)
)
try:
code, stdout, stderr = self.runner(command, str(connector_home), self.command_timeout_seconds)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "Provider send command timed out"}
except Exception as exc:
return {"ok": False, "error": _redact(str(exc))}
if code != 0:
return {"ok": False, "error": _redact(stderr or stdout)}
provider_message_id = _extract_provider_message_id(stdout) or f"vendor_{payload['requestId']}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
def _command_template(self, kind: str) -> str:
key = "WEIXIN_CONNECT_COMMAND" if kind == "weixin" else "FEISHU_CONNECT_COMMAND"
command = str(self.env.get(key) or "").strip()
if not command:
raise RuntimeError(f"{key} is required")
return command
def _optional_command_template(self, kind: str, action: str) -> str | None:
prefix = "WEIXIN" if kind == "weixin" else "FEISHU"
command = str(self.env.get(f"{prefix}_{action}_COMMAND") or "").strip()
return command or None
def _extract_account_id(output: str) -> str | None:
for part in output.split():
if part.startswith("account="):
return part.split("=", 1)[1]
return None
def _provider_output_updates(output: str) -> dict[str, Any]:
text = output.strip()
if not text:
return {}
parsed: dict[str, Any] = {}
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = _parse_key_value_output(text)
if isinstance(raw, dict):
parsed = raw
updates: dict[str, Any] = {}
key_map = {
"status": "status",
"qrImage": "qr_image",
"qr_image": "qr_image",
"qrCode": "qr_code",
"qr_code": "qr_code",
"accountId": "account_id",
"account_id": "account_id",
"account": "account_id",
"displayName": "display_name",
"display_name": "display_name",
"error": "error",
}
for source_key, target_key in key_map.items():
value = parsed.get(source_key)
if value is not None:
updates[target_key] = str(value)
instructions = parsed.get("instructions")
if isinstance(instructions, list):
updates["instructions"] = [str(item) for item in instructions]
elif isinstance(instructions, str) and instructions.strip():
updates["instructions"] = [instructions.strip()]
metadata = parsed.get("metadata")
if isinstance(metadata, dict):
updates["metadata"] = metadata
return updates
def _parse_key_value_output(text: str) -> dict[str, str]:
values: dict[str, str] = {}
for part in text.split():
if "=" not in part:
continue
key, value = part.split("=", 1)
if key:
values[key] = value
return values
def _write_send_payload(state_dir: Path, request_id: str, payload: dict[str, Any]) -> Path:
sends_dir = state_dir / "sends"
sends_dir.mkdir(parents=True, exist_ok=True)
safe_request_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", request_id) or "request"
payload_path = sends_dir / f"{safe_request_id}.json"
payload_path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
return payload_path
def _extract_provider_message_id(output: str) -> str | None:
text = output.strip()
if not text:
return None
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = _parse_key_value_output(text)
if not isinstance(raw, dict):
return None
value = raw.get("providerMessageId") or raw.get("provider_message_id") or raw.get("messageId") or raw.get("message_id")
return str(value) if value is not None else None
def _redact(text: str) -> str:
redacted = re.sub(r"\bsecret-[A-Za-z0-9._:-]+\b", "***", text)
return re.sub(r"\b(appSecret|token|secret)=\S+", r"\1=***", redacted)

View File

@ -0,0 +1,463 @@
from __future__ import annotations
import base64
import hashlib
import json
import random
import threading
import time
from collections.abc import Callable
from typing import Any
from urllib.parse import quote
from uuid import uuid4
import httpx
import qrcode
import qrcode.image.svg
from external_connector.providers.fake import _session_view
from external_connector.state import ConnectorSessionState, SidecarStateStore
BridgePoster = Callable[[str, dict[str, Any], dict[str, str]], None]
class WeixinIlinkProvider:
provider_id = "weixin_ilink"
fixed_base_url = "https://ilinkai.weixin.qq.com"
app_client_version = str((2 << 16) | (4 << 8) | 3)
def __init__(
self,
*,
store: SidecarStateStore,
http_client: Any | None = None,
bridge_base_url: str,
bridge_token: str,
bridge_post: BridgePoster | None = None,
start_receivers: bool = True,
) -> None:
self.store = store
self.http = http_client or httpx.Client(timeout=40)
self.bridge_base_url = bridge_base_url.rstrip("/")
self.bridge_token = bridge_token
self.bridge_post = bridge_post or self._default_bridge_post
self.start_receivers = start_receivers
self._receiver_stops: dict[str, threading.Event] = {}
self._receiver_lock = threading.Lock()
def connectors(self) -> list[dict[str, Any]]:
return [
{
"kind": "weixin",
"displayName": "Weixin",
"authType": "qr",
"providerId": self.provider_id,
"capabilities": ["receive_text", "send_text", "direct_messages"],
}
]
def health(self) -> dict[str, Any]:
return {"ok": True, "providerId": self.provider_id}
def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
kind = str(payload["kind"])
if kind != "weixin":
raise KeyError(f"Unsupported connector kind: {kind}")
session = self.store.create_session(
kind=kind,
connection_id=str(payload["connectionId"]),
channel_id=str(payload["channelId"]),
display_name=str(payload["displayName"]),
options=dict(payload.get("options") or {}),
)
response = self._post_json(
self.fixed_base_url,
"ilink/bot/get_bot_qrcode?bot_type=3",
{"local_token_list": []},
token=None,
timeout=20,
)
qr_code = str(response.get("qrcode") or "")
qr_url = str(response.get("qrcode_img_content") or "")
session = self.store.update_session(
session.session_id,
status="qr_ready",
qr_code=qr_url,
qr_image=_qr_svg_data_uri(qr_url),
metadata={
"qrcode": qr_code,
"qrBaseUrl": self.fixed_base_url,
"bridgeBaseUrl": str(payload.get("callbackBaseUrl") or self.bridge_base_url),
"getUpdatesBuf": "",
},
)
return _session_view(session)
def get_session(self, session_id: str) -> dict[str, Any]:
session = self.store.get_session(session_id)
if session.kind != "weixin":
raise KeyError(session_id)
if session.kind == "weixin" and session.status not in {"expired", "error", "cancelled"} and _has_connection_material(session):
if session.status != "connected":
session = self.store.update_session(session.session_id, status="connected")
self._ensure_receiver(session)
return _session_view(session)
if session.status in {"connected", "expired", "error", "cancelled"}:
if session.status == "connected":
self._ensure_receiver(session)
return _session_view(session)
qrcode = str(session.metadata.get("qrcode") or "")
if not qrcode:
return _session_view(session)
endpoint = f"ilink/bot/get_qrcode_status?qrcode={quote(qrcode)}"
response = self._get_json(self.fixed_base_url, endpoint, timeout=40)
status = str(response.get("status") or "wait")
session = self._apply_login_status(session, response, status)
if session.status == "connected":
self._ensure_receiver(session)
return _session_view(session)
def cancel_session(self, session_id: str) -> None:
session = self.store.get_session(session_id)
if session.kind != "weixin":
raise KeyError(session_id)
session = self.store.update_session(session_id, status="cancelled")
self._stop_receiver(session.connection_id)
def logout(self, connection_id: str) -> None:
self._stop_receiver(connection_id)
try:
session = self.store.find_session_by_connection_id(connection_id)
except KeyError:
return None
self.store.update_session(session.session_id, status="cancelled")
return None
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
begin = self.store.begin_send(connection_id=str(payload["connectionId"]), request_id=str(payload["requestId"]))
if not begin.should_send:
if begin.http_status == 409:
return {"ok": False, "status": begin.status, "retryAfterSeconds": begin.retry_after_seconds, "httpStatus": 409}
return {"ok": True, "providerMessageId": begin.provider_message_id}
session = self.store.find_session_by_connection_id(str(payload["connectionId"]))
token = str(session.metadata.get("token") or "")
base_url = str(session.metadata.get("baseUrl") or "")
if not token or not base_url:
return {"ok": False, "error": "Weixin connection is not connected"}
target = dict(payload.get("target") or {})
metadata = dict(payload.get("metadata") or {})
peer_id = str(target.get("peerId") or "")
request_id = str(payload["requestId"])
client_id = _client_id(request_id)
message_body = {
"from_user_id": "",
"to_user_id": peer_id,
"client_id": client_id,
"message_type": 2,
"message_state": 2,
"item_list": [{"type": 1, "text_item": {"text": str(payload.get("content") or "")}}],
}
context_token = _optional_text(metadata.get("contextToken") or metadata.get("context_token"))
if not context_token:
context_token = _cached_context_token(session, peer_id)
if context_token:
message_body["context_token"] = context_token
print(
json.dumps(
{
"event": "weixin_send_attempt",
"connectionId": payload["connectionId"],
"peerId": peer_id,
"requestId": request_id,
"clientId": client_id,
"hasContextToken": bool(context_token),
"textLength": len(str(payload.get("content") or "")),
},
ensure_ascii=False,
),
flush=True,
)
try:
response = self._post_json(
base_url,
"ilink/bot/sendmessage",
{"msg": message_body},
token=token,
timeout=20,
)
except Exception as exc:
error = str(exc)
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
error = _business_error(response)
if error:
self.store.fail_send(begin.dedupe_key, error=error)
return {"ok": False, "error": error, "httpStatus": 502}
print(
json.dumps(
{
"event": "weixin_send_result",
"connectionId": payload["connectionId"],
"requestId": request_id,
"responseKeys": sorted(str(key) for key in response.keys()),
"businessError": None,
},
ensure_ascii=False,
),
flush=True,
)
provider_message_id = f"weixin_{payload['requestId']}"
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
return {"ok": True, "providerMessageId": provider_message_id}
def poll_once(self, connection_id: str) -> None:
session = self.store.find_session_by_connection_id(connection_id)
token = str(session.metadata.get("token") or "")
base_url = str(session.metadata.get("baseUrl") or "")
if not token or not base_url:
return None
metadata = dict(session.metadata)
response = self._post_json(
base_url,
"ilink/bot/getupdates",
{"get_updates_buf": str(metadata.get("getUpdatesBuf") or "")},
token=token,
timeout=40,
)
metadata["getUpdatesBuf"] = str(response.get("get_updates_buf") or metadata.get("getUpdatesBuf") or "")
for message in response.get("msgs") or []:
if isinstance(message, dict):
_remember_context_token(metadata, message)
event = _bridge_event_from_weixin_message(session, message)
print(
json.dumps(
{
"event": "weixin_inbound_message",
"connectionId": session.connection_id,
"messageId": event["messageId"],
"peerId": event["peerId"],
"hasContextToken": bool(event["metadata"].get("contextToken")),
"textLength": len(event["content"]),
},
ensure_ascii=False,
),
flush=True,
)
self.bridge_post(
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
event,
{"Authorization": f"Bearer {self.bridge_token}"},
)
self.store.update_session(session.session_id, metadata=metadata)
return None
def _apply_login_status(
self,
session: ConnectorSessionState,
response: dict[str, Any],
status: str,
) -> ConnectorSessionState:
if status == "wait":
return self.store.update_session(session.session_id, status="qr_ready")
if status == "scaned":
return self.store.update_session(session.session_id, status="scanned")
if status == "expired":
return self.store.update_session(session.session_id, status="expired", error="QR code expired")
if status == "confirmed":
token = str(response.get("bot_token") or "")
account_id = str(response.get("ilink_bot_id") or "")
base_url = str(response.get("baseurl") or self.fixed_base_url)
if not token or not account_id:
return self.store.update_session(session.session_id, status="confirmed")
metadata = dict(session.metadata)
metadata.update(
{
"token": token,
"baseUrl": base_url,
"userId": str(response.get("ilink_user_id") or ""),
"getUpdatesBuf": str(metadata.get("getUpdatesBuf") or ""),
}
)
return self.store.update_session(
session.session_id,
status="connected",
account_id=f"weixin:{account_id}",
metadata=metadata,
)
if status == "need_verifycode":
return self.store.update_session(
session.session_id,
status="waiting_for_user",
instructions=["Enter the verification code shown in Weixin, then refresh status."],
)
return self.store.update_session(session.session_id, status="waiting_for_user")
def _ensure_receiver(self, session: ConnectorSessionState) -> None:
if not self.start_receivers:
return None
with self._receiver_lock:
if session.connection_id in self._receiver_stops:
return None
stop = threading.Event()
self._receiver_stops[session.connection_id] = stop
thread = threading.Thread(target=self._receiver_loop, args=(session.connection_id, stop), daemon=True)
thread.start()
return None
def _receiver_loop(self, connection_id: str, stop: threading.Event) -> None:
while not stop.is_set():
try:
self.poll_once(connection_id)
except Exception:
time.sleep(5)
stop.wait(1)
def _stop_receiver(self, connection_id: str) -> None:
with self._receiver_lock:
stop = self._receiver_stops.pop(connection_id, None)
if stop is not None:
stop.set()
def _post_json(
self,
base_url: str,
endpoint: str,
body: dict[str, Any],
*,
token: str | None,
timeout: float,
) -> dict[str, Any]:
response = self.http.post(
_url(base_url, endpoint),
json={**body, "base_info": _base_info()},
headers=_headers(token),
timeout=timeout,
)
response.raise_for_status()
return dict(response.json())
def _get_json(self, base_url: str, endpoint: str, *, timeout: float) -> dict[str, Any]:
response = self.http.get(_url(base_url, endpoint), headers=_common_headers(), timeout=timeout)
response.raise_for_status()
return dict(response.json())
def _default_bridge_post(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> None:
response = self.http.post(url, json=payload, headers=headers, timeout=20)
response.raise_for_status()
def _url(base_url: str, endpoint: str) -> str:
return f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
def _base_info() -> dict[str, str]:
return {"channel_version": "2.4.3", "bot_agent": "Beaver/1.0"}
def _common_headers() -> dict[str, str]:
return {"iLink-App-Id": "bot", "iLink-App-ClientVersion": WeixinIlinkProvider.app_client_version}
def _headers(token: str | None) -> dict[str, str]:
headers = {
**_common_headers(),
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": base64.b64encode(str(random.getrandbits(32)).encode("utf-8")).decode("ascii"),
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _qr_svg_data_uri(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
raw = image.to_string(encoding="unicode")
return "data:image/svg+xml;base64," + base64.b64encode(raw.encode("utf-8")).decode("ascii")
def _bridge_event_from_weixin_message(session: ConnectorSessionState, message: dict[str, Any]) -> dict[str, Any]:
message_id = str(message.get("message_id") or uuid4().hex)
peer_id = str(message.get("from_user_id") or "")
text = _extract_text(message)
return {
"eventId": f"{session.channel_id}:{message_id}",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"deliveryAttempt": 1,
"connectionId": session.connection_id,
"channelId": session.channel_id,
"kind": "weixin",
"accountId": session.account_id,
"peerId": peer_id,
"peerType": "dm",
"userId": peer_id,
"threadId": None,
"messageId": message_id,
"messageType": "text",
"content": text,
"metadata": {"contextToken": message.get("context_token")},
}
def _remember_context_token(metadata: dict[str, Any], message: dict[str, Any]) -> None:
peer_id = _optional_text(message.get("from_user_id"))
context_token = _optional_text(message.get("context_token"))
if not peer_id or not context_token:
return None
context_tokens = metadata.get("contextTokens")
if not isinstance(context_tokens, dict):
context_tokens = {}
context_tokens[peer_id] = context_token
metadata["contextTokens"] = context_tokens
return None
def _extract_text(message: dict[str, Any]) -> str:
for item in message.get("item_list") or []:
if not isinstance(item, dict):
continue
text_item = item.get("text_item")
if isinstance(text_item, dict) and text_item.get("text") is not None:
return str(text_item["text"])
return ""
def _business_error(response: dict[str, Any]) -> str | None:
for key in ("ret", "code", "errcode"):
if key not in response:
continue
try:
value = int(response.get(key) or 0)
except (TypeError, ValueError):
value = -1
if value == 0:
return None
message = response.get("errmsg") or response.get("msg") or response.get("error") or response
return str(message)
return None
def _has_connection_material(session: ConnectorSessionState) -> bool:
metadata = dict(session.metadata)
return bool(str(metadata.get("token") or "").strip() and str(metadata.get("baseUrl") or "").strip() and session.account_id)
def _cached_context_token(session: ConnectorSessionState, peer_id: str) -> str | None:
context_tokens = dict(session.metadata.get("contextTokens") or {})
return _optional_text(context_tokens.get(peer_id))
def _client_id(request_id: str) -> str:
text = str(request_id).strip()
if text and len(text) <= 64 and all(char.isalnum() or char in {"_", "-"} for char in text):
return text
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:24]
return f"beaver-weixin-{digest}"
def _optional_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,203 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
def iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(slots=True)
class ConnectorSessionState:
session_id: str
kind: str
connection_id: str
channel_id: str
display_name: str
status: str
options: dict[str, Any] = field(default_factory=dict)
qr_code: str | None = None
qr_image: str | None = None
instructions: list[str] = field(default_factory=list)
account_id: str | None = None
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=iso_now)
updated_at: str = field(default_factory=iso_now)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConnectorSessionState":
return cls(
session_id=str(data.get("session_id") or ""),
kind=str(data.get("kind") or ""),
connection_id=str(data.get("connection_id") or ""),
channel_id=str(data.get("channel_id") or ""),
display_name=str(data.get("display_name") or ""),
status=str(data.get("status") or "pending"),
options=dict(data.get("options") or {}),
qr_code=str(data["qr_code"]) if data.get("qr_code") is not None else None,
qr_image=str(data["qr_image"]) if data.get("qr_image") is not None else None,
instructions=[str(item) for item in data.get("instructions") or []],
account_id=str(data["account_id"]) if data.get("account_id") is not None else None,
error=str(data["error"]) if data.get("error") is not None else None,
metadata=dict(data.get("metadata") or {}),
created_at=str(data.get("created_at") or iso_now()),
updated_at=str(data.get("updated_at") or iso_now()),
)
@dataclass(slots=True)
class SendBeginResult:
should_send: bool
dedupe_key: str
status: str
http_status: int
retry_after_seconds: int | None = None
provider_message_id: str | None = None
class SidecarStateStore:
def __init__(self, path: Path, *, send_processing_ttl_seconds: int = 60) -> None:
self.path = Path(path)
self.send_processing_ttl_seconds = int(send_processing_ttl_seconds)
self._lock = Lock()
def create_session(
self,
*,
kind: str,
connection_id: str,
channel_id: str,
display_name: str,
options: dict[str, Any],
) -> ConnectorSessionState:
session = ConnectorSessionState(
session_id=f"cs_{uuid4().hex}",
kind=kind,
connection_id=connection_id,
channel_id=channel_id,
display_name=display_name,
status="pending",
options=dict(options),
)
with self._lock:
data = self._load()
data["sessions"][session.session_id] = session.to_dict()
self._save(data)
return session
def get_session(self, session_id: str) -> ConnectorSessionState:
data = self._load()
raw = data["sessions"].get(session_id)
if not isinstance(raw, dict):
raise KeyError(session_id)
return ConnectorSessionState.from_dict(raw)
def list_sessions(self) -> list[ConnectorSessionState]:
data = self._load()
return [
ConnectorSessionState.from_dict(raw)
for raw in data["sessions"].values()
if isinstance(raw, dict)
]
def find_session_by_connection_id(self, connection_id: str) -> ConnectorSessionState:
data = self._load()
matches: list[ConnectorSessionState] = []
for raw in data["sessions"].values():
if not isinstance(raw, dict):
continue
session = ConnectorSessionState.from_dict(raw)
if session.connection_id == connection_id:
matches.append(session)
if not matches:
raise KeyError(connection_id)
matches.sort(key=lambda item: item.updated_at)
return matches[-1]
def update_session(self, session_id: str, **updates: Any) -> ConnectorSessionState:
with self._lock:
data = self._load()
raw = data["sessions"].get(session_id)
if not isinstance(raw, dict):
raise KeyError(session_id)
session = ConnectorSessionState.from_dict(raw)
for key, value in updates.items():
if hasattr(session, key):
setattr(session, key, value)
session.updated_at = iso_now()
data["sessions"][session_id] = session.to_dict()
self._save(data)
return session
def begin_send(self, *, connection_id: str, request_id: str) -> SendBeginResult:
dedupe_key = f"{connection_id}:{request_id}"
with self._lock:
data = self._load()
existing = data["sends"].get(dedupe_key)
if isinstance(existing, dict):
status = str(existing.get("status") or "processing")
if status == "completed":
provider_message_id = str(existing.get("provider_message_id") or "")
return SendBeginResult(False, dedupe_key, "completed", 200, None, provider_message_id)
if status == "processing" and not self._send_is_stale(existing):
return SendBeginResult(False, dedupe_key, "processing", 409, 5)
data["sends"][dedupe_key] = {
"connection_id": connection_id,
"request_id": request_id,
"status": "processing",
"updated_at": iso_now(),
}
self._save(data)
return SendBeginResult(True, dedupe_key, "processing", 200)
def complete_send(self, dedupe_key: str, *, provider_message_id: str | None) -> None:
with self._lock:
data = self._load()
item = dict(data["sends"].get(dedupe_key) or {})
item.update({"status": "completed", "provider_message_id": provider_message_id, "updated_at": iso_now()})
data["sends"][dedupe_key] = item
self._save(data)
def fail_send(self, dedupe_key: str, *, error: str | None) -> None:
with self._lock:
data = self._load()
item = dict(data["sends"].get(dedupe_key) or {})
item.update({"status": "failed", "last_error": error, "updated_at": iso_now()})
data["sends"][dedupe_key] = item
self._save(data)
def _send_is_stale(self, item: dict[str, Any]) -> bool:
updated_at = str(item.get("updated_at") or iso_now())
updated = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
return (datetime.now(timezone.utc) - updated).total_seconds() >= self.send_processing_ttl_seconds
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"sessions": {}, "sends": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"sessions": {}, "sends": {}}
if not isinstance(data, dict):
return {"sessions": {}, "sends": {}}
if not isinstance(data.get("sessions"), dict):
data["sessions"] = {}
if not isinstance(data.get("sends"), dict):
data["sends"] = {}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)

View File

@ -0,0 +1,6 @@
{
"private": true,
"dependencies": {
"@larksuiteoapi/node-sdk": "1.66.1"
}
}

View File

@ -0,0 +1,20 @@
[project]
name = "external-connector"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0,<1.0",
"httpx>=0.27.0,<1.0",
"pydantic>=2.7.0,<3.0",
"qrcode>=8.0,<9.0",
"uvicorn[standard]>=0.30.0,<1.0",
]
[dependency-groups]
dev = [
"pytest>=8.0.0,<9.0",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

View File

@ -0,0 +1,322 @@
from __future__ import annotations
import json
from urllib.parse import parse_qs
from external_connector.app import create_app
from external_connector.providers.composite import CompositeProvider
from external_connector.providers.fake import FakeProvider
from external_connector.providers.feishu_bot import FeishuBotProvider
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
from external_connector.state import SidecarStateStore
from fastapi.testclient import TestClient
class FakeResponse:
def __init__(self, payload: dict[str, object]) -> None:
self.payload = payload
self.status_code = 200
self.is_success = True
self.text = json.dumps(payload)
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, object]:
return self.payload
class FakeHttpClient:
def __init__(self) -> None:
self.posts: list[tuple[str, dict[str, object] | None, dict[str, str] | None]] = []
self.registration_poll_response: dict[str, object] = {"error": "authorization_pending"}
def post(
self,
url: str,
*,
json: dict[str, object] | None = None,
data: str | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> FakeResponse:
self.posts.append((url, json, headers))
if url.endswith("/oauth/v1/app/registration"):
params = parse_qs(data or "")
action = str((params.get("action") or [""])[0])
if action == "init":
return FakeResponse({"supported_auth_methods": ["client_secret"]})
if action == "begin":
return FakeResponse(
{
"verification_uri_complete": "https://accounts.feishu.cn/scan?device=1",
"device_code": "device-1",
"interval": 1,
"expire_in": 600,
}
)
if action == "poll":
return FakeResponse(self.registration_poll_response)
if url.endswith("/open-apis/auth/v3/tenant_access_token/internal"):
return FakeResponse({"code": 0, "tenant_access_token": "tenant-token", "expire": 7200})
if "/open-apis/im/v1/messages" in url:
return FakeResponse({"code": 0, "data": {"message_id": "om_out"}})
raise AssertionError(url)
def _provider(
tmp_path,
*,
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] | None = None,
http_client: FakeHttpClient | None = None,
receiver_starts: list[str] | None = None,
) -> FeishuBotProvider:
def bridge_post(url: str, payload: dict[str, object], headers: dict[str, str]) -> None:
if bridge_posts is not None:
bridge_posts.append((url, payload, headers))
def start_receiver(session) -> object:
if receiver_starts is not None:
receiver_starts.append(session.connection_id)
return object()
return FeishuBotProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http_client or FakeHttpClient(),
bridge_base_url="http://beaver:8080",
public_base_url="http://public-sidecar:8787",
bridge_token="bridge-token",
bridge_post=bridge_post,
receiver_start=start_receiver,
)
def test_feishu_bot_provider_starts_create_session_with_qr_from_registration(tmp_path) -> None:
provider = _provider(tmp_path)
session = provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {"mode": "create", "domain": "feishu"},
}
)
assert session["status"] == "qr_ready"
assert session["qrCode"] == "https://accounts.feishu.cn/scan?device=1&from=onboard"
assert session["qrImage"].startswith("data:image/svg+xml;base64,")
assert any("一键创建飞书机器人" in item for item in session["instructions"])
assert any("/feishu start" in item for item in session["instructions"])
assert session["metadata"]["eventCallbackPath"] == "/feishu/events"
assert session["metadata"]["eventCallbackUrl"] == "http://public-sidecar:8787/feishu/events"
assert session["metadata"]["deviceCode"] == "device-1"
def test_feishu_bot_provider_poll_connects_after_qr_confirmation(tmp_path) -> None:
http = FakeHttpClient()
receiver_starts: list[str] = []
provider = _provider(tmp_path, http_client=http, receiver_starts=receiver_starts)
session = provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {"mode": "create", "domain": "feishu"},
}
)
http.registration_poll_response = {
"client_id": "cli_qr",
"client_secret": "qr-secret",
"user_info": {"tenant_brand": "feishu", "open_id": "ou_me"},
}
connected = provider.get_session(session["sessionId"])
repeated = provider.get_session(session["sessionId"])
assert connected["status"] == "connected"
assert repeated["status"] == "connected"
assert connected["accountId"] == "feishu:cli_qr"
assert receiver_starts == ["conn_1"]
stored = provider.store.get_session(session["sessionId"])
assert stored.metadata["appId"] == "cli_qr"
assert stored.metadata["appSecret"] == "qr-secret"
assert stored.metadata["tenantAccessToken"] == "tenant-token"
def test_feishu_bot_provider_connects_with_app_credentials(tmp_path) -> None:
receiver_starts: list[str] = []
provider = _provider(tmp_path, receiver_starts=receiver_starts)
session = provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
}
)
assert session["status"] == "connected"
assert session["accountId"] == "feishu:cli_xxx"
assert session["displayName"] == "Feishu Main"
assert receiver_starts == ["conn_1"]
def test_feishu_bot_provider_send_uses_tenant_token_and_dedupes(tmp_path) -> None:
provider = _provider(tmp_path)
session = provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {"appId": "cli_xxx", "appSecret": "secret"},
}
)
payload = {
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "feishu-main",
"kind": "feishu",
"target": {"peerId": "ou_user", "peerType": "dm", "threadId": None},
"content": "hello",
"metadata": {},
}
first = provider.send(payload)
second = provider.send(payload)
send_posts = [item for item in provider.http.posts if "/open-apis/im/v1/messages" in item[0]]
assert session["status"] == "connected"
assert first == second
assert first["providerMessageId"] == "om_out"
assert len(send_posts) == 1
assert send_posts[0][0].startswith("https://open.feishu.cn/open-apis/im/v1/messages")
assert send_posts[0][2]["Authorization"] == "Bearer tenant-token"
assert send_posts[0][1]["receive_id"] == "ou_user"
assert send_posts[0][1]["msg_type"] == "text"
def test_feishu_event_route_returns_challenge(tmp_path) -> None:
provider = _provider(tmp_path)
app = create_app(provider=provider, api_token="sidecar-token")
with TestClient(app) as client:
response = client.post("/feishu/events", json={"challenge": "abc"})
assert response.status_code == 200
assert response.json() == {"challenge": "abc"}
def test_feishu_event_route_forwards_message_to_bridge(tmp_path) -> None:
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
provider = _provider(tmp_path, bridge_posts=bridge_posts)
provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
}
)
app = create_app(provider=provider, api_token="sidecar-token")
with TestClient(app) as client:
response = client.post(
"/feishu/events",
json={
"schema": "2.0",
"header": {"event_id": "evt_1", "token": "verify-token", "app_id": "cli_xxx"},
"event": {
"sender": {"sender_id": {"open_id": "ou_user"}},
"message": {
"message_id": "om_1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello feishu\"}",
},
},
},
)
assert response.status_code == 200
assert response.json() == {"ok": True}
assert bridge_posts[0][0] == "http://beaver:8080/api/channel-connector-bridge/events"
assert bridge_posts[0][2]["Authorization"] == "Bearer bridge-token"
assert bridge_posts[0][1]["eventId"] == "evt_1"
assert bridge_posts[0][1]["content"] == "hello feishu"
assert bridge_posts[0][1]["peerId"] == "ou_user"
def test_composite_provider_routes_feishu_and_weixin_descriptors(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json")
provider = CompositeProvider([FakeProvider(store), _provider(tmp_path)])
connectors = provider.connectors()
assert [item["kind"] for item in connectors] == ["weixin", "feishu", "feishu"]
assert provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)["status"] == "qr_ready"
def test_composite_provider_get_session_routes_feishu_session_to_feishu_provider(tmp_path) -> None:
http = FakeHttpClient()
store = SidecarStateStore(tmp_path / "state.json")
provider = CompositeProvider(
[
WeixinIlinkProvider(
store=store,
http_client=FakeHttpClient(),
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
),
FeishuBotProvider(
store=store,
http_client=http,
bridge_base_url="http://beaver:8080",
public_base_url="http://public-sidecar:8787",
bridge_token="bridge-token",
start_receivers=False,
),
]
)
session = provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {"mode": "create", "domain": "feishu"},
}
)
http.registration_poll_response = {
"client_id": "cli_qr",
"client_secret": "qr-secret",
"user_info": {"tenant_brand": "feishu", "open_id": "ou_me"},
}
connected = provider.get_session(session["sessionId"])
assert connected["status"] == "connected"
assert connected["accountId"] == "feishu:cli_qr"

View File

@ -0,0 +1,135 @@
from __future__ import annotations
import base64
from fastapi.testclient import TestClient
from external_connector.app import create_app
from external_connector.providers.fake import FakeProvider
from external_connector.state import SidecarStateStore
def test_fake_provider_lists_weixin_and_feishu(tmp_path) -> None:
provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))
connectors = provider.connectors()
assert [item["kind"] for item in connectors] == ["weixin", "feishu"]
assert connectors[0]["authType"] == "qr"
def test_fake_provider_session_flow(tmp_path) -> None:
provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
loaded = provider.get_session(session["sessionId"])
assert session["status"] == "qr_ready"
prefix = "data:image/svg+xml;base64,"
assert session["qrImage"].startswith(prefix)
svg = base64.b64decode(session["qrImage"][len(prefix) :]).decode("utf-8")
assert svg.startswith("<svg")
assert "FAKE QR" in svg
assert loaded["sessionId"] == session["sessionId"]
def test_fake_provider_send_returns_idempotent_result(tmp_path) -> None:
provider = FakeProvider(SidecarStateStore(tmp_path / "state.json"))
payload = {
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "hello",
"metadata": {},
}
first = provider.send(payload)
second = provider.send(payload)
assert first == second
assert first["ok"] is True
def test_sidecar_http_api_requires_bearer_token(tmp_path) -> None:
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")
with TestClient(app) as client:
response = client.get("/connectors")
assert response.status_code == 401
def test_sidecar_http_api_session_and_send(tmp_path) -> None:
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")
headers = {"Authorization": "Bearer sidecar-token"}
with TestClient(app) as client:
connectors = client.get("/connectors", headers=headers)
session = client.post(
"/connector-sessions",
headers=headers,
json={
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
},
)
session_id = session.json()["sessionId"]
loaded = client.get(f"/connector-sessions/{session_id}", headers=headers)
sent = client.post(
"/send",
headers=headers,
json={
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "hello",
"metadata": {},
},
)
assert connectors.status_code == 200
assert session.status_code == 200
assert loaded.json()["sessionId"] == session_id
assert sent.json()["ok"] is True
def test_sidecar_http_api_returns_conflict_for_processing_send(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)
store.begin_send(connection_id="conn_1", request_id="out_1")
app = create_app(provider=FakeProvider(store), api_token="sidecar-token")
headers = {"Authorization": "Bearer sidecar-token"}
with TestClient(app) as client:
response = client.post(
"/send",
headers=headers,
json={
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "hello",
"metadata": {},
},
)
assert response.status_code == 409
assert response.json()["retryAfterSeconds"] == 5

View File

@ -0,0 +1,68 @@
from __future__ import annotations
from external_connector.state import SidecarStateStore
def test_state_store_saves_and_loads_connector_sessions(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json")
session = store.create_session(
kind="weixin",
connection_id="conn_1",
channel_id="weixin-main",
display_name="Weixin Main",
options={},
)
store.update_session(session.session_id, status="connected", account_id="weixin:me", display_name="Me")
loaded = store.get_session(session.session_id)
assert session.session_id.startswith("cs_")
assert loaded.status == "connected"
assert loaded.account_id == "weixin:me"
def test_state_store_dedupes_send_results(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json")
first = store.begin_send(connection_id="conn_1", request_id="out_1")
store.complete_send(first.dedupe_key, provider_message_id="provider-1")
duplicate = store.begin_send(connection_id="conn_1", request_id="out_1")
assert first.should_send is True
assert duplicate.should_send is False
assert duplicate.status == "completed"
assert duplicate.http_status == 200
assert duplicate.provider_message_id == "provider-1"
def test_state_store_returns_conflict_for_active_send_processing(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)
store.begin_send(connection_id="conn_1", request_id="out_1")
duplicate = store.begin_send(connection_id="conn_1", request_id="out_1")
assert duplicate.should_send is False
assert duplicate.status == "processing"
assert duplicate.http_status == 409
assert duplicate.retry_after_seconds == 5
def test_state_store_retries_stale_send_processing(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=0)
store.begin_send(connection_id="conn_1", request_id="out_1")
retry = store.begin_send(connection_id="conn_1", request_id="out_1")
assert retry.should_send is True
assert retry.status == "processing"
def test_state_store_retries_failed_send_immediately(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json", send_processing_ttl_seconds=60)
first = store.begin_send(connection_id="conn_1", request_id="out_1")
store.fail_send(first.dedupe_key, error="provider rejected message")
retry = store.begin_send(connection_id="conn_1", request_id="out_1")
assert retry.should_send is True
assert retry.status == "processing"

View File

@ -0,0 +1,182 @@
from __future__ import annotations
import json
from pathlib import Path
from external_connector.providers.vendor_cli import VendorCliProvider
from external_connector.state import SidecarStateStore
class FakeRunner:
def __init__(self) -> None:
self.commands: list[list[str]] = []
self.cwd: str | None = None
self.timeout: float | None = None
def __call__(self, command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
self.commands.append(command)
self.cwd = cwd
self.timeout = timeout
return 0, "connected account=weixin:me", ""
def test_vendor_cli_provider_uses_env_command_templates(tmp_path) -> None:
runner = FakeRunner()
provider = VendorCliProvider(
store=SidecarStateStore(tmp_path / "state.json"),
env={
"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}",
"CONNECTOR_COMMAND_TIMEOUT_SECONDS": "30",
},
runner=runner,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
assert session["status"] in {"waiting_for_user", "connected"}
assert runner.commands[0][0] == "vendor-weixin"
assert runner.cwd == str(tmp_path)
assert runner.timeout == 30.0
def test_vendor_cli_provider_redacts_sensitive_error(tmp_path) -> None:
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
return 1, "", "failed secret-token appSecret=abc"
provider = VendorCliProvider(
store=SidecarStateStore(tmp_path / "state.json"),
env={"FEISHU_CONNECT_COMMAND": "vendor-feishu install --secret abc"},
runner=runner,
)
session = provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
assert session["status"] == "error"
assert "secret-token" not in (session["error"] or "")
assert "appSecret=abc" not in (session["error"] or "")
def test_vendor_cli_provider_refreshes_session_from_status_command_json(tmp_path) -> None:
calls: list[list[str]] = []
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
calls.append(command)
if command[0] == "vendor-weixin-status":
return (
0,
'{"status":"qr_ready","qrImage":"data:image/png;base64,abc","qrCode":"weixin://scan","metadata":{"phase":"scan"}}',
"",
)
return 0, "waiting", ""
provider = VendorCliProvider(
store=SidecarStateStore(tmp_path / "state.json"),
env={
"WEIXIN_CONNECT_COMMAND": "vendor-weixin install --state {state_dir}",
"WEIXIN_STATUS_COMMAND": "vendor-weixin-status --state {state_dir} --session {session_id}",
},
runner=runner,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
refreshed = provider.get_session(session["sessionId"])
assert calls[1][0] == "vendor-weixin-status"
assert refreshed["status"] == "qr_ready"
assert refreshed["qrImage"] == "data:image/png;base64,abc"
assert refreshed["qrCode"] == "weixin://scan"
assert refreshed["metadata"]["phase"] == "scan"
def test_vendor_cli_provider_refreshes_connected_session_from_key_value_status(tmp_path) -> None:
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
if command[0] == "vendor-feishu-status":
return 0, "status=connected accountId=feishu:tenant-bot displayName=FeishuBot", ""
return 0, "waiting", ""
provider = VendorCliProvider(
store=SidecarStateStore(tmp_path / "state.json"),
env={
"FEISHU_CONNECT_COMMAND": "vendor-feishu install --state {state_dir}",
"FEISHU_STATUS_COMMAND": "vendor-feishu-status --state {state_dir}",
},
runner=runner,
)
session = provider.start_session(
{
"kind": "feishu",
"connectionId": "conn_1",
"channelId": "feishu-main",
"displayName": "Feishu Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
refreshed = provider.get_session(session["sessionId"])
assert refreshed["status"] == "connected"
assert refreshed["accountId"] == "feishu:tenant-bot"
assert refreshed["displayName"] == "FeishuBot"
def test_vendor_cli_provider_send_uses_payload_file_and_dedupes_result(tmp_path) -> None:
payloads: list[dict[str, object]] = []
commands: list[list[str]] = []
def runner(command: list[str], cwd: str, timeout: float) -> tuple[int, str, str]:
commands.append(command)
payload_path = Path(command[command.index("--payload") + 1])
payloads.append(json.loads(payload_path.read_text(encoding="utf-8")))
return 0, '{"providerMessageId":"wx-msg-1"}', ""
provider = VendorCliProvider(
store=SidecarStateStore(tmp_path / "state.json"),
env={"WEIXIN_SEND_COMMAND": "vendor-weixin-send --payload {payload_path}"},
runner=runner,
)
payload = {
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "hello world",
"metadata": {"source": "test"},
}
first = provider.send(payload)
duplicate = provider.send(payload)
assert first == {"ok": True, "providerMessageId": "wx-msg-1"}
assert duplicate == first
assert len(commands) == 1
assert payloads[0]["content"] == "hello world"
assert payloads[0]["target"] == {"peerId": "peer-1", "peerType": "dm", "threadId": None}

View File

@ -0,0 +1,408 @@
from __future__ import annotations
import json
from external_connector.providers.weixin_ilink import WeixinIlinkProvider
from external_connector.state import SidecarStateStore
class FakeResponse:
def __init__(self, payload: dict[str, object]) -> None:
self.payload = payload
self.text = json.dumps(payload)
self.status_code = 200
self.is_success = True
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, object]:
return self.payload
class FakeHttpClient:
def __init__(self) -> None:
self.posts: list[tuple[str, dict[str, object] | None, dict[str, str] | None]] = []
self.gets: list[str] = []
def post(self, url: str, *, json: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.posts.append((url, json, headers))
if "get_bot_qrcode" in url:
return FakeResponse({"qrcode": "qr-token", "qrcode_img_content": "https://scan.example/qr"})
if "sendmessage" in url:
return FakeResponse({"ret": 0})
if "getupdates" in url:
return FakeResponse(
{
"ret": 0,
"get_updates_buf": "next-buf",
"msgs": [
{
"message_id": 42,
"from_user_id": "wx-user",
"to_user_id": "wx-bot",
"context_token": "ctx-1",
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
}
],
}
)
raise AssertionError(url)
def get(self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.gets.append(url)
return FakeResponse(
{
"status": "confirmed",
"bot_token": "bot-token",
"ilink_bot_id": "bot-1@im.bot",
"baseurl": "https://api.weixin.example",
"ilink_user_id": "wx-owner",
}
)
class BusinessFailingSendHttpClient(FakeHttpClient):
def post(self, url: str, *, json: dict[str, object] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.posts.append((url, json, headers))
if "sendmessage" in url:
return FakeResponse({"ret": 47001, "errmsg": "invalid receiver"})
return super().post(url, json=json, headers=headers, timeout=timeout)
class ScannedAfterConnectedHttpClient(FakeHttpClient):
def __init__(self) -> None:
super().__init__()
self.get_count = 0
def get(self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None) -> FakeResponse:
self.gets.append(url)
self.get_count += 1
if self.get_count == 1:
return FakeResponse(
{
"status": "confirmed",
"bot_token": "bot-token",
"ilink_bot_id": "bot-1@im.bot",
"baseurl": "https://api.weixin.example",
"ilink_user_id": "wx-owner",
}
)
return FakeResponse({"status": "scaned"})
def test_weixin_ilink_provider_starts_real_qr_session(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
assert session["status"] == "qr_ready"
assert session["qrCode"] == "https://scan.example/qr"
assert session["qrImage"].startswith("data:image/svg+xml;base64,")
assert http.posts[0][0].endswith("/ilink/bot/get_bot_qrcode?bot_type=3")
def test_weixin_ilink_provider_connects_on_confirmed_status(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
connected = provider.get_session(session["sessionId"])
assert connected["status"] == "connected"
assert connected["accountId"] == "weixin:bot-1@im.bot"
assert connected["displayName"] == "Weixin Main"
def test_weixin_ilink_provider_does_not_downgrade_token_session_to_scanned(tmp_path) -> None:
http = ScannedAfterConnectedHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
connected = provider.get_session(session["sessionId"])
refreshed = provider.get_session(session["sessionId"])
assert connected["status"] == "connected"
assert refreshed["status"] == "connected"
assert refreshed["accountId"] == "weixin:bot-1@im.bot"
def test_weixin_ilink_provider_recovers_token_session_persisted_as_scanned(tmp_path) -> None:
store = SidecarStateStore(tmp_path / "state.json")
provider = WeixinIlinkProvider(
store=store,
http_client=FakeHttpClient(),
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = store.create_session(
kind="weixin",
connection_id="conn_1",
channel_id="weixin-main",
display_name="Weixin Main",
options={},
)
session = store.update_session(
session.session_id,
status="scanned",
account_id="weixin:bot-1@im.bot",
metadata={
"token": "bot-token",
"baseUrl": "https://api.weixin.example",
"userId": "wx-owner",
"getUpdatesBuf": "buf",
},
)
recovered = provider.get_session(session.session_id)
assert recovered["status"] == "connected"
assert recovered["accountId"] == "weixin:bot-1@im.bot"
def test_weixin_ilink_provider_send_uses_saved_token_and_dedupes(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
payload = {
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"contextToken": "ctx-1"},
}
first = provider.send(payload)
second = provider.send(payload)
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
assert first == second
assert first["ok"] is True
assert len(send_posts) == 1
assert send_posts[0][2]["Authorization"] == "Bearer bot-token"
assert send_posts[0][1]["msg"]["from_user_id"] == ""
assert send_posts[0][1]["msg"]["to_user_id"] == "wx-user"
assert send_posts[0][1]["msg"]["client_id"] == "out_1"
assert send_posts[0][1]["msg"]["message_type"] == 2
assert send_posts[0][1]["msg"]["message_state"] == 2
assert send_posts[0][1]["msg"]["context_token"] == "ctx-1"
assert send_posts[0][1]["msg"]["item_list"][0]["text_item"]["text"] == "reply"
def test_weixin_ilink_provider_send_uses_cached_context_token(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
bridge_post=lambda url, payload, headers: None,
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.poll_once("conn_1")
result = provider.send(
{
"requestId": "out_2",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {},
}
)
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
assert result["ok"] is True
assert send_posts[-1][1]["msg"]["context_token"] == "ctx-1"
def test_weixin_ilink_provider_send_uses_safe_client_id_for_platform(tmp_path) -> None:
http = FakeHttpClient()
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.send(
{
"requestId": "out_weixin-main:account@im.bot:peer@im.wechat:msg-1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"contextToken": "ctx-1"},
}
)
send_posts = [item for item in http.posts if "sendmessage" in item[0]]
client_id = send_posts[-1][1]["msg"]["client_id"]
assert client_id.startswith("beaver-weixin-")
assert ":" not in client_id
assert "@" not in client_id
def test_weixin_ilink_provider_send_rejects_business_error_without_completing(tmp_path) -> None:
http = BusinessFailingSendHttpClient()
store = SidecarStateStore(tmp_path / "state.json")
provider = WeixinIlinkProvider(
store=store,
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
payload = {
"requestId": "out_1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "wx-user", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {},
}
result = provider.send(payload)
retry = store.begin_send(connection_id="conn_1", request_id="out_1")
assert result["ok"] is False
assert "invalid receiver" in result["error"]
assert retry.should_send is True
def test_weixin_ilink_provider_poll_once_forwards_bridge_event(tmp_path) -> None:
http = FakeHttpClient()
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
def bridge_post(url: str, payload: dict[str, object], headers: dict[str, str]) -> None:
bridge_posts.append((url, payload, headers))
provider = WeixinIlinkProvider(
store=SidecarStateStore(tmp_path / "state.json"),
http_client=http,
bridge_base_url="http://beaver:8080",
bridge_token="bridge-token",
bridge_post=bridge_post,
start_receivers=False,
)
session = provider.start_session(
{
"kind": "weixin",
"connectionId": "conn_1",
"channelId": "weixin-main",
"displayName": "Weixin Main",
"callbackBaseUrl": "http://beaver:8080",
"options": {},
}
)
provider.get_session(session["sessionId"])
provider.poll_once("conn_1")
assert bridge_posts[0][0] == "http://beaver:8080/api/channel-connector-bridge/events"
assert bridge_posts[0][2]["Authorization"] == "Bearer bridge-token"
assert bridge_posts[0][1]["eventId"] == "weixin-main:42"
assert bridge_posts[0][1]["content"] == "hello"
assert bridge_posts[0][1]["peerId"] == "wx-user"

621
external-connector/uv.lock generated Normal file
View File

@ -0,0 +1,621 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "external-connector"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "qrcode" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0,<1.0" },
{ name = "httpx", specifier = ">=0.27.0,<1.0" },
{ name = "pydantic", specifier = ">=2.7.0,<3.0" },
{ name = "qrcode", specifier = ">=8.0,<9.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<1.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0.0,<9.0" }]
[[package]]
name = "fastapi"
version = "0.136.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[[package]]
name = "starlette"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]