diff --git a/.env.feishu.example b/.env.feishu.example new file mode 100644 index 0000000..3de8f19 --- /dev/null +++ b/.env.feishu.example @@ -0,0 +1,28 @@ +# Feishu bridge — copy to .env and fill in values. +# Start: AGENT_MAILER_SECRET_KEY=... uv run agent-mailer-server feishu-bridge + +# Feishu app credentials (open.feishu.cn) +FEISHU_APP_ID= +FEISHU_APP_SECRET= +FEISHU_VERIFICATION_TOKEN= +FEISHU_ENCRYPT_KEY= + +# Target group chat id (oc_...) +FEISHU_CHAT_ID= + +# Broker connection +AGENT_MAILER_BASE_URL=http://127.0.0.1:9800 +AGENT_MAILER_OPERATOR_USER= +AGENT_MAILER_OPERATOR_PASSWORD= + +# Fixed PM agent address in this user's namespace +FEISHU_PM_ADDRESS=pm@youruser.amp.linkyun.co + +# Bridge process +FEISHU_POLL_INTERVAL=3 +FEISHU_BRIDGE_HOST=0.0.0.0 +FEISHU_BRIDGE_PORT=9810 +FEISHU_BRIDGE_STATE_PATH=.feishu-bridge/state.json + +# Feishu event subscription callback URL (must be public): +# https:///feishu/webhook diff --git a/pyproject.toml b/pyproject.toml index acd8f92..d3a1f2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "python-multipart>=0.0.9", "httpx>=0.28", "click>=8.1", + "cryptography>=42", ] [project.optional-dependencies] diff --git a/src/agent_mailer/cli.py b/src/agent_mailer/cli.py index a9afde3..7051343 100644 --- a/src/agent_mailer/cli.py +++ b/src/agent_mailer/cli.py @@ -167,6 +167,14 @@ def main(): md = subparsers.add_parser("migrate-db", help="Migrate local-mode DB to SaaS mode") md.add_argument("--password", required=True, help="Password for the admin user") + # feishu-bridge + fb = subparsers.add_parser( + "feishu-bridge", + help="Start Feishu bridge (human-operator second screen for a group chat)", + ) + fb.add_argument("--host", default=None, help="Bind host (default: FEISHU_BRIDGE_HOST or 0.0.0.0)") + fb.add_argument("--port", type=int, default=None, help="Bind port (default: FEISHU_BRIDGE_PORT or 9810)") + args = parser.parse_args() if not args.command: parser.print_help() @@ -178,6 +186,20 @@ def main(): asyncio.run(_generate_invite_code(args)) elif args.command == "migrate-db": asyncio.run(_migrate_db(args)) + elif args.command == "feishu-bridge": + _feishu_bridge(args) + + +def _feishu_bridge(args): + import uvicorn + + from agent_mailer.feishu.bridge import create_app + from agent_mailer.feishu.config import FeishuBridgeConfig + + config = FeishuBridgeConfig.from_env() + host = args.host or config.bridge_host + port = args.port or config.bridge_port + uvicorn.run(create_app(config), host=host, port=port) if __name__ == "__main__": diff --git a/src/agent_mailer/feishu/__init__.py b/src/agent_mailer/feishu/__init__.py new file mode 100644 index 0000000..8a46fc1 --- /dev/null +++ b/src/agent_mailer/feishu/__init__.py @@ -0,0 +1 @@ +"""Feishu bridge — human-operator second screen for a Feishu group chat.""" diff --git a/src/agent_mailer/feishu/bridge.py b/src/agent_mailer/feishu/bridge.py new file mode 100644 index 0000000..269d0a9 --- /dev/null +++ b/src/agent_mailer/feishu/bridge.py @@ -0,0 +1,204 @@ +"""FastAPI app: Feishu webhook (inbound) + broker inbox polling (outbound).""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, HTTPException, Request + +from agent_mailer.feishu.broker_client import BrokerClient +from agent_mailer.feishu.config import FeishuBridgeConfig +from agent_mailer.feishu.feishu_client import FeishuClient +from agent_mailer.feishu.state import BridgeState + +logger = logging.getLogger(__name__) + + +def _normalize_headers(headers: Any) -> dict[str, str]: + return {k.lower(): v for k, v in headers.items()} + + +async def poll_outbound_once( + broker: BrokerClient, + feishu: FeishuClient, + state: BridgeState, + config: FeishuBridgeConfig, +) -> None: + messages = await broker.inbox_unread() + for msg in reversed(messages): + msg_id = msg["id"] + if state.is_broker_message_pushed(msg_id): + continue + text = FeishuClient.format_outbound_message(msg) + await feishu.send_text(config.chat_id, text) + await broker.mark_read(msg_id) + state.record_broker_message_pushed(msg_id) + state.set_active_thread(msg.get("thread_id")) + + +async def outbound_poll_loop(app: FastAPI) -> None: + broker: BrokerClient = app.state.broker + feishu: FeishuClient = app.state.feishu + state: BridgeState = app.state.state + config: FeishuBridgeConfig = app.state.config + + while True: + try: + await poll_outbound_once(broker, feishu, state, config) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Outbound poll failed") + await asyncio.sleep(config.poll_interval) + + +async def handle_inbound_message( + broker: BrokerClient, + feishu: FeishuClient, + state: BridgeState, + config: FeishuBridgeConfig, + event: dict[str, Any], +) -> None: + message = event.get("message") or {} + message_id = message.get("message_id") + if not message_id: + return + + if message.get("chat_id") != config.chat_id: + return + + sender = event.get("sender") or {} + if sender.get("sender_type") == "app": + return + + if message.get("message_type") != "text": + return + + if not await feishu.is_bot_mentioned(message): + return + + if state.is_feishu_message_seen(message_id): + return + + text = FeishuClient.extract_text(message) + if not text: + return + + state.record_feishu_message(message_id) + + parent_id: str | None = None + action = "send" + subject = "Feishu" + + thread_id = state.active_thread_id + if thread_id: + thread_msgs = await broker.get_thread(thread_id) + if thread_msgs: + parent_id = thread_msgs[-1]["id"] + action = "reply" + subject = "" + + result = await broker.send_message( + to_agent=config.pm_address, + action=action, + subject=subject, + body=text, + parent_id=parent_id, + ) + state.set_active_thread(result.get("thread_id")) + + +def create_app( + config: FeishuBridgeConfig | None = None, + *, + enable_poll: bool = True, + broker: BrokerClient | None = None, + feishu: FeishuClient | None = None, + state: BridgeState | None = None, +) -> FastAPI: + bridge_config = config or FeishuBridgeConfig.from_env() + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.config = bridge_config + app.state.broker = broker or BrokerClient( + bridge_config.broker_base_url, + bridge_config.operator_username, + bridge_config.operator_password, + ) + app.state.feishu = feishu or FeishuClient( + bridge_config.app_id, + bridge_config.app_secret, + bridge_config.verification_token, + bridge_config.encrypt_key, + ) + app.state.state = state or BridgeState(bridge_config.state_path) + + if broker is None: + await app.state.broker.login() + await app.state.broker.ensure_human_operator() + if feishu is None: + await app.state.feishu.bot_open_id() + + poll_task = None + if enable_poll: + poll_task = asyncio.create_task(outbound_poll_loop(app)) + logger.info( + "Feishu bridge started (broker=%s, chat=%s, pm=%s)", + bridge_config.broker_base_url, + bridge_config.chat_id, + bridge_config.pm_address, + ) + try: + yield + finally: + if poll_task is not None: + poll_task.cancel() + try: + await poll_task + except asyncio.CancelledError: + pass + await app.state.broker.aclose() + await app.state.feishu.aclose() + + app = FastAPI(title="Agent Mailer Feishu Bridge", lifespan=lifespan) + + @app.get("/health") + async def health(): + return {"status": "ok"} + + @app.post("/feishu/webhook") + async def feishu_webhook(request: Request): + body_bytes = await request.body() + body = body_bytes.decode("utf-8") + feishu: FeishuClient = request.app.state.feishu + broker: BrokerClient = request.app.state.broker + state: BridgeState = request.app.state.state + config: FeishuBridgeConfig = request.app.state.config + + try: + payload = feishu.parse_event(_normalize_headers(request.headers), body) + except ValueError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + if payload.get("type") == "url_verification": + return {"challenge": payload["challenge"]} + + header = payload.get("header") or {} + if header.get("event_type") != "im.message.receive_v1": + return {"ok": True} + + try: + await handle_inbound_message( + broker, feishu, state, config, payload.get("event") or {} + ) + except Exception: + logger.exception("Inbound Feishu message handling failed") + raise HTTPException(status_code=500, detail="Inbound handling failed") from None + + return {"ok": True} + + return app diff --git a/src/agent_mailer/feishu/broker_client.py b/src/agent_mailer/feishu/broker_client.py new file mode 100644 index 0000000..77a623d --- /dev/null +++ b/src/agent_mailer/feishu/broker_client.py @@ -0,0 +1,97 @@ +"""HTTP client for broker admin routes (session-authenticated).""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + + +class BrokerClient: + def __init__(self, base_url: str, username: str, password: str): + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password + self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0) + self._session_token: str | None = None + self._op_address: str | None = None + + async def aclose(self) -> None: + await self._client.aclose() + + def _auth_headers(self) -> dict[str, str]: + if not self._session_token: + return {} + return {"Authorization": f"Bearer {self._session_token}"} + + async def login(self) -> None: + resp = await self._client.post( + "/users/login", + json={"username": self.username, "password": self.password}, + ) + resp.raise_for_status() + self._session_token = resp.json()["token"] + logger.info("Broker session established for user %s", self.username) + + async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + extra_headers = dict(kwargs.pop("headers", {})) + headers = {**extra_headers, **self._auth_headers()} + resp = await self._client.request(method, path, headers=headers, **kwargs) + if resp.status_code == 401: + logger.warning("Broker session expired; re-authenticating") + await self.login() + headers = {**extra_headers, **self._auth_headers()} + resp = await self._client.request(method, path, headers=headers, **kwargs) + resp.raise_for_status() + return resp + + async def ensure_human_operator(self) -> dict[str, str]: + resp = await self._request("GET", "/admin/human-operator") + data = resp.json() + self._op_address = data["address"] + return data + + @property + def op_address(self) -> str: + if not self._op_address: + raise RuntimeError("human-operator address not loaded; call ensure_human_operator()") + return self._op_address + + async def inbox_unread(self, address: str | None = None) -> list[dict[str, Any]]: + addr = address or self.op_address + resp = await self._request("GET", f"/admin/messages/inbox/{addr}") + data = resp.json() + if isinstance(data, dict) and "messages" in data: + return data["messages"] + return data + + async def mark_read(self, message_id: str) -> dict[str, Any]: + resp = await self._request("PATCH", f"/admin/messages/{message_id}/read") + return resp.json() + + async def send_message( + self, + *, + to_agent: str, + action: str, + subject: str, + body: str, + parent_id: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "to_agent": to_agent, + "action": action, + "subject": subject, + "body": body, + } + if parent_id: + payload["parent_id"] = parent_id + resp = await self._request("POST", "/admin/messages/send", json=payload) + return resp.json() + + async def get_thread(self, thread_id: str) -> list[dict[str, Any]]: + resp = await self._request("GET", f"/admin/messages/thread/{thread_id}") + return resp.json() diff --git a/src/agent_mailer/feishu/config.py b/src/agent_mailer/feishu/config.py new file mode 100644 index 0000000..bd9b301 --- /dev/null +++ b/src/agent_mailer/feishu/config.py @@ -0,0 +1,59 @@ +"""Environment-driven configuration for the Feishu bridge process.""" + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + +_project_root = Path(__file__).resolve().parent.parent.parent.parent +load_dotenv(_project_root / ".env") + + +def _require(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"Environment variable {name} is required for the Feishu bridge") + return value + + +@dataclass(frozen=True) +class FeishuBridgeConfig: + app_id: str + app_secret: str + verification_token: str + encrypt_key: str + chat_id: str + broker_base_url: str + operator_username: str + operator_password: str + pm_address: str + poll_interval: float + state_path: Path + bridge_host: str + bridge_port: int + + @classmethod + def from_env(cls) -> "FeishuBridgeConfig": + return cls( + app_id=_require("FEISHU_APP_ID"), + app_secret=_require("FEISHU_APP_SECRET"), + verification_token=_require("FEISHU_VERIFICATION_TOKEN"), + encrypt_key=os.environ.get("FEISHU_ENCRYPT_KEY", "").strip(), + chat_id=_require("FEISHU_CHAT_ID"), + broker_base_url=os.environ.get( + "AGENT_MAILER_BASE_URL", "http://127.0.0.1:9800" + ).rstrip("/"), + operator_username=_require("AGENT_MAILER_OPERATOR_USER"), + operator_password=_require("AGENT_MAILER_OPERATOR_PASSWORD"), + pm_address=_require("FEISHU_PM_ADDRESS"), + poll_interval=float(os.environ.get("FEISHU_POLL_INTERVAL", "3")), + state_path=Path( + os.environ.get( + "FEISHU_BRIDGE_STATE_PATH", + str(_project_root / ".feishu-bridge" / "state.json"), + ) + ), + bridge_host=os.environ.get("FEISHU_BRIDGE_HOST", "0.0.0.0"), + bridge_port=int(os.environ.get("FEISHU_BRIDGE_PORT", "9810")), + ) diff --git a/src/agent_mailer/feishu/feishu_client.py b/src/agent_mailer/feishu/feishu_client.py new file mode 100644 index 0000000..0800a6b --- /dev/null +++ b/src/agent_mailer/feishu/feishu_client.py @@ -0,0 +1,189 @@ +"""Feishu Open Platform client — token, messaging, and event parsing.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import time +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +FEISHU_API_BASE = "https://open.feishu.cn/open-apis" + + +class FeishuClient: + def __init__( + self, + app_id: str, + app_secret: str, + verification_token: str, + encrypt_key: str = "", + ): + self.app_id = app_id + self.app_secret = app_secret + self.verification_token = verification_token + self.encrypt_key = encrypt_key + self._client = httpx.AsyncClient(base_url=FEISHU_API_BASE, timeout=30.0) + self._tenant_token: str | None = None + self._tenant_token_expires_at: float = 0.0 + self._bot_open_id: str | None = None + + async def aclose(self) -> None: + await self._client.aclose() + + async def tenant_access_token(self) -> str: + now = time.time() + if self._tenant_token and now < self._tenant_token_expires_at - 60: + return self._tenant_token + + resp = await self._client.post( + "/auth/v3/tenant_access_token/internal", + json={"app_id": self.app_id, "app_secret": self.app_secret}, + ) + resp.raise_for_status() + data = resp.json() + if data.get("code") != 0: + raise RuntimeError(f"Feishu token error: {data}") + self._tenant_token = data["tenant_access_token"] + self._tenant_token_expires_at = now + float(data.get("expire", 7200)) + return self._tenant_token + + async def bot_open_id(self) -> str: + if self._bot_open_id: + return self._bot_open_id + token = await self.tenant_access_token() + resp = await self._client.get( + "/bot/v3/info", + headers={"Authorization": f"Bearer {token}"}, + ) + resp.raise_for_status() + data = resp.json() + if data.get("code") != 0: + raise RuntimeError(f"Feishu bot info error: {data}") + self._bot_open_id = data["bot"]["open_id"] + return self._bot_open_id + + async def send_text(self, chat_id: str, text: str) -> None: + token = await self.tenant_access_token() + resp = await self._client.post( + "/im/v1/messages", + params={"receive_id_type": "chat_id"}, + headers={"Authorization": f"Bearer {token}"}, + json={ + "receive_id": chat_id, + "msg_type": "text", + "content": json.dumps({"text": text}, ensure_ascii=False), + }, + ) + resp.raise_for_status() + data = resp.json() + if data.get("code") != 0: + raise RuntimeError(f"Feishu send message error: {data}") + + def verify_signature( + self, timestamp: str, nonce: str, body: str, signature: str + ) -> bool: + key = self.encrypt_key or self.verification_token + raw = f"{timestamp}{nonce}{key}{body}" + expected = hashlib.sha256(raw.encode("utf-8")).hexdigest() + return hmac.compare_digest(expected, signature) + + def decrypt_payload(self, cipher_text_b64: str) -> dict[str, Any]: + if not self.encrypt_key: + raise RuntimeError("FEISHU_ENCRYPT_KEY is required to decrypt events") + plain = _aes_cbc_decrypt(self.encrypt_key, cipher_text_b64) + return json.loads(plain) + + def parse_event( + self, + headers: dict[str, str], + body: str, + ) -> dict[str, Any]: + timestamp = headers.get("x-lark-request-timestamp", "") + nonce = headers.get("x-lark-request-nonce", "") + signature = headers.get("x-lark-signature", "") + + payload = json.loads(body) + + if payload.get("type") == "url_verification": + token = payload.get("token", "") + if not token or token != self.verification_token: + raise ValueError("Invalid Feishu verification token") + return payload + + if not signature: + raise ValueError("Missing Feishu request signature") + if not self.verify_signature(timestamp, nonce, body, signature): + raise ValueError("Invalid Feishu request signature") + + if "encrypt" in payload: + payload = self.decrypt_payload(payload["encrypt"]) + + header = payload.get("header", {}) + token = header.get("token", "") + if token and token != self.verification_token: + raise ValueError("Invalid Feishu event token") + + return payload + + async def is_bot_mentioned(self, message: dict[str, Any]) -> bool: + bot_id = await self.bot_open_id() + for mention in message.get("mentions") or []: + mention_id = mention.get("id") or {} + if mention_id.get("open_id") == bot_id: + return True + return False + + @staticmethod + def extract_text(message: dict[str, Any]) -> str: + content_raw = message.get("content") or "{}" + content = json.loads(content_raw) if isinstance(content_raw, str) else content_raw + text = content.get("text", "") + for mention in message.get("mentions") or []: + key = mention.get("key") + if key: + text = text.replace(key, "").strip() + return text.strip() + + @staticmethod + def format_outbound_message(msg: dict[str, Any]) -> str: + subject = (msg.get("subject") or "").strip() + from_agent = msg.get("from_agent") or "" + body = (msg.get("body") or "").strip() + lines = [] + if subject: + lines.append(f"**{subject}**") + if from_agent: + lines.append(f"From: `{from_agent}`") + if body: + lines.append(body) + return "\n".join(lines) if lines else "(empty message)" + + +def _aes_cbc_decrypt(encrypt_key: str, cipher_text_b64: str) -> str: + try: + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + except ImportError as exc: + raise RuntimeError( + "FEISHU_ENCRYPT_KEY is set but cryptography is not installed; " + "add the cryptography package to decrypt Feishu events" + ) from exc + + key = hashlib.sha256(encrypt_key.encode("utf-8")).digest() + cipher_bytes = base64.b64decode(cipher_text_b64) + iv = cipher_bytes[:16] + encrypted = cipher_bytes[16:] + cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) + decryptor = cipher.decryptor() + padded = decryptor.update(encrypted) + decryptor.finalize() + pad_len = padded[-1] + if pad_len < 1 or pad_len > 16: + raise ValueError("Invalid PKCS7 padding in Feishu payload") + return padded[:-pad_len].decode("utf-8") diff --git a/src/agent_mailer/feishu/state.py b/src/agent_mailer/feishu/state.py new file mode 100644 index 0000000..62b2f63 --- /dev/null +++ b/src/agent_mailer/feishu/state.py @@ -0,0 +1,60 @@ +"""Lightweight JSON persistence for bridge dedup and thread continuity.""" + +import json +from pathlib import Path + +_MAX_IDS = 2000 + + +class BridgeState: + def __init__(self, path: Path): + self.path = path + self.data: dict = { + "feishu_message_ids": [], + "pushed_broker_message_ids": [], + "active_thread_id": None, + } + self.load() + + def load(self) -> None: + if self.path.exists(): + self.data = json.loads(self.path.read_text(encoding="utf-8")) + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text( + json.dumps(self.data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + @property + def active_thread_id(self) -> str | None: + return self.data.get("active_thread_id") + + def set_active_thread(self, thread_id: str | None) -> None: + self.data["active_thread_id"] = thread_id + self.save() + + def is_feishu_message_seen(self, message_id: str) -> bool: + return message_id in self.data["feishu_message_ids"] + + def record_feishu_message(self, message_id: str) -> None: + ids: list[str] = self.data["feishu_message_ids"] + if message_id in ids: + return + ids.append(message_id) + if len(ids) > _MAX_IDS: + self.data["feishu_message_ids"] = ids[-_MAX_IDS:] + self.save() + + def is_broker_message_pushed(self, message_id: str) -> bool: + return message_id in self.data["pushed_broker_message_ids"] + + def record_broker_message_pushed(self, message_id: str) -> None: + ids: list[str] = self.data["pushed_broker_message_ids"] + if message_id in ids: + return + ids.append(message_id) + if len(ids) > _MAX_IDS: + self.data["pushed_broker_message_ids"] = ids[-_MAX_IDS:] + self.save() diff --git a/tests/test_feishu_bridge.py b/tests/test_feishu_bridge.py new file mode 100644 index 0000000..adfe5fa --- /dev/null +++ b/tests/test_feishu_bridge.py @@ -0,0 +1,275 @@ +import hashlib +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from httpx import ASGITransport, AsyncClient + +from agent_mailer.feishu.bridge import create_app, handle_inbound_message, poll_outbound_once +from agent_mailer.feishu.broker_client import BrokerClient +from agent_mailer.feishu.config import FeishuBridgeConfig +from agent_mailer.feishu.feishu_client import FeishuClient +from agent_mailer.feishu.state import BridgeState + + +def _test_config(tmp_path: Path) -> FeishuBridgeConfig: + return FeishuBridgeConfig( + app_id="cli_test_app", + app_secret="cli_test_secret", + verification_token="verify_token_123", + encrypt_key="", + chat_id="oc_test_chat", + broker_base_url="http://broker.test", + operator_username="testuser", + operator_password="test-password-123", + pm_address="pm@testuser.amp.linkyun.co", + poll_interval=1.0, + state_path=tmp_path / "state.json", + bridge_host="127.0.0.1", + bridge_port=9810, + ) + + +def test_bridge_state_dedup_and_thread(tmp_path): + state = BridgeState(tmp_path / "state.json") + state.record_feishu_message("fm1") + assert state.is_feishu_message_seen("fm1") + state.set_active_thread("thread-abc") + assert state.active_thread_id == "thread-abc" + + state2 = BridgeState(tmp_path / "state.json") + assert state2.is_feishu_message_seen("fm1") + assert state2.active_thread_id == "thread-abc" + + +def test_feishu_signature_and_url_challenge(): + client = FeishuClient("app", "secret", "verify_token_123") + body = json.dumps({"type": "url_verification", "challenge": "ch_1", "token": "verify_token_123"}) + timestamp = "1700000000" + nonce = "nonce" + signature = hashlib.sha256(f"{timestamp}{nonce}verify_token_123{body}".encode()).hexdigest() + headers = { + "x-lark-request-timestamp": timestamp, + "x-lark-request-nonce": nonce, + "x-lark-signature": signature, + } + payload = client.parse_event(headers, body) + assert payload["challenge"] == "ch_1" + + +def test_feishu_signature_rejects_missing_header(): + client = FeishuClient("app", "secret", "verify_token_123") + body = json.dumps({"header": {"event_type": "im.message.receive_v1"}}) + with pytest.raises(ValueError, match="Missing"): + client.parse_event({}, body) + + +def test_feishu_signature_rejects_invalid(): + client = FeishuClient("app", "secret", "verify_token_123") + body = "{}" + headers = { + "x-lark-request-timestamp": "1", + "x-lark-request-nonce": "n", + "x-lark-signature": "bad", + } + with pytest.raises(ValueError, match="signature"): + client.parse_event(headers, body) + + +def test_feishu_extract_text_strips_mentions(): + message = { + "content": json.dumps({"text": "@_user_1 please review"}), + "mentions": [{"key": "@_user_1", "id": {"open_id": "ou_bot"}}], + } + assert FeishuClient.extract_text(message) == "please review" + + +@pytest.mark.asyncio +async def test_handle_inbound_new_thread(tmp_path): + config = _test_config(tmp_path) + broker = MagicMock() + broker.send_message = AsyncMock( + return_value={"id": "m1", "thread_id": "t-new"} + ) + broker.get_thread = AsyncMock(return_value=[]) + + feishu = MagicMock() + feishu.is_bot_mentioned = AsyncMock(return_value=True) + + state = BridgeState(config.state_path) + event = { + "message": { + "message_id": "fm-new", + "chat_id": config.chat_id, + "message_type": "text", + "content": json.dumps({"text": "@bot start task"}), + "mentions": [{"key": "@bot", "id": {"open_id": "ou_bot"}}], + }, + "sender": {"sender_type": "user"}, + } + + await handle_inbound_message(broker, feishu, state, config, event) + + broker.send_message.assert_awaited_once_with( + to_agent=config.pm_address, + action="send", + subject="Feishu", + body="start task", + parent_id=None, + ) + assert state.active_thread_id == "t-new" + assert state.is_feishu_message_seen("fm-new") + + +@pytest.mark.asyncio +async def test_handle_inbound_reply_active_thread(tmp_path): + config = _test_config(tmp_path) + broker = MagicMock() + broker.send_message = AsyncMock( + return_value={"id": "m2", "thread_id": "t-existing"} + ) + broker.get_thread = AsyncMock( + return_value=[{"id": "parent-msg", "thread_id": "t-existing"}] + ) + + feishu = MagicMock() + feishu.is_bot_mentioned = AsyncMock(return_value=True) + + state = BridgeState(config.state_path) + state.set_active_thread("t-existing") + + event = { + "message": { + "message_id": "fm-reply", + "chat_id": config.chat_id, + "message_type": "text", + "content": json.dumps({"text": "@bot follow up"}), + "mentions": [{"key": "@bot", "id": {"open_id": "ou_bot"}}], + }, + "sender": {"sender_type": "user"}, + } + + await handle_inbound_message(broker, feishu, state, config, event) + + broker.send_message.assert_awaited_once_with( + to_agent=config.pm_address, + action="reply", + subject="", + body="follow up", + parent_id="parent-msg", + ) + + +@pytest.mark.asyncio +async def test_handle_inbound_ignores_without_bot_mention(tmp_path): + config = _test_config(tmp_path) + broker = MagicMock() + broker.send_message = AsyncMock() + feishu = MagicMock() + feishu.is_bot_mentioned = AsyncMock(return_value=False) + state = BridgeState(config.state_path) + + event = { + "message": { + "message_id": "fm-chat", + "chat_id": config.chat_id, + "message_type": "text", + "content": json.dumps({"text": "just chatting"}), + }, + "sender": {"sender_type": "user"}, + } + + await handle_inbound_message(broker, feishu, state, config, event) + broker.send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_poll_outbound_once_pushes_and_marks_read(tmp_path): + config = _test_config(tmp_path) + broker = MagicMock() + broker.inbox_unread = AsyncMock( + return_value=[ + { + "id": "broker-msg-1", + "thread_id": "t-out", + "from_agent": "pm@testuser.amp.linkyun.co", + "subject": "Update", + "body": "done", + } + ] + ) + broker.mark_read = AsyncMock() + + feishu = MagicMock() + feishu.send_text = AsyncMock() + + state = BridgeState(config.state_path) + await poll_outbound_once(broker, feishu, state, config) + + feishu.send_text.assert_awaited_once() + broker.mark_read.assert_awaited_once_with("broker-msg-1") + assert state.is_broker_message_pushed("broker-msg-1") + assert state.active_thread_id == "t-out" + + await poll_outbound_once(broker, feishu, state, config) + assert feishu.send_text.await_count == 1 + + +@pytest.mark.asyncio +async def test_broker_client_relogin_on_401(): + client = BrokerClient("http://broker.test", "user", "pass") + client._session_token = "stale-token" + + responses = [ + httpx.Response(401, request=httpx.Request("GET", "http://broker.test/admin/human-operator")), + httpx.Response( + 200, + json={"token": "fresh-token"}, + request=httpx.Request("POST", "http://broker.test/users/login"), + ), + httpx.Response( + 200, + json={"agent_id": "op-id", "address": "human-operator@user.amp.linkyun.co"}, + request=httpx.Request("GET", "http://broker.test/admin/human-operator"), + ), + ] + + async def mock_request(method, path, **kwargs): + return responses.pop(0) + + client._client.request = mock_request # type: ignore[method-assign] + + data = await client.ensure_human_operator() + assert data["address"] == "human-operator@user.amp.linkyun.co" + assert client._session_token == "fresh-token" + + +@pytest.mark.asyncio +async def test_webhook_url_verification_endpoint(tmp_path): + config = _test_config(tmp_path) + feishu = FeishuClient(config.app_id, config.app_secret, config.verification_token) + broker = MagicMock() + + app = create_app( + config, + enable_poll=False, + broker=broker, + feishu=feishu, + state=BridgeState(config.state_path), + ) + # ASGITransport does not run lifespan; set state for the webhook handler. + app.state.feishu = feishu + app.state.broker = broker + app.state.state = BridgeState(config.state_path) + app.state.config = config + + body = json.dumps( + {"type": "url_verification", "challenge": "challenge_xyz", "token": config.verification_token} + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + resp = await ac.post("/feishu/webhook", content=body) + assert resp.status_code == 200 + assert resp.json()["challenge"] == "challenge_xyz" diff --git a/uv.lock b/uv.lock index 04f4cc3..6348fe5 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ dependencies = [ { name = "asyncpg" }, { name = "bcrypt" }, { name = "click" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "markdown" }, @@ -33,6 +34,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.29" }, { name = "bcrypt", specifier = ">=4.0" }, { name = "click", specifier = ">=8.1" }, + { name = "cryptography", specifier = ">=42" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, @@ -213,6 +215,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -234,6 +306,62 @@ 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 = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "fastapi" version = "0.135.2" @@ -332,6 +460,15 @@ 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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5"