feat: implement channel runtime connectors
This commit is contained in:
23
external-connector/Dockerfile
Normal file
23
external-connector/Dockerfile
Normal 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"]
|
||||
1
external-connector/external_connector/__init__.py
Normal file
1
external-connector/external_connector/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Generic external connector sidecar."""
|
||||
74
external-connector/external_connector/app.py
Normal file
74
external-connector/external_connector/app.py
Normal 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
|
||||
62
external-connector/external_connector/main.py
Normal file
62
external-connector/external_connector/main.py
Normal 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()
|
||||
24
external-connector/external_connector/models.py
Normal file
24
external-connector/external_connector/models.py
Normal 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)
|
||||
149
external-connector/external_connector/node/feishu_ws_receiver.js
Normal file
149
external-connector/external_connector/node/feishu_ws_receiver.js
Normal 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, "***");
|
||||
}
|
||||
28
external-connector/external_connector/providers/base.py
Normal file
28
external-connector/external_connector/providers/base.py
Normal 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]:
|
||||
...
|
||||
70
external-connector/external_connector/providers/composite.py
Normal file
70
external-connector/external_connector/providers/composite.py
Normal 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
|
||||
119
external-connector/external_connector/providers/fake.py
Normal file
119
external-connector/external_connector/providers/fake.py
Normal 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}
|
||||
542
external-connector/external_connector/providers/feishu_bot.py
Normal file
542
external-connector/external_connector/providers/feishu_bot.py
Normal 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 是否收到。",
|
||||
]
|
||||
281
external-connector/external_connector/providers/vendor_cli.py
Normal file
281
external-connector/external_connector/providers/vendor_cli.py
Normal 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)
|
||||
463
external-connector/external_connector/providers/weixin_ilink.py
Normal file
463
external-connector/external_connector/providers/weixin_ilink.py
Normal 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
|
||||
203
external-connector/external_connector/state.py
Normal file
203
external-connector/external_connector/state.py
Normal 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)
|
||||
6
external-connector/package.json
Normal file
6
external-connector/package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "1.66.1"
|
||||
}
|
||||
}
|
||||
20
external-connector/pyproject.toml
Normal file
20
external-connector/pyproject.toml
Normal 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"]
|
||||
322
external-connector/tests/test_feishu_bot_provider.py
Normal file
322
external-connector/tests/test_feishu_bot_provider.py
Normal 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"
|
||||
135
external-connector/tests/test_sidecar_api.py
Normal file
135
external-connector/tests/test_sidecar_api.py
Normal 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
|
||||
68
external-connector/tests/test_state.py
Normal file
68
external-connector/tests/test_state.py
Normal 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"
|
||||
182
external-connector/tests/test_vendor_cli_provider.py
Normal file
182
external-connector/tests/test_vendor_cli_provider.py
Normal 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}
|
||||
408
external-connector/tests/test_weixin_ilink_provider.py
Normal file
408
external-connector/tests/test_weixin_ilink_provider.py
Normal 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
621
external-connector/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user