From 5bff24096cb444c994cda9a244d1d2b49dd640fa Mon Sep 17 00:00:00 2001 From: qiupinhua Date: Thu, 5 Feb 2026 17:39:18 +0800 Subject: [PATCH 001/415] feat: implement OpenAI Codex OAuth login and provider integration --- nanobot/auth/__init__.py | 13 + nanobot/auth/codex_oauth.py | 607 +++++++++++++++++++++ nanobot/cli/commands.py | 104 +++- nanobot/providers/__init__.py | 3 +- nanobot/providers/openai_codex_provider.py | 333 +++++++++++ 5 files changed, 1041 insertions(+), 19 deletions(-) create mode 100644 nanobot/auth/__init__.py create mode 100644 nanobot/auth/codex_oauth.py create mode 100644 nanobot/providers/openai_codex_provider.py diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py new file mode 100644 index 0000000..e74e1c2 --- /dev/null +++ b/nanobot/auth/__init__.py @@ -0,0 +1,13 @@ +"""鉴权相关模块。""" + +from nanobot.auth.codex_oauth import ( + ensure_codex_token_available, + get_codex_token, + login_codex_oauth_interactive, +) + +__all__ = [ + "ensure_codex_token_available", + "get_codex_token", + "login_codex_oauth_interactive", +] diff --git a/nanobot/auth/codex_oauth.py b/nanobot/auth/codex_oauth.py new file mode 100644 index 0000000..0784267 --- /dev/null +++ b/nanobot/auth/codex_oauth.py @@ -0,0 +1,607 @@ +"""OpenAI Codex OAuth implementation.""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import os +import socket +import sys +import threading +import time +import urllib.parse +import webbrowser +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any, Callable + +import httpx + +from nanobot.utils.helpers import ensure_dir, get_data_path + +# Fixed parameters (sourced from the official Codex CLI OAuth client). +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" +TOKEN_URL = "https://auth.openai.com/oauth/token" +REDIRECT_URI = "http://localhost:1455/auth/callback" +SCOPE = "openid profile email offline_access" +JWT_CLAIM_PATH = "https://api.openai.com/auth" + +DEFAULT_ORIGINATOR = "nanobot" +TOKEN_FILENAME = "codex.json" +MANUAL_PROMPT_DELAY_SEC = 3 +SUCCESS_HTML = ( + "" + "" + "" + "" + "" + "Authentication successful" + "" + "" + "

Authentication successful. Return to your terminal to continue.

" + "" + "" +) + + +@dataclass +class CodexToken: + """Codex OAuth token data structure.""" + access: str + refresh: str + expires: int + account_id: str + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") + + +def _decode_base64url(data: str) -> bytes: + padding = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + padding) + + +def _generate_pkce() -> tuple[str, str]: + verifier = _base64url(os.urandom(32)) + challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest()) + return verifier, challenge + + +def _create_state() -> str: + return _base64url(os.urandom(16)) + + +def _get_token_path() -> Path: + auth_dir = ensure_dir(get_data_path() / "auth") + return auth_dir / TOKEN_FILENAME + + +def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]: + value = raw.strip() + if not value: + return None, None + try: + url = urllib.parse.urlparse(value) + qs = urllib.parse.parse_qs(url.query) + code = qs.get("code", [None])[0] + state = qs.get("state", [None])[0] + if code: + return code, state + except Exception: + pass + + if "#" in value: + parts = value.split("#", 1) + return parts[0] or None, parts[1] or None + + if "code=" in value: + qs = urllib.parse.parse_qs(value) + return qs.get("code", [None])[0], qs.get("state", [None])[0] + + return value, None + + +def _decode_account_id(access_token: str) -> str: + parts = access_token.split(".") + if len(parts) != 3: + raise ValueError("Invalid JWT token") + payload = json.loads(_decode_base64url(parts[1]).decode("utf-8")) + auth = payload.get(JWT_CLAIM_PATH) or {} + account_id = auth.get("chatgpt_account_id") + if not account_id: + raise ValueError("Failed to extract account_id from token") + return str(account_id) + + +class _OAuthHandler(BaseHTTPRequestHandler): + """Local callback HTTP handler.""" + + server_version = "NanobotOAuth/1.0" + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 + try: + url = urllib.parse.urlparse(self.path) + if url.path != "/auth/callback": + self.send_response(404) + self.end_headers() + self.wfile.write(b"Not found") + return + + qs = urllib.parse.parse_qs(url.query) + code = qs.get("code", [None])[0] + state = qs.get("state", [None])[0] + + if state != self.server.expected_state: + self.send_response(400) + self.end_headers() + self.wfile.write(b"State mismatch") + return + + if not code: + self.send_response(400) + self.end_headers() + self.wfile.write(b"Missing code") + return + + self.server.code = code + try: + if getattr(self.server, "on_code", None): + self.server.on_code(code) + except Exception: + pass + body = SUCCESS_HTML.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + try: + self.wfile.flush() + except Exception: + pass + self.close_connection = True + except Exception: + self.send_response(500) + self.end_headers() + self.wfile.write(b"Internal error") + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + # Suppress default logs to avoid noisy output. + return + + +class _OAuthServer(HTTPServer): + """OAuth callback server with state.""" + + def __init__( + self, + server_address: tuple[str, int], + expected_state: str, + on_code: Callable[[str], None] | None = None, + ): + super().__init__(server_address, _OAuthHandler) + self.expected_state = expected_state + self.code: str | None = None + self.on_code = on_code + + +def _start_local_server( + state: str, + on_code: Callable[[str], None] | None = None, +) -> tuple[_OAuthServer | None, str | None]: + """Start a local OAuth callback server on the first available localhost address.""" + try: + addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM) + except OSError as exc: + return None, f"Failed to resolve localhost: {exc}" + + last_error: OSError | None = None + for family, _socktype, _proto, _canonname, sockaddr in addrinfos: + try: + # 兼容 IPv4/IPv6 监听,避免 localhost 解析到 ::1 时收不到回调 + class _AddrOAuthServer(_OAuthServer): + address_family = family + + server = _AddrOAuthServer(sockaddr, state, on_code=on_code) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, None + except OSError as exc: + last_error = exc + continue + + if last_error: + return None, f"Local callback server failed to start: {last_error}" + return None, "Local callback server failed to start: unknown error" + + +def _exchange_code_for_token(code: str, verifier: str) -> CodexToken: + data = { + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "code": code, + "code_verifier": verifier, + "redirect_uri": REDIRECT_URI, + } + with httpx.Client(timeout=30.0) as client: + response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + if response.status_code != 200: + raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") + + payload = response.json() + access = payload.get("access_token") + refresh = payload.get("refresh_token") + expires_in = payload.get("expires_in") + if not access or not refresh or not isinstance(expires_in, int): + raise RuntimeError("Token response missing fields") + print("Received access token:", access) + account_id = _decode_account_id(access) + return CodexToken( + access=access, + refresh=refresh, + expires=int(time.time() * 1000 + expires_in * 1000), + account_id=account_id, + ) + + +async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken: + data = { + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "code": code, + "code_verifier": verifier, + "redirect_uri": REDIRECT_URI, + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + TOKEN_URL, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != 200: + raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") + + payload = response.json() + access = payload.get("access_token") + refresh = payload.get("refresh_token") + expires_in = payload.get("expires_in") + if not access or not refresh or not isinstance(expires_in, int): + raise RuntimeError("Token response missing fields") + + account_id = _decode_account_id(access) + return CodexToken( + access=access, + refresh=refresh, + expires=int(time.time() * 1000 + expires_in * 1000), + account_id=account_id, + ) + + +def _refresh_token(refresh_token: str) -> CodexToken: + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + } + with httpx.Client(timeout=30.0) as client: + response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + if response.status_code != 200: + raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}") + + payload = response.json() + access = payload.get("access_token") + refresh = payload.get("refresh_token") + expires_in = payload.get("expires_in") + if not access or not refresh or not isinstance(expires_in, int): + raise RuntimeError("Token refresh response missing fields") + + account_id = _decode_account_id(access) + return CodexToken( + access=access, + refresh=refresh, + expires=int(time.time() * 1000 + expires_in * 1000), + account_id=account_id, + ) + + +def _load_token_file() -> CodexToken | None: + path = _get_token_path() + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return CodexToken( + access=data["access"], + refresh=data["refresh"], + expires=int(data["expires"]), + account_id=data["account_id"], + ) + except Exception: + return None + + +def _save_token_file(token: CodexToken) -> None: + path = _get_token_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "access": token.access, + "refresh": token.refresh, + "expires": token.expires, + "account_id": token.account_id, + }, + ensure_ascii=True, + indent=2, + ), + encoding="utf-8", + ) + try: + os.chmod(path, 0o600) + except Exception: + # Ignore permission setting failures. + pass + + +def _try_import_codex_cli_token() -> CodexToken | None: + codex_path = Path.home() / ".codex" / "auth.json" + if not codex_path.exists(): + return None + try: + data = json.loads(codex_path.read_text(encoding="utf-8")) + tokens = data.get("tokens") or {} + access = tokens.get("access_token") + refresh = tokens.get("refresh_token") + account_id = tokens.get("account_id") + if not access or not refresh or not account_id: + return None + try: + mtime = codex_path.stat().st_mtime + expires = int(mtime * 1000 + 60 * 60 * 1000) + except Exception: + expires = int(time.time() * 1000 + 60 * 60 * 1000) + token = CodexToken( + access=str(access), + refresh=str(refresh), + expires=expires, + account_id=str(account_id), + ) + _save_token_file(token) + return token + except Exception: + return None + + +class _FileLock: + """Simple file lock to reduce concurrent refreshes.""" + + def __init__(self, path: Path): + self._path = path + self._fp = None + + def __enter__(self) -> "_FileLock": + self._path.parent.mkdir(parents=True, exist_ok=True) + self._fp = open(self._path, "a+") + try: + import fcntl + + fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX) + except Exception: + # Non-POSIX or failed lock: continue without locking. + pass + return self + + def __exit__(self, exc_type, exc, tb) -> None: + try: + import fcntl + + fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN) + except Exception: + pass + try: + if self._fp: + self._fp.close() + except Exception: + pass + + +def get_codex_token() -> CodexToken: + """Get an available token (refresh if needed).""" + token = _load_token_file() or _try_import_codex_cli_token() + if not token: + raise RuntimeError("Codex OAuth credentials not found. Please run the login command.") + + # Refresh 60 seconds early. + now_ms = int(time.time() * 1000) + if token.expires - now_ms > 60 * 1000: + return token + + lock_path = _get_token_path().with_suffix(".lock") + with _FileLock(lock_path): + # Re-read to avoid stale token if another process refreshed it. + token = _load_token_file() or token + now_ms = int(time.time() * 1000) + if token.expires - now_ms > 60 * 1000: + return token + try: + refreshed = _refresh_token(token.refresh) + _save_token_file(refreshed) + return refreshed + except Exception: + # If refresh fails, re-read the file to avoid false negatives. + latest = _load_token_file() + if latest and latest.expires - now_ms > 0: + return latest + raise + + +def ensure_codex_token_available() -> None: + """Ensure a valid token is available; raise if not.""" + _ = get_codex_token() + + +async def _read_stdin_line() -> str: + loop = asyncio.get_running_loop() + if hasattr(loop, "add_reader") and sys.stdin: + future: asyncio.Future[str] = loop.create_future() + + def _on_readable() -> None: + line = sys.stdin.readline() + if not future.done(): + future.set_result(line) + + try: + loop.add_reader(sys.stdin, _on_readable) + except Exception: + return await loop.run_in_executor(None, sys.stdin.readline) + + try: + return await future + finally: + try: + loop.remove_reader(sys.stdin) + except Exception: + pass + + return await loop.run_in_executor(None, sys.stdin.readline) + + +async def _await_manual_input( + on_manual_code_input: Callable[[str], None], +) -> str: + await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC) + on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:") + return await _read_stdin_line() + + +def login_codex_oauth_interactive( + on_auth: Callable[[str], None] | None = None, + on_prompt: Callable[[str], str] | None = None, + on_status: Callable[[str], None] | None = None, + on_progress: Callable[[str], None] | None = None, + on_manual_code_input: Callable[[str], None] = None, + originator: str = DEFAULT_ORIGINATOR, +) -> CodexToken: + """Interactive login flow.""" + async def _login_async() -> CodexToken: + verifier, challenge = _generate_pkce() + state = _create_state() + + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": SCOPE, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + "originator": originator, + } + url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" + + loop = asyncio.get_running_loop() + code_future: asyncio.Future[str] = loop.create_future() + + def _notify(code_value: str) -> None: + if code_future.done(): + return + loop.call_soon_threadsafe(code_future.set_result, code_value) + + server, server_error = _start_local_server(state, on_code=_notify) + if on_auth: + on_auth(url) + else: + webbrowser.open(url) + + if not server and server_error and on_status: + on_status( + f"Local callback server could not start ({server_error}). " + "You will need to paste the callback URL or authorization code." + ) + + code: str | None = None + try: + if server: + if on_progress and not on_manual_code_input: + on_progress("Waiting for browser callback...") + + tasks: list[asyncio.Task[Any]] = [] + callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120)) + tasks.append(callback_task) + manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input)) + tasks.append(manual_task) + + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + + for task in done: + try: + result = task.result() + except asyncio.TimeoutError: + result = None + if not result: + continue + if task is manual_task: + parsed_code, parsed_state = _parse_authorization_input(result) + if parsed_state and parsed_state != state: + raise RuntimeError("State validation failed.") + code = parsed_code + else: + code = result + if code: + break + + if not code: + prompt = "Please paste the callback URL or authorization code:" + if on_prompt: + raw = await loop.run_in_executor(None, on_prompt, prompt) + else: + raw = await loop.run_in_executor(None, input, prompt) + parsed_code, parsed_state = _parse_authorization_input(raw) + if parsed_state and parsed_state != state: + raise RuntimeError("State validation failed.") + code = parsed_code + + if not code: + raise RuntimeError("Authorization code not found.") + + if on_progress: + on_progress("Exchanging authorization code for tokens...") + token = await _exchange_code_for_token_async(code, verifier) + _save_token_file(token) + return token + finally: + if server: + server.shutdown() + server.server_close() + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(_login_async()) + + result: list[CodexToken] = [] + error: list[Exception] = [] + + def _runner() -> None: + try: + result.append(asyncio.run(_login_async())) + except Exception as exc: + error.append(exc) + + thread = threading.Thread(target=_runner) + thread.start() + thread.join() + if error: + raise error[0] + return result[0] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c2241fb..213f8c5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -73,6 +73,49 @@ def onboard(): console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") +@app.command("login") +def login( + provider: str = typer.Option("openai-codex", "--provider", "-p", help="Auth provider"), +): + """Login to an auth provider (e.g. openai-codex).""" + if provider != "openai-codex": + console.print(f"[red]Unsupported provider: {provider}[/red]") + raise typer.Exit(1) + + from nanobot.auth.codex_oauth import login_codex_oauth_interactive + + def on_auth(url: str) -> None: + console.print("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]") + console.print(url) + try: + import webbrowser + webbrowser.open(url) + except Exception: + pass + + def on_status(message: str) -> None: + console.print(f"[yellow]{message}[/yellow]") + + def on_progress(message: str) -> None: + console.print(f"[dim]{message}[/dim]") + + def on_prompt(message: str) -> str: + return typer.prompt(message) + + def on_manual_code_input(message: str) -> None: + console.print(f"[cyan]{message}[/cyan]") + + console.print("[green]Starting OpenAI Codex OAuth login...[/green]") + login_codex_oauth_interactive( + on_auth=on_auth, + on_prompt=on_prompt, + on_status=on_status, + on_progress=on_progress, + on_manual_code_input=on_manual_code_input, + ) + console.print("[green]✓ Login successful. Credentials saved.[/green]") + + def _create_workspace_templates(workspace: Path): @@ -161,6 +204,8 @@ def gateway( from nanobot.config.loader import load_config, get_data_dir from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from nanobot.auth.codex_oauth import ensure_codex_token_available from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService @@ -183,17 +228,26 @@ def gateway( api_base = config.get_api_base() model = config.agents.defaults.model is_bedrock = model.startswith("bedrock/") + is_codex = model.startswith("openai-codex/") - if not api_key and not is_bedrock: - console.print("[red]Error: No API key configured.[/red]") - console.print("Set one in ~/.nanobot/config.json under providers.openrouter.apiKey") - raise typer.Exit(1) - - provider = LiteLLMProvider( - api_key=api_key, - api_base=api_base, - default_model=config.agents.defaults.model - ) + if is_codex: + try: + ensure_codex_token_available() + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") + raise typer.Exit(1) + provider = OpenAICodexProvider(default_model=model) + else: + if not api_key and not is_bedrock: + console.print("[red]Error: No API key configured.[/red]") + console.print("Set one in ~/.nanobot/config.json under providers.openrouter.apiKey") + raise typer.Exit(1) + provider = LiteLLMProvider( + api_key=api_key, + api_base=api_base, + default_model=config.agents.defaults.model + ) # Create agent agent = AgentLoop( @@ -286,6 +340,8 @@ def agent( from nanobot.config.loader import load_config from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from nanobot.auth.codex_oauth import ensure_codex_token_available from nanobot.agent.loop import AgentLoop config = load_config() @@ -294,17 +350,29 @@ def agent( api_base = config.get_api_base() model = config.agents.defaults.model is_bedrock = model.startswith("bedrock/") + is_codex = model.startswith("openai-codex/") - if not api_key and not is_bedrock: - console.print("[red]Error: No API key configured.[/red]") - raise typer.Exit(1) + if is_codex: + try: + ensure_codex_token_available() + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") + raise typer.Exit(1) + else: + if not api_key and not is_bedrock: + console.print("[red]Error: No API key configured.[/red]") + raise typer.Exit(1) bus = MessageBus() - provider = LiteLLMProvider( - api_key=api_key, - api_base=api_base, - default_model=config.agents.defaults.model - ) + if is_codex: + provider = OpenAICodexProvider(default_model=config.agents.defaults.model) + else: + provider = LiteLLMProvider( + api_key=api_key, + api_base=api_base, + default_model=config.agents.defaults.model + ) agent_loop = AgentLoop( bus=bus, diff --git a/nanobot/providers/__init__.py b/nanobot/providers/__init__.py index ceff8fa..b2bb2b9 100644 --- a/nanobot/providers/__init__.py +++ b/nanobot/providers/__init__.py @@ -2,5 +2,6 @@ from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.litellm_provider import LiteLLMProvider +from nanobot.providers.openai_codex_provider import OpenAICodexProvider -__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"] +__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider"] diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py new file mode 100644 index 0000000..2081180 --- /dev/null +++ b/nanobot/providers/openai_codex_provider.py @@ -0,0 +1,333 @@ +"""OpenAI Codex Responses Provider。""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from typing import Any, AsyncGenerator + +import httpx + +from nanobot.auth.codex_oauth import get_codex_token +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest + +DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" +DEFAULT_ORIGINATOR = "nanobot" + + +class OpenAICodexProvider(LLMProvider): + """使用 Codex OAuth 调用 Responses 接口。""" + + def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"): + super().__init__(api_key=None, api_base=None) + self.default_model = default_model + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + ) -> LLMResponse: + model = model or self.default_model + system_prompt, input_items = _convert_messages(messages) + + token = await asyncio.to_thread(get_codex_token) + headers = _build_headers(token.account_id, token.access) + + body: dict[str, Any] = { + "model": _strip_model_prefix(model), + "store": False, + "stream": True, + "instructions": system_prompt, + "input": input_items, + "text": {"verbosity": "medium"}, + "include": ["reasoning.encrypted_content"], + "prompt_cache_key": _prompt_cache_key(messages), + "tool_choice": "auto", + "parallel_tool_calls": True, + } + + if tools: + body["tools"] = _convert_tools(tools) + + url = _resolve_codex_url(DEFAULT_CODEX_BASE_URL) + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + try: + async with client.stream("POST", url, headers=headers, json=body) as response: + if response.status_code != 200: + text = await response.aread() + raise RuntimeError( + _friendly_error(response.status_code, text.decode("utf-8", "ignore")) + ) + content, tool_calls, finish_reason = await _consume_sse(response) + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason, + ) + except Exception as e: + # 证书校验失败时降级关闭校验(存在安全风险) + if "CERTIFICATE_VERIFY_FAILED" not in str(e): + raise + async with httpx.AsyncClient(timeout=60.0, verify=False) as insecure_client: + async with insecure_client.stream("POST", url, headers=headers, json=body) as response: + if response.status_code != 200: + text = await response.aread() + raise RuntimeError( + _friendly_error(response.status_code, text.decode("utf-8", "ignore")) + ) + content, tool_calls, finish_reason = await _consume_sse(response) + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason, + ) + except Exception as e: + return LLMResponse( + content=f"Error calling Codex: {str(e)}", + finish_reason="error", + ) + + def get_default_model(self) -> str: + return self.default_model + + +def _strip_model_prefix(model: str) -> str: + if model.startswith("openai-codex/"): + return model.split("/", 1)[1] + return model + + +def _resolve_codex_url(base_url: str) -> str: + raw = base_url.rstrip("/") + if raw.endswith("/codex/responses"): + return raw + if raw.endswith("/codex"): + return f"{raw}/responses" + return f"{raw}/codex/responses" + + +def _build_headers(account_id: str, token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "chatgpt-account-id": account_id, + "OpenAI-Beta": "responses=experimental", + "originator": DEFAULT_ORIGINATOR, + "User-Agent": "nanobot (python)", + "accept": "text/event-stream", + "content-type": "application/json", + } + + +def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + # nanobot 工具定义已是 OpenAI function schema + converted: list[dict[str, Any]] = [] + for tool in tools: + name = tool.get("name") + if not isinstance(name, str) or not name: + # 忽略无效工具,避免被 Codex 拒绝 + continue + params = tool.get("parameters") or {} + if not isinstance(params, dict): + # 参数必须是 JSON Schema 对象 + params = {} + converted.append( + { + "type": "function", + "name": name, + "description": tool.get("description") or "", + "parameters": params, + } + ) + return converted + + +def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: + system_prompt = "" + input_items: list[dict[str, Any]] = [] + + for idx, msg in enumerate(messages): + role = msg.get("role") + content = msg.get("content") + + if role == "system": + system_prompt = content if isinstance(content, str) else "" + continue + + if role == "user": + input_items.append(_convert_user_message(content)) + continue + + if role == "assistant": + # 先处理文本 + if isinstance(content, str) and content: + input_items.append( + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": content}], + "status": "completed", + "id": f"msg_{idx}", + } + ) + # 再处理工具调用 + for tool_call in msg.get("tool_calls", []) or []: + fn = tool_call.get("function") or {} + call_id = tool_call.get("id") or f"call_{idx}" + item_id = f"fc_{idx}" + input_items.append( + { + "type": "function_call", + "id": item_id, + "call_id": call_id, + "name": fn.get("name"), + "arguments": fn.get("arguments") or "{}", + } + ) + continue + + if role == "tool": + call_id = _extract_call_id(msg.get("tool_call_id")) + output_text = content if isinstance(content, str) else json.dumps(content) + input_items.append( + { + "type": "function_call_output", + "call_id": call_id, + "output": output_text, + } + ) + continue + + return system_prompt, input_items + + +def _convert_user_message(content: Any) -> dict[str, Any]: + if isinstance(content, str): + return {"role": "user", "content": [{"type": "input_text", "text": content}]} + if isinstance(content, list): + converted: list[dict[str, Any]] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "text": + converted.append({"type": "input_text", "text": item.get("text", "")}) + elif item.get("type") == "image_url": + url = (item.get("image_url") or {}).get("url") + if url: + converted.append({"type": "input_image", "image_url": url, "detail": "auto"}) + if converted: + return {"role": "user", "content": converted} + return {"role": "user", "content": [{"type": "input_text", "text": ""}]} + + +def _extract_call_id(tool_call_id: Any) -> str: + if isinstance(tool_call_id, str) and tool_call_id: + return tool_call_id.split("|", 1)[0] + return "call_0" + + +def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: + raw = json.dumps(messages, ensure_ascii=True, sort_keys=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]: + buffer: list[str] = [] + async for line in response.aiter_lines(): + if line == "": + if buffer: + data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")] + buffer = [] + if not data_lines: + continue + data = "\n".join(data_lines).strip() + if not data or data == "[DONE]": + continue + try: + yield json.loads(data) + except Exception: + continue + continue + buffer.append(line) + + + + +async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]: + content = "" + tool_calls: list[ToolCallRequest] = [] + tool_call_buffers: dict[str, dict[str, Any]] = {} + finish_reason = "stop" + + async for event in _iter_sse(response): + event_type = event.get("type") + if event_type == "response.output_item.added": + item = event.get("item") or {} + if item.get("type") == "function_call": + call_id = item.get("call_id") + if not call_id: + continue + tool_call_buffers[call_id] = { + "id": item.get("id") or "fc_0", + "name": item.get("name"), + "arguments": item.get("arguments") or "", + } + elif event_type == "response.output_text.delta": + content += event.get("delta") or "" + elif event_type == "response.function_call_arguments.delta": + call_id = event.get("call_id") + if call_id and call_id in tool_call_buffers: + tool_call_buffers[call_id]["arguments"] += event.get("delta") or "" + elif event_type == "response.function_call_arguments.done": + call_id = event.get("call_id") + if call_id and call_id in tool_call_buffers: + tool_call_buffers[call_id]["arguments"] = event.get("arguments") or "" + elif event_type == "response.output_item.done": + item = event.get("item") or {} + if item.get("type") == "function_call": + call_id = item.get("call_id") + if not call_id: + continue + buf = tool_call_buffers.get(call_id) or {} + args_raw = buf.get("arguments") or item.get("arguments") or "{}" + try: + args = json.loads(args_raw) + except Exception: + args = {"raw": args_raw} + tool_calls.append( + ToolCallRequest( + id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}", + name=buf.get("name") or item.get("name"), + arguments=args, + ) + ) + elif event_type == "response.completed": + status = (event.get("response") or {}).get("status") + finish_reason = _map_finish_reason(status) + elif event_type in {"error", "response.failed"}: + raise RuntimeError("Codex response failed") + + return content, tool_calls, finish_reason + + +def _map_finish_reason(status: str | None) -> str: + if not status: + return "stop" + if status == "completed": + return "stop" + if status == "incomplete": + return "length" + if status in {"failed", "cancelled"}: + return "error" + return "stop" + + +def _friendly_error(status_code: int, raw: str) -> str: + if status_code == 429: + return "ChatGPT 使用额度已达上限或触发限流,请稍后再试。" + return f"HTTP {status_code}: {raw}" From d4e65319eeba616caaf9564abe83a62ebf51435e Mon Sep 17 00:00:00 2001 From: qiupinhua Date: Thu, 5 Feb 2026 17:53:00 +0800 Subject: [PATCH 002/415] refactor: split codex oauth logic to several files --- nanobot/auth/__init__.py | 4 +- nanobot/auth/codex/__init__.py | 15 + nanobot/auth/codex/constants.py | 25 + nanobot/auth/codex/flow.py | 312 +++++++++++ nanobot/auth/codex/models.py | 15 + nanobot/auth/codex/pkce.py | 77 +++ nanobot/auth/codex/server.py | 115 ++++ nanobot/auth/codex/storage.py | 118 ++++ nanobot/auth/codex_oauth.py | 607 --------------------- nanobot/cli/commands.py | 6 +- nanobot/providers/openai_codex_provider.py | 75 ++- 11 files changed, 717 insertions(+), 652 deletions(-) create mode 100644 nanobot/auth/codex/__init__.py create mode 100644 nanobot/auth/codex/constants.py create mode 100644 nanobot/auth/codex/flow.py create mode 100644 nanobot/auth/codex/models.py create mode 100644 nanobot/auth/codex/pkce.py create mode 100644 nanobot/auth/codex/server.py create mode 100644 nanobot/auth/codex/storage.py delete mode 100644 nanobot/auth/codex_oauth.py diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py index e74e1c2..c74d992 100644 --- a/nanobot/auth/__init__.py +++ b/nanobot/auth/__init__.py @@ -1,6 +1,6 @@ -"""鉴权相关模块。""" +"""Authentication modules.""" -from nanobot.auth.codex_oauth import ( +from nanobot.auth.codex import ( ensure_codex_token_available, get_codex_token, login_codex_oauth_interactive, diff --git a/nanobot/auth/codex/__init__.py b/nanobot/auth/codex/__init__.py new file mode 100644 index 0000000..707cd4d --- /dev/null +++ b/nanobot/auth/codex/__init__.py @@ -0,0 +1,15 @@ +"""Codex OAuth module.""" + +from nanobot.auth.codex.flow import ( + ensure_codex_token_available, + get_codex_token, + login_codex_oauth_interactive, +) +from nanobot.auth.codex.models import CodexToken + +__all__ = [ + "CodexToken", + "ensure_codex_token_available", + "get_codex_token", + "login_codex_oauth_interactive", +] diff --git a/nanobot/auth/codex/constants.py b/nanobot/auth/codex/constants.py new file mode 100644 index 0000000..bbe676a --- /dev/null +++ b/nanobot/auth/codex/constants.py @@ -0,0 +1,25 @@ +"""Codex OAuth constants.""" + +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" +TOKEN_URL = "https://auth.openai.com/oauth/token" +REDIRECT_URI = "http://localhost:1455/auth/callback" +SCOPE = "openid profile email offline_access" +JWT_CLAIM_PATH = "https://api.openai.com/auth" + +DEFAULT_ORIGINATOR = "nanobot" +TOKEN_FILENAME = "codex.json" +MANUAL_PROMPT_DELAY_SEC = 3 +SUCCESS_HTML = ( + "" + "" + "" + "" + "" + "Authentication successful" + "" + "" + "

Authentication successful. Return to your terminal to continue.

" + "" + "" +) diff --git a/nanobot/auth/codex/flow.py b/nanobot/auth/codex/flow.py new file mode 100644 index 0000000..0966327 --- /dev/null +++ b/nanobot/auth/codex/flow.py @@ -0,0 +1,312 @@ +"""Codex OAuth login and token management.""" + +from __future__ import annotations + +import asyncio +import sys +import threading +import time +import urllib.parse +import webbrowser +from typing import Any, Callable + +import httpx + +from nanobot.auth.codex.constants import ( + AUTHORIZE_URL, + CLIENT_ID, + DEFAULT_ORIGINATOR, + MANUAL_PROMPT_DELAY_SEC, + REDIRECT_URI, + SCOPE, + TOKEN_URL, +) +from nanobot.auth.codex.models import CodexToken +from nanobot.auth.codex.pkce import ( + _create_state, + _decode_account_id, + _generate_pkce, + _parse_authorization_input, + _parse_token_payload, +) +from nanobot.auth.codex.server import _start_local_server +from nanobot.auth.codex.storage import ( + _FileLock, + _get_token_path, + _load_token_file, + _save_token_file, + _try_import_codex_cli_token, +) + + +def _exchange_code_for_token(code: str, verifier: str) -> CodexToken: + data = { + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "code": code, + "code_verifier": verifier, + "redirect_uri": REDIRECT_URI, + } + with httpx.Client(timeout=30.0) as client: + response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + if response.status_code != 200: + raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") + + payload = response.json() + access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields") + print("Received access token:", access) + account_id = _decode_account_id(access) + return CodexToken( + access=access, + refresh=refresh, + expires=int(time.time() * 1000 + expires_in * 1000), + account_id=account_id, + ) + + +async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken: + data = { + "grant_type": "authorization_code", + "client_id": CLIENT_ID, + "code": code, + "code_verifier": verifier, + "redirect_uri": REDIRECT_URI, + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + TOKEN_URL, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != 200: + raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") + + payload = response.json() + access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields") + + account_id = _decode_account_id(access) + return CodexToken( + access=access, + refresh=refresh, + expires=int(time.time() * 1000 + expires_in * 1000), + account_id=account_id, + ) + + +def _refresh_token(refresh_token: str) -> CodexToken: + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + } + with httpx.Client(timeout=30.0) as client: + response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + if response.status_code != 200: + raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}") + + payload = response.json() + access, refresh, expires_in = _parse_token_payload(payload, "Token refresh response missing fields") + + account_id = _decode_account_id(access) + return CodexToken( + access=access, + refresh=refresh, + expires=int(time.time() * 1000 + expires_in * 1000), + account_id=account_id, + ) + + +def get_codex_token() -> CodexToken: + """Get an available token (refresh if needed).""" + token = _load_token_file() or _try_import_codex_cli_token() + if not token: + raise RuntimeError("Codex OAuth credentials not found. Please run the login command.") + + # Refresh 60 seconds early. + now_ms = int(time.time() * 1000) + if token.expires - now_ms > 60 * 1000: + return token + + lock_path = _get_token_path().with_suffix(".lock") + with _FileLock(lock_path): + # Re-read to avoid stale token if another process refreshed it. + token = _load_token_file() or token + now_ms = int(time.time() * 1000) + if token.expires - now_ms > 60 * 1000: + return token + try: + refreshed = _refresh_token(token.refresh) + _save_token_file(refreshed) + return refreshed + except Exception: + # If refresh fails, re-read the file to avoid false negatives. + latest = _load_token_file() + if latest and latest.expires - now_ms > 0: + return latest + raise + + +def ensure_codex_token_available() -> None: + """Ensure a valid token is available; raise if not.""" + _ = get_codex_token() + + +async def _read_stdin_line() -> str: + loop = asyncio.get_running_loop() + if hasattr(loop, "add_reader") and sys.stdin: + future: asyncio.Future[str] = loop.create_future() + + def _on_readable() -> None: + line = sys.stdin.readline() + if not future.done(): + future.set_result(line) + + try: + loop.add_reader(sys.stdin, _on_readable) + except Exception: + return await loop.run_in_executor(None, sys.stdin.readline) + + try: + return await future + finally: + try: + loop.remove_reader(sys.stdin) + except Exception: + pass + + return await loop.run_in_executor(None, sys.stdin.readline) + + +async def _await_manual_input( + on_manual_code_input: Callable[[str], None], +) -> str: + await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC) + on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:") + return await _read_stdin_line() + + +def login_codex_oauth_interactive( + on_auth: Callable[[str], None] | None = None, + on_prompt: Callable[[str], str] | None = None, + on_status: Callable[[str], None] | None = None, + on_progress: Callable[[str], None] | None = None, + on_manual_code_input: Callable[[str], None] = None, + originator: str = DEFAULT_ORIGINATOR, +) -> CodexToken: + """Interactive login flow.""" + + async def _login_async() -> CodexToken: + verifier, challenge = _generate_pkce() + state = _create_state() + + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": SCOPE, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + "originator": originator, + } + url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" + + loop = asyncio.get_running_loop() + code_future: asyncio.Future[str] = loop.create_future() + + def _notify(code_value: str) -> None: + if code_future.done(): + return + loop.call_soon_threadsafe(code_future.set_result, code_value) + + server, server_error = _start_local_server(state, on_code=_notify) + if on_auth: + on_auth(url) + else: + webbrowser.open(url) + + if not server and server_error and on_status: + on_status( + f"Local callback server could not start ({server_error}). " + "You will need to paste the callback URL or authorization code." + ) + + code: str | None = None + try: + if server: + if on_progress and not on_manual_code_input: + on_progress("Waiting for browser callback...") + + tasks: list[asyncio.Task[Any]] = [] + callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120)) + tasks.append(callback_task) + manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input)) + tasks.append(manual_task) + + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + + for task in done: + try: + result = task.result() + except asyncio.TimeoutError: + result = None + if not result: + continue + if task is manual_task: + parsed_code, parsed_state = _parse_authorization_input(result) + if parsed_state and parsed_state != state: + raise RuntimeError("State validation failed.") + code = parsed_code + else: + code = result + if code: + break + + if not code: + prompt = "Please paste the callback URL or authorization code:" + if on_prompt: + raw = await loop.run_in_executor(None, on_prompt, prompt) + else: + raw = await loop.run_in_executor(None, input, prompt) + parsed_code, parsed_state = _parse_authorization_input(raw) + if parsed_state and parsed_state != state: + raise RuntimeError("State validation failed.") + code = parsed_code + + if not code: + raise RuntimeError("Authorization code not found.") + + if on_progress: + on_progress("Exchanging authorization code for tokens...") + token = await _exchange_code_for_token_async(code, verifier) + _save_token_file(token) + return token + finally: + if server: + server.shutdown() + server.server_close() + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(_login_async()) + + result: list[CodexToken] = [] + error: list[Exception] = [] + + def _runner() -> None: + try: + result.append(asyncio.run(_login_async())) + except Exception as exc: + error.append(exc) + + thread = threading.Thread(target=_runner) + thread.start() + thread.join() + if error: + raise error[0] + return result[0] diff --git a/nanobot/auth/codex/models.py b/nanobot/auth/codex/models.py new file mode 100644 index 0000000..e3a5f55 --- /dev/null +++ b/nanobot/auth/codex/models.py @@ -0,0 +1,15 @@ +"""Codex OAuth data models.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class CodexToken: + """Codex OAuth token data structure.""" + + access: str + refresh: str + expires: int + account_id: str diff --git a/nanobot/auth/codex/pkce.py b/nanobot/auth/codex/pkce.py new file mode 100644 index 0000000..b682386 --- /dev/null +++ b/nanobot/auth/codex/pkce.py @@ -0,0 +1,77 @@ +"""PKCE and authorization helpers.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import urllib.parse +from typing import Any + +from nanobot.auth.codex.constants import JWT_CLAIM_PATH + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") + + +def _decode_base64url(data: str) -> bytes: + padding = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(data + padding) + + +def _generate_pkce() -> tuple[str, str]: + verifier = _base64url(os.urandom(32)) + challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest()) + return verifier, challenge + + +def _create_state() -> str: + return _base64url(os.urandom(16)) + + +def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]: + value = raw.strip() + if not value: + return None, None + try: + url = urllib.parse.urlparse(value) + qs = urllib.parse.parse_qs(url.query) + code = qs.get("code", [None])[0] + state = qs.get("state", [None])[0] + if code: + return code, state + except Exception: + pass + + if "#" in value: + parts = value.split("#", 1) + return parts[0] or None, parts[1] or None + + if "code=" in value: + qs = urllib.parse.parse_qs(value) + return qs.get("code", [None])[0], qs.get("state", [None])[0] + + return value, None + + +def _decode_account_id(access_token: str) -> str: + parts = access_token.split(".") + if len(parts) != 3: + raise ValueError("Invalid JWT token") + payload = json.loads(_decode_base64url(parts[1]).decode("utf-8")) + auth = payload.get(JWT_CLAIM_PATH) or {} + account_id = auth.get("chatgpt_account_id") + if not account_id: + raise ValueError("Failed to extract account_id from token") + return str(account_id) + + +def _parse_token_payload(payload: dict[str, Any], missing_message: str) -> tuple[str, str, int]: + access = payload.get("access_token") + refresh = payload.get("refresh_token") + expires_in = payload.get("expires_in") + if not access or not refresh or not isinstance(expires_in, int): + raise RuntimeError(missing_message) + return access, refresh, expires_in diff --git a/nanobot/auth/codex/server.py b/nanobot/auth/codex/server.py new file mode 100644 index 0000000..f31db19 --- /dev/null +++ b/nanobot/auth/codex/server.py @@ -0,0 +1,115 @@ +"""Local OAuth callback server.""" + +from __future__ import annotations + +import socket +import threading +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any, Callable + +from nanobot.auth.codex.constants import SUCCESS_HTML + + +class _OAuthHandler(BaseHTTPRequestHandler): + """Local callback HTTP handler.""" + + server_version = "NanobotOAuth/1.0" + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 + try: + url = urllib.parse.urlparse(self.path) + if url.path != "/auth/callback": + self.send_response(404) + self.end_headers() + self.wfile.write(b"Not found") + return + + qs = urllib.parse.parse_qs(url.query) + code = qs.get("code", [None])[0] + state = qs.get("state", [None])[0] + + if state != self.server.expected_state: + self.send_response(400) + self.end_headers() + self.wfile.write(b"State mismatch") + return + + if not code: + self.send_response(400) + self.end_headers() + self.wfile.write(b"Missing code") + return + + self.server.code = code + try: + if getattr(self.server, "on_code", None): + self.server.on_code(code) + except Exception: + pass + body = SUCCESS_HTML.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + try: + self.wfile.flush() + except Exception: + pass + self.close_connection = True + except Exception: + self.send_response(500) + self.end_headers() + self.wfile.write(b"Internal error") + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + # Suppress default logs to avoid noisy output. + return + + +class _OAuthServer(HTTPServer): + """OAuth callback server with state.""" + + def __init__( + self, + server_address: tuple[str, int], + expected_state: str, + on_code: Callable[[str], None] | None = None, + ): + super().__init__(server_address, _OAuthHandler) + self.expected_state = expected_state + self.code: str | None = None + self.on_code = on_code + + +def _start_local_server( + state: str, + on_code: Callable[[str], None] | None = None, +) -> tuple[_OAuthServer | None, str | None]: + """Start a local OAuth callback server on the first available localhost address.""" + try: + addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM) + except OSError as exc: + return None, f"Failed to resolve localhost: {exc}" + + last_error: OSError | None = None + for family, _socktype, _proto, _canonname, sockaddr in addrinfos: + try: + # Support IPv4/IPv6 to avoid missing callbacks when localhost resolves to ::1. + class _AddrOAuthServer(_OAuthServer): + address_family = family + + server = _AddrOAuthServer(sockaddr, state, on_code=on_code) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, None + except OSError as exc: + last_error = exc + continue + + if last_error: + return None, f"Local callback server failed to start: {last_error}" + return None, "Local callback server failed to start: unknown error" diff --git a/nanobot/auth/codex/storage.py b/nanobot/auth/codex/storage.py new file mode 100644 index 0000000..31e5e3d --- /dev/null +++ b/nanobot/auth/codex/storage.py @@ -0,0 +1,118 @@ +"""Token storage helpers.""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +from nanobot.auth.codex.constants import TOKEN_FILENAME +from nanobot.auth.codex.models import CodexToken +from nanobot.utils.helpers import ensure_dir, get_data_path + + +def _get_token_path() -> Path: + auth_dir = ensure_dir(get_data_path() / "auth") + return auth_dir / TOKEN_FILENAME + + +def _load_token_file() -> CodexToken | None: + path = _get_token_path() + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return CodexToken( + access=data["access"], + refresh=data["refresh"], + expires=int(data["expires"]), + account_id=data["account_id"], + ) + except Exception: + return None + + +def _save_token_file(token: CodexToken) -> None: + path = _get_token_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "access": token.access, + "refresh": token.refresh, + "expires": token.expires, + "account_id": token.account_id, + }, + ensure_ascii=True, + indent=2, + ), + encoding="utf-8", + ) + try: + os.chmod(path, 0o600) + except Exception: + # Ignore permission setting failures. + pass + + +def _try_import_codex_cli_token() -> CodexToken | None: + codex_path = Path.home() / ".codex" / "auth.json" + if not codex_path.exists(): + return None + try: + data = json.loads(codex_path.read_text(encoding="utf-8")) + tokens = data.get("tokens") or {} + access = tokens.get("access_token") + refresh = tokens.get("refresh_token") + account_id = tokens.get("account_id") + if not access or not refresh or not account_id: + return None + try: + mtime = codex_path.stat().st_mtime + expires = int(mtime * 1000 + 60 * 60 * 1000) + except Exception: + expires = int(time.time() * 1000 + 60 * 60 * 1000) + token = CodexToken( + access=str(access), + refresh=str(refresh), + expires=expires, + account_id=str(account_id), + ) + _save_token_file(token) + return token + except Exception: + return None + + +class _FileLock: + """Simple file lock to reduce concurrent refreshes.""" + + def __init__(self, path: Path): + self._path = path + self._fp = None + + def __enter__(self) -> "_FileLock": + self._path.parent.mkdir(parents=True, exist_ok=True) + self._fp = open(self._path, "a+") + try: + import fcntl + + fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX) + except Exception: + # Non-POSIX or failed lock: continue without locking. + pass + return self + + def __exit__(self, exc_type, exc, tb) -> None: + try: + import fcntl + + fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN) + except Exception: + pass + try: + if self._fp: + self._fp.close() + except Exception: + pass diff --git a/nanobot/auth/codex_oauth.py b/nanobot/auth/codex_oauth.py deleted file mode 100644 index 0784267..0000000 --- a/nanobot/auth/codex_oauth.py +++ /dev/null @@ -1,607 +0,0 @@ -"""OpenAI Codex OAuth implementation.""" - -from __future__ import annotations - -import asyncio -import base64 -import hashlib -import json -import os -import socket -import sys -import threading -import time -import urllib.parse -import webbrowser -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path -from typing import Any, Callable - -import httpx - -from nanobot.utils.helpers import ensure_dir, get_data_path - -# Fixed parameters (sourced from the official Codex CLI OAuth client). -CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" -AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" -TOKEN_URL = "https://auth.openai.com/oauth/token" -REDIRECT_URI = "http://localhost:1455/auth/callback" -SCOPE = "openid profile email offline_access" -JWT_CLAIM_PATH = "https://api.openai.com/auth" - -DEFAULT_ORIGINATOR = "nanobot" -TOKEN_FILENAME = "codex.json" -MANUAL_PROMPT_DELAY_SEC = 3 -SUCCESS_HTML = ( - "" - "" - "" - "" - "" - "Authentication successful" - "" - "" - "

Authentication successful. Return to your terminal to continue.

" - "" - "" -) - - -@dataclass -class CodexToken: - """Codex OAuth token data structure.""" - access: str - refresh: str - expires: int - account_id: str - - -def _base64url(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") - - -def _decode_base64url(data: str) -> bytes: - padding = "=" * (-len(data) % 4) - return base64.urlsafe_b64decode(data + padding) - - -def _generate_pkce() -> tuple[str, str]: - verifier = _base64url(os.urandom(32)) - challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest()) - return verifier, challenge - - -def _create_state() -> str: - return _base64url(os.urandom(16)) - - -def _get_token_path() -> Path: - auth_dir = ensure_dir(get_data_path() / "auth") - return auth_dir / TOKEN_FILENAME - - -def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]: - value = raw.strip() - if not value: - return None, None - try: - url = urllib.parse.urlparse(value) - qs = urllib.parse.parse_qs(url.query) - code = qs.get("code", [None])[0] - state = qs.get("state", [None])[0] - if code: - return code, state - except Exception: - pass - - if "#" in value: - parts = value.split("#", 1) - return parts[0] or None, parts[1] or None - - if "code=" in value: - qs = urllib.parse.parse_qs(value) - return qs.get("code", [None])[0], qs.get("state", [None])[0] - - return value, None - - -def _decode_account_id(access_token: str) -> str: - parts = access_token.split(".") - if len(parts) != 3: - raise ValueError("Invalid JWT token") - payload = json.loads(_decode_base64url(parts[1]).decode("utf-8")) - auth = payload.get(JWT_CLAIM_PATH) or {} - account_id = auth.get("chatgpt_account_id") - if not account_id: - raise ValueError("Failed to extract account_id from token") - return str(account_id) - - -class _OAuthHandler(BaseHTTPRequestHandler): - """Local callback HTTP handler.""" - - server_version = "NanobotOAuth/1.0" - protocol_version = "HTTP/1.1" - - def do_GET(self) -> None: # noqa: N802 - try: - url = urllib.parse.urlparse(self.path) - if url.path != "/auth/callback": - self.send_response(404) - self.end_headers() - self.wfile.write(b"Not found") - return - - qs = urllib.parse.parse_qs(url.query) - code = qs.get("code", [None])[0] - state = qs.get("state", [None])[0] - - if state != self.server.expected_state: - self.send_response(400) - self.end_headers() - self.wfile.write(b"State mismatch") - return - - if not code: - self.send_response(400) - self.end_headers() - self.wfile.write(b"Missing code") - return - - self.server.code = code - try: - if getattr(self.server, "on_code", None): - self.server.on_code(code) - except Exception: - pass - body = SUCCESS_HTML.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - try: - self.wfile.flush() - except Exception: - pass - self.close_connection = True - except Exception: - self.send_response(500) - self.end_headers() - self.wfile.write(b"Internal error") - - def log_message(self, format: str, *args: Any) -> None: # noqa: A003 - # Suppress default logs to avoid noisy output. - return - - -class _OAuthServer(HTTPServer): - """OAuth callback server with state.""" - - def __init__( - self, - server_address: tuple[str, int], - expected_state: str, - on_code: Callable[[str], None] | None = None, - ): - super().__init__(server_address, _OAuthHandler) - self.expected_state = expected_state - self.code: str | None = None - self.on_code = on_code - - -def _start_local_server( - state: str, - on_code: Callable[[str], None] | None = None, -) -> tuple[_OAuthServer | None, str | None]: - """Start a local OAuth callback server on the first available localhost address.""" - try: - addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM) - except OSError as exc: - return None, f"Failed to resolve localhost: {exc}" - - last_error: OSError | None = None - for family, _socktype, _proto, _canonname, sockaddr in addrinfos: - try: - # 兼容 IPv4/IPv6 监听,避免 localhost 解析到 ::1 时收不到回调 - class _AddrOAuthServer(_OAuthServer): - address_family = family - - server = _AddrOAuthServer(sockaddr, state, on_code=on_code) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - return server, None - except OSError as exc: - last_error = exc - continue - - if last_error: - return None, f"Local callback server failed to start: {last_error}" - return None, "Local callback server failed to start: unknown error" - - -def _exchange_code_for_token(code: str, verifier: str) -> CodexToken: - data = { - "grant_type": "authorization_code", - "client_id": CLIENT_ID, - "code": code, - "code_verifier": verifier, - "redirect_uri": REDIRECT_URI, - } - with httpx.Client(timeout=30.0) as client: - response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) - if response.status_code != 200: - raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") - - payload = response.json() - access = payload.get("access_token") - refresh = payload.get("refresh_token") - expires_in = payload.get("expires_in") - if not access or not refresh or not isinstance(expires_in, int): - raise RuntimeError("Token response missing fields") - print("Received access token:", access) - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - -async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken: - data = { - "grant_type": "authorization_code", - "client_id": CLIENT_ID, - "code": code, - "code_verifier": verifier, - "redirect_uri": REDIRECT_URI, - } - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - TOKEN_URL, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code != 200: - raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") - - payload = response.json() - access = payload.get("access_token") - refresh = payload.get("refresh_token") - expires_in = payload.get("expires_in") - if not access or not refresh or not isinstance(expires_in, int): - raise RuntimeError("Token response missing fields") - - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - -def _refresh_token(refresh_token: str) -> CodexToken: - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - } - with httpx.Client(timeout=30.0) as client: - response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) - if response.status_code != 200: - raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}") - - payload = response.json() - access = payload.get("access_token") - refresh = payload.get("refresh_token") - expires_in = payload.get("expires_in") - if not access or not refresh or not isinstance(expires_in, int): - raise RuntimeError("Token refresh response missing fields") - - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - -def _load_token_file() -> CodexToken | None: - path = _get_token_path() - if not path.exists(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return CodexToken( - access=data["access"], - refresh=data["refresh"], - expires=int(data["expires"]), - account_id=data["account_id"], - ) - except Exception: - return None - - -def _save_token_file(token: CodexToken) -> None: - path = _get_token_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps( - { - "access": token.access, - "refresh": token.refresh, - "expires": token.expires, - "account_id": token.account_id, - }, - ensure_ascii=True, - indent=2, - ), - encoding="utf-8", - ) - try: - os.chmod(path, 0o600) - except Exception: - # Ignore permission setting failures. - pass - - -def _try_import_codex_cli_token() -> CodexToken | None: - codex_path = Path.home() / ".codex" / "auth.json" - if not codex_path.exists(): - return None - try: - data = json.loads(codex_path.read_text(encoding="utf-8")) - tokens = data.get("tokens") or {} - access = tokens.get("access_token") - refresh = tokens.get("refresh_token") - account_id = tokens.get("account_id") - if not access or not refresh or not account_id: - return None - try: - mtime = codex_path.stat().st_mtime - expires = int(mtime * 1000 + 60 * 60 * 1000) - except Exception: - expires = int(time.time() * 1000 + 60 * 60 * 1000) - token = CodexToken( - access=str(access), - refresh=str(refresh), - expires=expires, - account_id=str(account_id), - ) - _save_token_file(token) - return token - except Exception: - return None - - -class _FileLock: - """Simple file lock to reduce concurrent refreshes.""" - - def __init__(self, path: Path): - self._path = path - self._fp = None - - def __enter__(self) -> "_FileLock": - self._path.parent.mkdir(parents=True, exist_ok=True) - self._fp = open(self._path, "a+") - try: - import fcntl - - fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX) - except Exception: - # Non-POSIX or failed lock: continue without locking. - pass - return self - - def __exit__(self, exc_type, exc, tb) -> None: - try: - import fcntl - - fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN) - except Exception: - pass - try: - if self._fp: - self._fp.close() - except Exception: - pass - - -def get_codex_token() -> CodexToken: - """Get an available token (refresh if needed).""" - token = _load_token_file() or _try_import_codex_cli_token() - if not token: - raise RuntimeError("Codex OAuth credentials not found. Please run the login command.") - - # Refresh 60 seconds early. - now_ms = int(time.time() * 1000) - if token.expires - now_ms > 60 * 1000: - return token - - lock_path = _get_token_path().with_suffix(".lock") - with _FileLock(lock_path): - # Re-read to avoid stale token if another process refreshed it. - token = _load_token_file() or token - now_ms = int(time.time() * 1000) - if token.expires - now_ms > 60 * 1000: - return token - try: - refreshed = _refresh_token(token.refresh) - _save_token_file(refreshed) - return refreshed - except Exception: - # If refresh fails, re-read the file to avoid false negatives. - latest = _load_token_file() - if latest and latest.expires - now_ms > 0: - return latest - raise - - -def ensure_codex_token_available() -> None: - """Ensure a valid token is available; raise if not.""" - _ = get_codex_token() - - -async def _read_stdin_line() -> str: - loop = asyncio.get_running_loop() - if hasattr(loop, "add_reader") and sys.stdin: - future: asyncio.Future[str] = loop.create_future() - - def _on_readable() -> None: - line = sys.stdin.readline() - if not future.done(): - future.set_result(line) - - try: - loop.add_reader(sys.stdin, _on_readable) - except Exception: - return await loop.run_in_executor(None, sys.stdin.readline) - - try: - return await future - finally: - try: - loop.remove_reader(sys.stdin) - except Exception: - pass - - return await loop.run_in_executor(None, sys.stdin.readline) - - -async def _await_manual_input( - on_manual_code_input: Callable[[str], None], -) -> str: - await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC) - on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:") - return await _read_stdin_line() - - -def login_codex_oauth_interactive( - on_auth: Callable[[str], None] | None = None, - on_prompt: Callable[[str], str] | None = None, - on_status: Callable[[str], None] | None = None, - on_progress: Callable[[str], None] | None = None, - on_manual_code_input: Callable[[str], None] = None, - originator: str = DEFAULT_ORIGINATOR, -) -> CodexToken: - """Interactive login flow.""" - async def _login_async() -> CodexToken: - verifier, challenge = _generate_pkce() - state = _create_state() - - params = { - "response_type": "code", - "client_id": CLIENT_ID, - "redirect_uri": REDIRECT_URI, - "scope": SCOPE, - "code_challenge": challenge, - "code_challenge_method": "S256", - "state": state, - "id_token_add_organizations": "true", - "codex_cli_simplified_flow": "true", - "originator": originator, - } - url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" - - loop = asyncio.get_running_loop() - code_future: asyncio.Future[str] = loop.create_future() - - def _notify(code_value: str) -> None: - if code_future.done(): - return - loop.call_soon_threadsafe(code_future.set_result, code_value) - - server, server_error = _start_local_server(state, on_code=_notify) - if on_auth: - on_auth(url) - else: - webbrowser.open(url) - - if not server and server_error and on_status: - on_status( - f"Local callback server could not start ({server_error}). " - "You will need to paste the callback URL or authorization code." - ) - - code: str | None = None - try: - if server: - if on_progress and not on_manual_code_input: - on_progress("Waiting for browser callback...") - - tasks: list[asyncio.Task[Any]] = [] - callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120)) - tasks.append(callback_task) - manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input)) - tasks.append(manual_task) - - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - - for task in done: - try: - result = task.result() - except asyncio.TimeoutError: - result = None - if not result: - continue - if task is manual_task: - parsed_code, parsed_state = _parse_authorization_input(result) - if parsed_state and parsed_state != state: - raise RuntimeError("State validation failed.") - code = parsed_code - else: - code = result - if code: - break - - if not code: - prompt = "Please paste the callback URL or authorization code:" - if on_prompt: - raw = await loop.run_in_executor(None, on_prompt, prompt) - else: - raw = await loop.run_in_executor(None, input, prompt) - parsed_code, parsed_state = _parse_authorization_input(raw) - if parsed_state and parsed_state != state: - raise RuntimeError("State validation failed.") - code = parsed_code - - if not code: - raise RuntimeError("Authorization code not found.") - - if on_progress: - on_progress("Exchanging authorization code for tokens...") - token = await _exchange_code_for_token_async(code, verifier) - _save_token_file(token) - return token - finally: - if server: - server.shutdown() - server.server_close() - - try: - asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(_login_async()) - - result: list[CodexToken] = [] - error: list[Exception] = [] - - def _runner() -> None: - try: - result.append(asyncio.run(_login_async())) - except Exception as exc: - error.append(exc) - - thread = threading.Thread(target=_runner) - thread.start() - thread.join() - if error: - raise error[0] - return result[0] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 213f8c5..93be424 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -82,7 +82,7 @@ def login( console.print(f"[red]Unsupported provider: {provider}[/red]") raise typer.Exit(1) - from nanobot.auth.codex_oauth import login_codex_oauth_interactive + from nanobot.auth.codex import login_codex_oauth_interactive def on_auth(url: str) -> None: console.print("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]") @@ -205,7 +205,7 @@ def gateway( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex_oauth import ensure_codex_token_available + from nanobot.auth.codex import ensure_codex_token_available from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService @@ -341,7 +341,7 @@ def agent( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex_oauth import ensure_codex_token_available + from nanobot.auth.codex import ensure_codex_token_available from nanobot.agent.loop import AgentLoop config = load_config() diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 2081180..ec0383c 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -1,4 +1,4 @@ -"""OpenAI Codex Responses Provider。""" +"""OpenAI Codex Responses Provider.""" from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Any, AsyncGenerator import httpx -from nanobot.auth.codex_oauth import get_codex_token +from nanobot.auth.codex import get_codex_token from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" @@ -17,7 +17,7 @@ DEFAULT_ORIGINATOR = "nanobot" class OpenAICodexProvider(LLMProvider): - """使用 Codex OAuth 调用 Responses 接口。""" + """Use Codex OAuth to call the Responses API.""" def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"): super().__init__(api_key=None, api_base=None) @@ -56,37 +56,18 @@ class OpenAICodexProvider(LLMProvider): url = _resolve_codex_url(DEFAULT_CODEX_BASE_URL) try: - async with httpx.AsyncClient(timeout=60.0) as client: - try: - async with client.stream("POST", url, headers=headers, json=body) as response: - if response.status_code != 200: - text = await response.aread() - raise RuntimeError( - _friendly_error(response.status_code, text.decode("utf-8", "ignore")) - ) - content, tool_calls, finish_reason = await _consume_sse(response) - return LLMResponse( - content=content, - tool_calls=tool_calls, - finish_reason=finish_reason, - ) - except Exception as e: - # 证书校验失败时降级关闭校验(存在安全风险) - if "CERTIFICATE_VERIFY_FAILED" not in str(e): - raise - async with httpx.AsyncClient(timeout=60.0, verify=False) as insecure_client: - async with insecure_client.stream("POST", url, headers=headers, json=body) as response: - if response.status_code != 200: - text = await response.aread() - raise RuntimeError( - _friendly_error(response.status_code, text.decode("utf-8", "ignore")) - ) - content, tool_calls, finish_reason = await _consume_sse(response) - return LLMResponse( - content=content, - tool_calls=tool_calls, - finish_reason=finish_reason, - ) + try: + content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True) + except Exception as e: + # Certificate verification failed, downgrade to disable verification (security risk) + if "CERTIFICATE_VERIFY_FAILED" not in str(e): + raise + content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False) + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason, + ) except Exception as e: return LLMResponse( content=f"Error calling Codex: {str(e)}", @@ -124,17 +105,31 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]: } +async def _request_codex( + url: str, + headers: dict[str, str], + body: dict[str, Any], + verify: bool, +) -> tuple[str, list[ToolCallRequest], str]: + async with httpx.AsyncClient(timeout=60.0, verify=verify) as client: + async with client.stream("POST", url, headers=headers, json=body) as response: + if response.status_code != 200: + text = await response.aread() + raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore"))) + return await _consume_sse(response) + + def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: - # nanobot 工具定义已是 OpenAI function schema + # Nanobot tool definitions already use the OpenAI function schema. converted: list[dict[str, Any]] = [] for tool in tools: name = tool.get("name") if not isinstance(name, str) or not name: - # 忽略无效工具,避免被 Codex 拒绝 + # Skip invalid tools to avoid Codex rejection. continue params = tool.get("parameters") or {} if not isinstance(params, dict): - # 参数必须是 JSON Schema 对象 + # Parameters must be a JSON Schema object. params = {} converted.append( { @@ -164,7 +159,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st continue if role == "assistant": - # 先处理文本 + # Handle text first. if isinstance(content, str) and content: input_items.append( { @@ -175,7 +170,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st "id": f"msg_{idx}", } ) - # 再处理工具调用 + # Then handle tool calls. for tool_call in msg.get("tool_calls", []) or []: fn = tool_call.get("function") or {} call_id = tool_call.get("id") or f"call_{idx}" @@ -329,5 +324,5 @@ def _map_finish_reason(status: str | None) -> str: def _friendly_error(status_code: int, raw: str) -> str: if status_code == 429: - return "ChatGPT 使用额度已达上限或触发限流,请稍后再试。" + return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later." return f"HTTP {status_code}: {raw}" From 01420f4dd607aea6c8f9881a1b72ac2a814a628a Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Fri, 6 Feb 2026 00:14:31 +0800 Subject: [PATCH 003/415] refactor: remove unused functions and simplify code --- nanobot/auth/__init__.py | 7 +-- nanobot/auth/codex/__init__.py | 10 +---- nanobot/auth/codex/constants.py | 1 - nanobot/auth/codex/flow.py | 76 +++++++++------------------------ nanobot/cli/commands.py | 42 ++++-------------- 5 files changed, 30 insertions(+), 106 deletions(-) diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py index c74d992..ecdc1dc 100644 --- a/nanobot/auth/__init__.py +++ b/nanobot/auth/__init__.py @@ -1,13 +1,8 @@ """Authentication modules.""" -from nanobot.auth.codex import ( - ensure_codex_token_available, - get_codex_token, - login_codex_oauth_interactive, -) +from nanobot.auth.codex import get_codex_token, login_codex_oauth_interactive __all__ = [ - "ensure_codex_token_available", "get_codex_token", "login_codex_oauth_interactive", ] diff --git a/nanobot/auth/codex/__init__.py b/nanobot/auth/codex/__init__.py index 707cd4d..7a9a39b 100644 --- a/nanobot/auth/codex/__init__.py +++ b/nanobot/auth/codex/__init__.py @@ -1,15 +1,7 @@ """Codex OAuth module.""" -from nanobot.auth.codex.flow import ( - ensure_codex_token_available, - get_codex_token, - login_codex_oauth_interactive, -) -from nanobot.auth.codex.models import CodexToken - +from nanobot.auth.codex.flow import get_codex_token, login_codex_oauth_interactive __all__ = [ - "CodexToken", - "ensure_codex_token_available", "get_codex_token", "login_codex_oauth_interactive", ] diff --git a/nanobot/auth/codex/constants.py b/nanobot/auth/codex/constants.py index bbe676a..7f20aad 100644 --- a/nanobot/auth/codex/constants.py +++ b/nanobot/auth/codex/constants.py @@ -9,7 +9,6 @@ JWT_CLAIM_PATH = "https://api.openai.com/auth" DEFAULT_ORIGINATOR = "nanobot" TOKEN_FILENAME = "codex.json" -MANUAL_PROMPT_DELAY_SEC = 3 SUCCESS_HTML = ( "" "" diff --git a/nanobot/auth/codex/flow.py b/nanobot/auth/codex/flow.py index 0966327..d05feb7 100644 --- a/nanobot/auth/codex/flow.py +++ b/nanobot/auth/codex/flow.py @@ -8,7 +8,7 @@ import threading import time import urllib.parse import webbrowser -from typing import Any, Callable +from typing import Callable import httpx @@ -16,7 +16,6 @@ from nanobot.auth.codex.constants import ( AUTHORIZE_URL, CLIENT_ID, DEFAULT_ORIGINATOR, - MANUAL_PROMPT_DELAY_SEC, REDIRECT_URI, SCOPE, TOKEN_URL, @@ -39,31 +38,6 @@ from nanobot.auth.codex.storage import ( ) -def _exchange_code_for_token(code: str, verifier: str) -> CodexToken: - data = { - "grant_type": "authorization_code", - "client_id": CLIENT_ID, - "code": code, - "code_verifier": verifier, - "redirect_uri": REDIRECT_URI, - } - with httpx.Client(timeout=30.0) as client: - response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) - if response.status_code != 200: - raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") - - payload = response.json() - access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields") - print("Received access token:", access) - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken: data = { "grant_type": "authorization_code", @@ -146,11 +120,6 @@ def get_codex_token() -> CodexToken: raise -def ensure_codex_token_available() -> None: - """Ensure a valid token is available; raise if not.""" - _ = get_codex_token() - - async def _read_stdin_line() -> str: loop = asyncio.get_running_loop() if hasattr(loop, "add_reader") and sys.stdin: @@ -177,20 +146,14 @@ async def _read_stdin_line() -> str: return await loop.run_in_executor(None, sys.stdin.readline) -async def _await_manual_input( - on_manual_code_input: Callable[[str], None], -) -> str: - await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC) - on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:") +async def _await_manual_input(print_fn: Callable[[str], None]) -> str: + print_fn("[cyan]Paste the authorization code (or full redirect URL), or wait for the browser callback:[/cyan]") return await _read_stdin_line() def login_codex_oauth_interactive( - on_auth: Callable[[str], None] | None = None, - on_prompt: Callable[[str], str] | None = None, - on_status: Callable[[str], None] | None = None, - on_progress: Callable[[str], None] | None = None, - on_manual_code_input: Callable[[str], None] = None, + print_fn: Callable[[str], None], + prompt_fn: Callable[[str], str], originator: str = DEFAULT_ORIGINATOR, ) -> CodexToken: """Interactive login flow.""" @@ -222,27 +185,30 @@ def login_codex_oauth_interactive( loop.call_soon_threadsafe(code_future.set_result, code_value) server, server_error = _start_local_server(state, on_code=_notify) - if on_auth: - on_auth(url) - else: + print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]") + print_fn(url) + try: webbrowser.open(url) + except Exception: + pass - if not server and server_error and on_status: - on_status( + if not server and server_error: + print_fn( + "[yellow]" f"Local callback server could not start ({server_error}). " "You will need to paste the callback URL or authorization code." + "[/yellow]" ) code: str | None = None try: if server: - if on_progress and not on_manual_code_input: - on_progress("Waiting for browser callback...") + print_fn("[dim]Waiting for browser callback...[/dim]") - tasks: list[asyncio.Task[Any]] = [] + tasks: list[asyncio.Task[object]] = [] callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120)) tasks.append(callback_task) - manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input)) + manual_task = asyncio.create_task(_await_manual_input(print_fn)) tasks.append(manual_task) done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) @@ -268,10 +234,7 @@ def login_codex_oauth_interactive( if not code: prompt = "Please paste the callback URL or authorization code:" - if on_prompt: - raw = await loop.run_in_executor(None, on_prompt, prompt) - else: - raw = await loop.run_in_executor(None, input, prompt) + raw = await loop.run_in_executor(None, prompt_fn, prompt) parsed_code, parsed_state = _parse_authorization_input(raw) if parsed_state and parsed_state != state: raise RuntimeError("State validation failed.") @@ -280,8 +243,7 @@ def login_codex_oauth_interactive( if not code: raise RuntimeError("Authorization code not found.") - if on_progress: - on_progress("Exchanging authorization code for tokens...") + print_fn("[dim]Exchanging authorization code for tokens...[/dim]") token = await _exchange_code_for_token_async(code, verifier) _save_token_file(token) return token diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 93be424..7827103 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -84,37 +84,12 @@ def login( from nanobot.auth.codex import login_codex_oauth_interactive - def on_auth(url: str) -> None: - console.print("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]") - console.print(url) - try: - import webbrowser - webbrowser.open(url) - except Exception: - pass - - def on_status(message: str) -> None: - console.print(f"[yellow]{message}[/yellow]") - - def on_progress(message: str) -> None: - console.print(f"[dim]{message}[/dim]") - - def on_prompt(message: str) -> str: - return typer.prompt(message) - - def on_manual_code_input(message: str) -> None: - console.print(f"[cyan]{message}[/cyan]") - console.print("[green]Starting OpenAI Codex OAuth login...[/green]") login_codex_oauth_interactive( - on_auth=on_auth, - on_prompt=on_prompt, - on_status=on_status, - on_progress=on_progress, - on_manual_code_input=on_manual_code_input, + print_fn=console.print, + prompt_fn=typer.prompt, ) - console.print("[green]✓ Login successful. Credentials saved.[/green]") - + console.print("[green]Login successful. Credentials saved.[/green]") @@ -205,7 +180,7 @@ def gateway( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex import ensure_codex_token_available + from nanobot.auth.codex import get_codex_token from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService @@ -232,7 +207,7 @@ def gateway( if is_codex: try: - ensure_codex_token_available() + _ = get_codex_token() except Exception as e: console.print(f"[red]Error: {e}[/red]") console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") @@ -341,7 +316,7 @@ def agent( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex import ensure_codex_token_available + from nanobot.auth.codex import get_codex_token from nanobot.agent.loop import AgentLoop config = load_config() @@ -354,7 +329,7 @@ def agent( if is_codex: try: - ensure_codex_token_available() + _ = get_codex_token() except Exception as e: console.print(f"[red]Error: {e}[/red]") console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") @@ -716,9 +691,10 @@ def status(): console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}") console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}") console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}") - vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" + vllm_status = f"[green]�?{config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" console.print(f"vLLM/Local: {vllm_status}") if __name__ == "__main__": app() + From f20afc8d2f033f873f57072ed77c2a08ece250a2 Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Fri, 6 Feb 2026 00:39:02 +0800 Subject: [PATCH 004/415] feat: add Codex login status to nanobot status command --- nanobot/cli/commands.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 7827103..3be4e23 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,4 +1,4 @@ -"""CLI commands for nanobot.""" +"""CLI commands for nanobot.""" import asyncio from pathlib import Path @@ -667,6 +667,7 @@ def cron_run( def status(): """Show nanobot status.""" from nanobot.config.loader import load_config, get_config_path + from nanobot.auth.codex import get_codex_token config_path = get_config_path() config = load_config() @@ -694,6 +695,12 @@ def status(): vllm_status = f"[green]�?{config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" console.print(f"vLLM/Local: {vllm_status}") + try: + _ = get_codex_token() + codex_status = "[green]logged in[/green]" + except Exception: + codex_status = "[dim]not logged in[/dim]" + console.print(f"Codex Login: {codex_status}") if __name__ == "__main__": app() From b639192e46d7a9ebe94714c270ea1bbace0bb224 Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Fri, 6 Feb 2026 11:52:03 +0800 Subject: [PATCH 005/415] fix: codex tool calling failed unexpectedly --- nanobot/providers/openai_codex_provider.py | 28 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index ec0383c..a23becd 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -123,11 +123,19 @@ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: # Nanobot tool definitions already use the OpenAI function schema. converted: list[dict[str, Any]] = [] for tool in tools: - name = tool.get("name") + fn = tool.get("function") if isinstance(tool, dict) and tool.get("type") == "function" else None + if fn and isinstance(fn, dict): + name = fn.get("name") + desc = fn.get("description") + params = fn.get("parameters") + else: + name = tool.get("name") + desc = tool.get("description") + params = tool.get("parameters") if not isinstance(name, str) or not name: # Skip invalid tools to avoid Codex rejection. continue - params = tool.get("parameters") or {} + params = params or {} if not isinstance(params, dict): # Parameters must be a JSON Schema object. params = {} @@ -135,7 +143,7 @@ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: { "type": "function", "name": name, - "description": tool.get("description") or "", + "description": desc or "", "parameters": params, } ) @@ -173,8 +181,9 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st # Then handle tool calls. for tool_call in msg.get("tool_calls", []) or []: fn = tool_call.get("function") or {} - call_id = tool_call.get("id") or f"call_{idx}" - item_id = f"fc_{idx}" + call_id, item_id = _split_tool_call_id(tool_call.get("id")) + call_id = call_id or f"call_{idx}" + item_id = item_id or f"fc_{idx}" input_items.append( { "type": "function_call", @@ -226,6 +235,15 @@ def _extract_call_id(tool_call_id: Any) -> str: return "call_0" +def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: + if isinstance(tool_call_id, str) and tool_call_id: + if "|" in tool_call_id: + call_id, item_id = tool_call_id.split("|", 1) + return call_id, item_id or None + return tool_call_id, None + return "call_0", None + + def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: raw = json.dumps(messages, ensure_ascii=True, sort_keys=True) return hashlib.sha256(raw.encode("utf-8")).hexdigest() From 59017aa9bb9d8d114c7f5345d831eac81e81ed43 Mon Sep 17 00:00:00 2001 From: "tao.jun" <61566027@163.com> Date: Sun, 8 Feb 2026 13:03:32 +0800 Subject: [PATCH 006/415] feat(feishu): Add event handlers for reactions, message read, and p2p chat events - Register handlers for message reaction created events - Register handlers for message read events - Register handlers for bot entering p2p chat events - Prevent error logs for these common but unprocessed events - Import required event types from lark_oapi --- nanobot/channels/feishu.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 1c176a2..a4c7454 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -23,6 +23,8 @@ try: CreateMessageReactionRequestBody, Emoji, P2ImMessageReceiveV1, + P2ImMessageMessageReadV1, + P2ImMessageReactionCreatedV1, ) FEISHU_AVAILABLE = True except ImportError: @@ -82,12 +84,18 @@ class FeishuChannel(BaseChannel): .log_level(lark.LogLevel.INFO) \ .build() - # Create event handler (only register message receive, ignore other events) + # Create event handler (register message receive and other common events) event_handler = lark.EventDispatcherHandler.builder( self.config.encrypt_key or "", self.config.verification_token or "", ).register_p2_im_message_receive_v1( self._on_message_sync + ).register_p2_im_message_reaction_created_v1( + self._on_reaction_created + ).register_p2_im_message_message_read_v1( + self._on_message_read + ).register_p2_im_chat_access_event_bot_p2p_chat_entered_v1( + self._on_bot_p2p_chat_entered ).build() # Create WebSocket client for long connection @@ -305,3 +313,26 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.error(f"Error processing Feishu message: {e}") + + def _on_reaction_created(self, data: "P2ImMessageReactionCreatedV1") -> None: + """ + Handler for message reaction events. + We don't need to process these, but registering prevents error logs. + """ + pass + + def _on_message_read(self, data: "P2ImMessageMessageReadV1") -> None: + """ + Handler for message read events. + We don't need to process these, but registering prevents error logs. + """ + pass + + def _on_bot_p2p_chat_entered(self, data: Any) -> None: + """ + Handler for bot entering p2p chat events. + This is triggered when a user opens a chat with the bot. + We don't need to process these, but registering prevents error logs. + """ + logger.debug("Bot entered p2p chat (user opened chat window)") + pass From 42c2d83d70251a98233057ba0e55047a4cb112e7 Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Sun, 8 Feb 2026 13:41:47 +0800 Subject: [PATCH 007/415] refactor: remove Codex OAuth implementation and integrate oauth-cli-kit --- nanobot/auth/__init__.py | 8 - nanobot/auth/codex/__init__.py | 7 - nanobot/auth/codex/constants.py | 24 -- nanobot/auth/codex/flow.py | 274 --------------------- nanobot/auth/codex/models.py | 15 -- nanobot/auth/codex/pkce.py | 77 ------ nanobot/auth/codex/server.py | 115 --------- nanobot/auth/codex/storage.py | 118 --------- nanobot/cli/commands.py | 20 +- nanobot/providers/openai_codex_provider.py | 2 +- pyproject.toml | 1 + 11 files changed, 15 insertions(+), 646 deletions(-) delete mode 100644 nanobot/auth/__init__.py delete mode 100644 nanobot/auth/codex/__init__.py delete mode 100644 nanobot/auth/codex/constants.py delete mode 100644 nanobot/auth/codex/flow.py delete mode 100644 nanobot/auth/codex/models.py delete mode 100644 nanobot/auth/codex/pkce.py delete mode 100644 nanobot/auth/codex/server.py delete mode 100644 nanobot/auth/codex/storage.py diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py deleted file mode 100644 index ecdc1dc..0000000 --- a/nanobot/auth/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Authentication modules.""" - -from nanobot.auth.codex import get_codex_token, login_codex_oauth_interactive - -__all__ = [ - "get_codex_token", - "login_codex_oauth_interactive", -] diff --git a/nanobot/auth/codex/__init__.py b/nanobot/auth/codex/__init__.py deleted file mode 100644 index 7a9a39b..0000000 --- a/nanobot/auth/codex/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Codex OAuth module.""" - -from nanobot.auth.codex.flow import get_codex_token, login_codex_oauth_interactive -__all__ = [ - "get_codex_token", - "login_codex_oauth_interactive", -] diff --git a/nanobot/auth/codex/constants.py b/nanobot/auth/codex/constants.py deleted file mode 100644 index 7f20aad..0000000 --- a/nanobot/auth/codex/constants.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Codex OAuth constants.""" - -CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" -AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" -TOKEN_URL = "https://auth.openai.com/oauth/token" -REDIRECT_URI = "http://localhost:1455/auth/callback" -SCOPE = "openid profile email offline_access" -JWT_CLAIM_PATH = "https://api.openai.com/auth" - -DEFAULT_ORIGINATOR = "nanobot" -TOKEN_FILENAME = "codex.json" -SUCCESS_HTML = ( - "" - "" - "" - "" - "" - "Authentication successful" - "" - "" - "

Authentication successful. Return to your terminal to continue.

" - "" - "" -) diff --git a/nanobot/auth/codex/flow.py b/nanobot/auth/codex/flow.py deleted file mode 100644 index d05feb7..0000000 --- a/nanobot/auth/codex/flow.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Codex OAuth login and token management.""" - -from __future__ import annotations - -import asyncio -import sys -import threading -import time -import urllib.parse -import webbrowser -from typing import Callable - -import httpx - -from nanobot.auth.codex.constants import ( - AUTHORIZE_URL, - CLIENT_ID, - DEFAULT_ORIGINATOR, - REDIRECT_URI, - SCOPE, - TOKEN_URL, -) -from nanobot.auth.codex.models import CodexToken -from nanobot.auth.codex.pkce import ( - _create_state, - _decode_account_id, - _generate_pkce, - _parse_authorization_input, - _parse_token_payload, -) -from nanobot.auth.codex.server import _start_local_server -from nanobot.auth.codex.storage import ( - _FileLock, - _get_token_path, - _load_token_file, - _save_token_file, - _try_import_codex_cli_token, -) - - -async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken: - data = { - "grant_type": "authorization_code", - "client_id": CLIENT_ID, - "code": code, - "code_verifier": verifier, - "redirect_uri": REDIRECT_URI, - } - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - TOKEN_URL, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code != 200: - raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") - - payload = response.json() - access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields") - - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - -def _refresh_token(refresh_token: str) -> CodexToken: - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - } - with httpx.Client(timeout=30.0) as client: - response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) - if response.status_code != 200: - raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}") - - payload = response.json() - access, refresh, expires_in = _parse_token_payload(payload, "Token refresh response missing fields") - - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - -def get_codex_token() -> CodexToken: - """Get an available token (refresh if needed).""" - token = _load_token_file() or _try_import_codex_cli_token() - if not token: - raise RuntimeError("Codex OAuth credentials not found. Please run the login command.") - - # Refresh 60 seconds early. - now_ms = int(time.time() * 1000) - if token.expires - now_ms > 60 * 1000: - return token - - lock_path = _get_token_path().with_suffix(".lock") - with _FileLock(lock_path): - # Re-read to avoid stale token if another process refreshed it. - token = _load_token_file() or token - now_ms = int(time.time() * 1000) - if token.expires - now_ms > 60 * 1000: - return token - try: - refreshed = _refresh_token(token.refresh) - _save_token_file(refreshed) - return refreshed - except Exception: - # If refresh fails, re-read the file to avoid false negatives. - latest = _load_token_file() - if latest and latest.expires - now_ms > 0: - return latest - raise - - -async def _read_stdin_line() -> str: - loop = asyncio.get_running_loop() - if hasattr(loop, "add_reader") and sys.stdin: - future: asyncio.Future[str] = loop.create_future() - - def _on_readable() -> None: - line = sys.stdin.readline() - if not future.done(): - future.set_result(line) - - try: - loop.add_reader(sys.stdin, _on_readable) - except Exception: - return await loop.run_in_executor(None, sys.stdin.readline) - - try: - return await future - finally: - try: - loop.remove_reader(sys.stdin) - except Exception: - pass - - return await loop.run_in_executor(None, sys.stdin.readline) - - -async def _await_manual_input(print_fn: Callable[[str], None]) -> str: - print_fn("[cyan]Paste the authorization code (or full redirect URL), or wait for the browser callback:[/cyan]") - return await _read_stdin_line() - - -def login_codex_oauth_interactive( - print_fn: Callable[[str], None], - prompt_fn: Callable[[str], str], - originator: str = DEFAULT_ORIGINATOR, -) -> CodexToken: - """Interactive login flow.""" - - async def _login_async() -> CodexToken: - verifier, challenge = _generate_pkce() - state = _create_state() - - params = { - "response_type": "code", - "client_id": CLIENT_ID, - "redirect_uri": REDIRECT_URI, - "scope": SCOPE, - "code_challenge": challenge, - "code_challenge_method": "S256", - "state": state, - "id_token_add_organizations": "true", - "codex_cli_simplified_flow": "true", - "originator": originator, - } - url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" - - loop = asyncio.get_running_loop() - code_future: asyncio.Future[str] = loop.create_future() - - def _notify(code_value: str) -> None: - if code_future.done(): - return - loop.call_soon_threadsafe(code_future.set_result, code_value) - - server, server_error = _start_local_server(state, on_code=_notify) - print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]") - print_fn(url) - try: - webbrowser.open(url) - except Exception: - pass - - if not server and server_error: - print_fn( - "[yellow]" - f"Local callback server could not start ({server_error}). " - "You will need to paste the callback URL or authorization code." - "[/yellow]" - ) - - code: str | None = None - try: - if server: - print_fn("[dim]Waiting for browser callback...[/dim]") - - tasks: list[asyncio.Task[object]] = [] - callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120)) - tasks.append(callback_task) - manual_task = asyncio.create_task(_await_manual_input(print_fn)) - tasks.append(manual_task) - - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - - for task in done: - try: - result = task.result() - except asyncio.TimeoutError: - result = None - if not result: - continue - if task is manual_task: - parsed_code, parsed_state = _parse_authorization_input(result) - if parsed_state and parsed_state != state: - raise RuntimeError("State validation failed.") - code = parsed_code - else: - code = result - if code: - break - - if not code: - prompt = "Please paste the callback URL or authorization code:" - raw = await loop.run_in_executor(None, prompt_fn, prompt) - parsed_code, parsed_state = _parse_authorization_input(raw) - if parsed_state and parsed_state != state: - raise RuntimeError("State validation failed.") - code = parsed_code - - if not code: - raise RuntimeError("Authorization code not found.") - - print_fn("[dim]Exchanging authorization code for tokens...[/dim]") - token = await _exchange_code_for_token_async(code, verifier) - _save_token_file(token) - return token - finally: - if server: - server.shutdown() - server.server_close() - - try: - asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(_login_async()) - - result: list[CodexToken] = [] - error: list[Exception] = [] - - def _runner() -> None: - try: - result.append(asyncio.run(_login_async())) - except Exception as exc: - error.append(exc) - - thread = threading.Thread(target=_runner) - thread.start() - thread.join() - if error: - raise error[0] - return result[0] diff --git a/nanobot/auth/codex/models.py b/nanobot/auth/codex/models.py deleted file mode 100644 index e3a5f55..0000000 --- a/nanobot/auth/codex/models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Codex OAuth data models.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class CodexToken: - """Codex OAuth token data structure.""" - - access: str - refresh: str - expires: int - account_id: str diff --git a/nanobot/auth/codex/pkce.py b/nanobot/auth/codex/pkce.py deleted file mode 100644 index b682386..0000000 --- a/nanobot/auth/codex/pkce.py +++ /dev/null @@ -1,77 +0,0 @@ -"""PKCE and authorization helpers.""" - -from __future__ import annotations - -import base64 -import hashlib -import json -import os -import urllib.parse -from typing import Any - -from nanobot.auth.codex.constants import JWT_CLAIM_PATH - - -def _base64url(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") - - -def _decode_base64url(data: str) -> bytes: - padding = "=" * (-len(data) % 4) - return base64.urlsafe_b64decode(data + padding) - - -def _generate_pkce() -> tuple[str, str]: - verifier = _base64url(os.urandom(32)) - challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest()) - return verifier, challenge - - -def _create_state() -> str: - return _base64url(os.urandom(16)) - - -def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]: - value = raw.strip() - if not value: - return None, None - try: - url = urllib.parse.urlparse(value) - qs = urllib.parse.parse_qs(url.query) - code = qs.get("code", [None])[0] - state = qs.get("state", [None])[0] - if code: - return code, state - except Exception: - pass - - if "#" in value: - parts = value.split("#", 1) - return parts[0] or None, parts[1] or None - - if "code=" in value: - qs = urllib.parse.parse_qs(value) - return qs.get("code", [None])[0], qs.get("state", [None])[0] - - return value, None - - -def _decode_account_id(access_token: str) -> str: - parts = access_token.split(".") - if len(parts) != 3: - raise ValueError("Invalid JWT token") - payload = json.loads(_decode_base64url(parts[1]).decode("utf-8")) - auth = payload.get(JWT_CLAIM_PATH) or {} - account_id = auth.get("chatgpt_account_id") - if not account_id: - raise ValueError("Failed to extract account_id from token") - return str(account_id) - - -def _parse_token_payload(payload: dict[str, Any], missing_message: str) -> tuple[str, str, int]: - access = payload.get("access_token") - refresh = payload.get("refresh_token") - expires_in = payload.get("expires_in") - if not access or not refresh or not isinstance(expires_in, int): - raise RuntimeError(missing_message) - return access, refresh, expires_in diff --git a/nanobot/auth/codex/server.py b/nanobot/auth/codex/server.py deleted file mode 100644 index f31db19..0000000 --- a/nanobot/auth/codex/server.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Local OAuth callback server.""" - -from __future__ import annotations - -import socket -import threading -import urllib.parse -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any, Callable - -from nanobot.auth.codex.constants import SUCCESS_HTML - - -class _OAuthHandler(BaseHTTPRequestHandler): - """Local callback HTTP handler.""" - - server_version = "NanobotOAuth/1.0" - protocol_version = "HTTP/1.1" - - def do_GET(self) -> None: # noqa: N802 - try: - url = urllib.parse.urlparse(self.path) - if url.path != "/auth/callback": - self.send_response(404) - self.end_headers() - self.wfile.write(b"Not found") - return - - qs = urllib.parse.parse_qs(url.query) - code = qs.get("code", [None])[0] - state = qs.get("state", [None])[0] - - if state != self.server.expected_state: - self.send_response(400) - self.end_headers() - self.wfile.write(b"State mismatch") - return - - if not code: - self.send_response(400) - self.end_headers() - self.wfile.write(b"Missing code") - return - - self.server.code = code - try: - if getattr(self.server, "on_code", None): - self.server.on_code(code) - except Exception: - pass - body = SUCCESS_HTML.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - try: - self.wfile.flush() - except Exception: - pass - self.close_connection = True - except Exception: - self.send_response(500) - self.end_headers() - self.wfile.write(b"Internal error") - - def log_message(self, format: str, *args: Any) -> None: # noqa: A003 - # Suppress default logs to avoid noisy output. - return - - -class _OAuthServer(HTTPServer): - """OAuth callback server with state.""" - - def __init__( - self, - server_address: tuple[str, int], - expected_state: str, - on_code: Callable[[str], None] | None = None, - ): - super().__init__(server_address, _OAuthHandler) - self.expected_state = expected_state - self.code: str | None = None - self.on_code = on_code - - -def _start_local_server( - state: str, - on_code: Callable[[str], None] | None = None, -) -> tuple[_OAuthServer | None, str | None]: - """Start a local OAuth callback server on the first available localhost address.""" - try: - addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM) - except OSError as exc: - return None, f"Failed to resolve localhost: {exc}" - - last_error: OSError | None = None - for family, _socktype, _proto, _canonname, sockaddr in addrinfos: - try: - # Support IPv4/IPv6 to avoid missing callbacks when localhost resolves to ::1. - class _AddrOAuthServer(_OAuthServer): - address_family = family - - server = _AddrOAuthServer(sockaddr, state, on_code=on_code) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - return server, None - except OSError as exc: - last_error = exc - continue - - if last_error: - return None, f"Local callback server failed to start: {last_error}" - return None, "Local callback server failed to start: unknown error" diff --git a/nanobot/auth/codex/storage.py b/nanobot/auth/codex/storage.py deleted file mode 100644 index 31e5e3d..0000000 --- a/nanobot/auth/codex/storage.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Token storage helpers.""" - -from __future__ import annotations - -import json -import os -import time -from pathlib import Path - -from nanobot.auth.codex.constants import TOKEN_FILENAME -from nanobot.auth.codex.models import CodexToken -from nanobot.utils.helpers import ensure_dir, get_data_path - - -def _get_token_path() -> Path: - auth_dir = ensure_dir(get_data_path() / "auth") - return auth_dir / TOKEN_FILENAME - - -def _load_token_file() -> CodexToken | None: - path = _get_token_path() - if not path.exists(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return CodexToken( - access=data["access"], - refresh=data["refresh"], - expires=int(data["expires"]), - account_id=data["account_id"], - ) - except Exception: - return None - - -def _save_token_file(token: CodexToken) -> None: - path = _get_token_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps( - { - "access": token.access, - "refresh": token.refresh, - "expires": token.expires, - "account_id": token.account_id, - }, - ensure_ascii=True, - indent=2, - ), - encoding="utf-8", - ) - try: - os.chmod(path, 0o600) - except Exception: - # Ignore permission setting failures. - pass - - -def _try_import_codex_cli_token() -> CodexToken | None: - codex_path = Path.home() / ".codex" / "auth.json" - if not codex_path.exists(): - return None - try: - data = json.loads(codex_path.read_text(encoding="utf-8")) - tokens = data.get("tokens") or {} - access = tokens.get("access_token") - refresh = tokens.get("refresh_token") - account_id = tokens.get("account_id") - if not access or not refresh or not account_id: - return None - try: - mtime = codex_path.stat().st_mtime - expires = int(mtime * 1000 + 60 * 60 * 1000) - except Exception: - expires = int(time.time() * 1000 + 60 * 60 * 1000) - token = CodexToken( - access=str(access), - refresh=str(refresh), - expires=expires, - account_id=str(account_id), - ) - _save_token_file(token) - return token - except Exception: - return None - - -class _FileLock: - """Simple file lock to reduce concurrent refreshes.""" - - def __init__(self, path: Path): - self._path = path - self._fp = None - - def __enter__(self) -> "_FileLock": - self._path.parent.mkdir(parents=True, exist_ok=True) - self._fp = open(self._path, "a+") - try: - import fcntl - - fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX) - except Exception: - # Non-POSIX or failed lock: continue without locking. - pass - return self - - def __exit__(self, exc_type, exc, tb) -> None: - try: - import fcntl - - fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN) - except Exception: - pass - try: - if self._fp: - self._fp.close() - except Exception: - pass diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3be4e23..727c451 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,6 +1,7 @@ """CLI commands for nanobot.""" import asyncio +import sys from pathlib import Path import typer @@ -18,6 +19,12 @@ app = typer.Typer( console = Console() +def _safe_print(text: str) -> None: + encoding = sys.stdout.encoding or "utf-8" + safe_text = text.encode(encoding, errors="replace").decode(encoding, errors="replace") + console.print(safe_text) + + def version_callback(value: bool): if value: console.print(f"{__logo__} nanobot v{__version__}") @@ -82,7 +89,7 @@ def login( console.print(f"[red]Unsupported provider: {provider}[/red]") raise typer.Exit(1) - from nanobot.auth.codex import login_codex_oauth_interactive + from oauth_cli_kit import login_oauth_interactive as login_codex_oauth_interactive console.print("[green]Starting OpenAI Codex OAuth login...[/green]") login_codex_oauth_interactive( @@ -180,7 +187,7 @@ def gateway( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex import get_codex_token + from oauth_cli_kit import get_token as get_codex_token from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService @@ -316,7 +323,7 @@ def agent( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex import get_codex_token + from oauth_cli_kit import get_token as get_codex_token from nanobot.agent.loop import AgentLoop config = load_config() @@ -361,7 +368,7 @@ def agent( # Single message mode async def run_once(): response = await agent_loop.process_direct(message, session_id) - console.print(f"\n{__logo__} {response}") + _safe_print(f"\n{__logo__} {response}") asyncio.run(run_once()) else: @@ -376,7 +383,7 @@ def agent( continue response = await agent_loop.process_direct(user_input, session_id) - console.print(f"\n{__logo__} {response}\n") + _safe_print(f"\n{__logo__} {response}\n") except KeyboardInterrupt: console.print("\nGoodbye!") break @@ -667,7 +674,7 @@ def cron_run( def status(): """Show nanobot status.""" from nanobot.config.loader import load_config, get_config_path - from nanobot.auth.codex import get_codex_token + from oauth_cli_kit import get_token as get_codex_token config_path = get_config_path() config = load_config() @@ -704,4 +711,3 @@ def status(): if __name__ == "__main__": app() - diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index a23becd..f92db09 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -9,7 +9,7 @@ from typing import Any, AsyncGenerator import httpx -from nanobot.auth.codex import get_codex_token +from oauth_cli_kit import get_token as get_codex_token from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" diff --git a/pyproject.toml b/pyproject.toml index 0c59f66..a1931e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "websockets>=12.0", "websocket-client>=1.6.0", "httpx>=0.25.0", + "oauth-cli-kit>=0.1.1", "loguru>=0.7.0", "readability-lxml>=0.8.0", "rich>=13.0.0", From c1dc8d3f554a4f299aec01d26f04cd91c89c68ec Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Sun, 8 Feb 2026 16:33:46 +0800 Subject: [PATCH 008/415] fix: integrate OpenAI Codex provider with new registry system - Add OpenAI Codex ProviderSpec to registry.py - Add openai_codex config field to ProvidersConfig in schema.py - Mark Codex as OAuth-based (no API key required) - Set appropriate default_api_base for Codex API This integrates the Codex OAuth provider with the refactored provider registry system introduced in upstream commit 299d8b3. --- nanobot/config/schema.py | 1 + nanobot/providers/registry.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ea8f8ba..1707797 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -81,6 +81,7 @@ class ProvidersConfig(BaseModel): gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway + openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) # AiHubMix API gateway class GatewayConfig(BaseModel): diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index aa4a76e..d7226e7 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -141,6 +141,24 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), + # OpenAI Codex: uses OAuth, not API key. + ProviderSpec( + name="openai_codex", + keywords=("openai-codex", "codex"), + env_key="", # OAuth-based, no API key + display_name="OpenAI Codex", + litellm_prefix="", # Not routed through LiteLLM + skip_prefixes=(), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="codex", + default_api_base="https://chatgpt.com/backend-api", + strip_model_prefix=False, + model_overrides=(), + ), + # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. ProviderSpec( name="deepseek", From 08efe6ad3f1a1314765a037ac4c7d1a5757cd6eb Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Sun, 8 Feb 2026 16:48:11 +0800 Subject: [PATCH 009/415] refactor: add OAuth support to provider registry system - Add is_oauth and oauth_provider fields to ProviderSpec - Update _make_provider() to use registry for OAuth provider detection - Update get_provider() to support OAuth providers (no API key required) - Mark OpenAI Codex as OAuth-based provider in registry This improves the provider registry architecture to support OAuth-based authentication flows, making it extensible for future OAuth providers. Benefits: - OAuth providers are now registry-driven (not hardcoded) - Extensible design: new OAuth providers only need registry entry - Backward compatible: existing API key providers unaffected - Clean separation: OAuth logic centralized in registry --- nanobot/cli/commands.py | 31 ++++++++++++++++++++++--------- nanobot/config/schema.py | 10 +++++++--- nanobot/providers/registry.py | 6 ++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0732d48..855023a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -173,20 +173,33 @@ This file stores important information that should persist across sessions. def _make_provider(config): - """Create LiteLLMProvider from config. Exits if no API key found.""" + """Create provider from config. Exits if no credentials found.""" from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from oauth_cli_kit import get_token as get_codex_token + from nanobot.providers.registry import PROVIDERS + from oauth_cli_kit import get_token as get_oauth_token - p = config.get_provider() model = config.agents.defaults.model - if model.startswith("openai-codex/"): - try: - _ = get_codex_token() - except Exception: - console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]") + model_lower = model.lower() + + # Check for OAuth-based providers first (registry-driven) + for spec in PROVIDERS: + if spec.is_oauth and any(kw in model_lower for kw in spec.keywords): + # OAuth provider matched + try: + _ = get_oauth_token(spec.oauth_provider or spec.name) + except Exception: + console.print(f"Please run: [cyan]nanobot login --provider {spec.name}[/cyan]") + raise typer.Exit(1) + # Return appropriate OAuth provider class + if spec.name == "openai_codex": + return OpenAICodexProvider(default_model=model) + # Future OAuth providers can be added here + console.print(f"[red]Error: OAuth provider '{spec.name}' not fully implemented.[/red]") raise typer.Exit(1) - return OpenAICodexProvider(default_model=model) + + # Standard API key-based providers + p = config.get_provider() if not (p and p.api_key) and not model.startswith("bedrock/"): console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1707797..cde73f2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -132,15 +132,19 @@ class Config(BaseSettings): model_lower = (model or self.agents.defaults.model).lower() # Match by keyword (order follows PROVIDERS registry) + # Note: OAuth providers don't require api_key, so we check is_oauth flag for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and any(kw in model_lower for kw in spec.keywords) and p.api_key: - return p + if p and any(kw in model_lower for kw in spec.keywords): + # OAuth providers don't need api_key + if spec.is_oauth or p.api_key: + return p # Fallback: gateways first, then others (follows registry order) + # OAuth providers are also valid fallbacks for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and p.api_key: + if p and (spec.is_oauth or p.api_key): return p return None diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index d7226e7..4ccf5da 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -51,6 +51,10 @@ class ProviderSpec: # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () + # OAuth-based providers (e.g., OpenAI Codex) don't use API keys + is_oauth: bool = False # if True, uses OAuth flow instead of API key + oauth_provider: str = "" # OAuth provider name for token retrieval + @property def label(self) -> str: return self.display_name or self.name.title() @@ -157,6 +161,8 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://chatgpt.com/backend-api", strip_model_prefix=False, model_overrides=(), + is_oauth=True, # OAuth-based authentication + oauth_provider="openai-codex", # OAuth provider identifier ), # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. From fc67d11da96d7f4e45df0cbe504116a27f900af2 Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Mon, 9 Feb 2026 15:39:30 +0800 Subject: [PATCH 010/415] feat: add OAuth login command for OpenAI Codex --- nanobot/cli/commands.py | 859 +++++++++++++++++++++++++++++++++------- 1 file changed, 706 insertions(+), 153 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 40d2ae6..bbdf79c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -4,9 +4,9 @@ import asyncio import atexit import os import signal -import sys from pathlib import Path import select +import sys import typer from rich.console import Console @@ -95,148 +95,382 @@ def _enable_line_editing() -> None: except Exception: pass + history_file = Path.home() / ".nanobot" / "history" / "cli_history" + history_file.parent.mkdir(parents=True, exist_ok=True) + _HISTORY_FILE = history_file + try: - import readline as _READLINE - import atexit - - # Detect libedit (macOS) vs GNU readline (Linux) - if hasattr(_READLINE, "__doc__") and _READLINE.__doc__ and "libedit" in _READLINE.__doc__: - _USING_LIBEDIT = True - - hist_file = Path.home() / ".nanobot_history" - _HISTORY_FILE = hist_file - try: - _READLINE.read_history_file(str(hist_file)) - except FileNotFoundError: - pass - - # Enable common readline settings - _READLINE.parse_and_bind("bind -v" if _USING_LIBEDIT else "set editing-mode vi") - _READLINE.parse_and_bind("set show-all-if-ambiguous on") - _READLINE.parse_and_bind("set colored-completion-prefix on") - - if not _HISTORY_HOOK_REGISTERED: - atexit.register(_save_history) - _HISTORY_HOOK_REGISTERED = True - except Exception: + import readline + except ImportError: return + _READLINE = readline + _USING_LIBEDIT = "libedit" in (readline.__doc__ or "").lower() + + try: + if _USING_LIBEDIT: + readline.parse_and_bind("bind ^I rl_complete") + else: + readline.parse_and_bind("tab: complete") + readline.parse_and_bind("set editing-mode emacs") + except Exception: + pass + + try: + readline.read_history_file(str(history_file)) + except Exception: + pass + + if not _HISTORY_HOOK_REGISTERED: + atexit.register(_save_history) + _HISTORY_HOOK_REGISTERED = True + + +def _prompt_text() -> str: + """Build a readline-friendly colored prompt.""" + if _READLINE is None: + return "You: " + # libedit on macOS does not honor GNU readline non-printing markers. + if _USING_LIBEDIT: + return "\033[1;34mYou:\033[0m " + return "\001\033[1;34m\002You:\001\033[0m\002 " + + +def _print_agent_response(response: str, render_markdown: bool) -> None: + """Render assistant response with consistent terminal styling.""" + content = response or "" + body = Markdown(content) if render_markdown else Text(content) + console.print() + console.print( + Panel( + body, + title=f"{__logo__} nanobot", + title_align="left", + border_style="cyan", + padding=(0, 1), + ) + ) + console.print() + + +def _is_exit_command(command: str) -> bool: + """Return True when input should end interactive chat.""" + return command.lower() in EXIT_COMMANDS + async def _read_interactive_input_async() -> str: - """Async wrapper around synchronous input() (runs in thread pool).""" - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, lambda: input(f"{__logo__} ")) - - -def _is_exit_command(text: str) -> bool: - return text.strip().lower() in EXIT_COMMANDS - - -# --------------------------------------------------------------------------- -# OAuth and Authentication helpers -# --------------------------------------------------------------------------- - -def _handle_oauth_login(provider: str) -> None: - """Handle OAuth login flow for supported providers.""" - from nanobot.providers.registry import get_oauth_handler - - oauth_handler = get_oauth_handler(provider) - if oauth_handler is None: - console.print(f"[red]OAuth is not supported for provider: {provider}[/red]") - console.print("[yellow]Supported OAuth providers: github-copilot[/yellow]") - raise typer.Exit(1) - + """Read user input with arrow keys and history (runs input() in a thread).""" try: - result = oauth_handler.authenticate() - if result.success: - console.print(f"[green]✓ {result.message}[/green]") - if result.token_path: - console.print(f"[dim]Token saved to: {result.token_path}[/dim]") - else: - console.print(f"[red]✗ {result.message}[/red]") - raise typer.Exit(1) - except Exception as e: - console.print(f"[red]OAuth authentication failed: {e}[/red]") - raise typer.Exit(1) + return await asyncio.to_thread(input, _prompt_text()) + except EOFError as exc: + raise KeyboardInterrupt from exc -# --------------------------------------------------------------------------- -# @agent decorator and public API helpers -# --------------------------------------------------------------------------- - -_agent_registry: dict[str, callable] = {} +def version_callback(value: bool): + if value: + console.print(f"{__logo__} nanobot v{__version__}") + raise typer.Exit() -def _get_agent(name: str | None = None) -> callable | None: - """Retrieve a registered agent function by name.""" - if name is None: - # Return the first registered agent if no name specified - return next(iter(_agent_registry.values())) if _agent_registry else None - return _agent_registry.get(name) +@app.callback() +def main( + version: bool = typer.Option( + None, "--version", "-v", callback=version_callback, is_eager=True + ), +): + """nanobot - Personal AI Assistant.""" + pass -def agent(name: str | None = None, model: str | None = None, prompt: str | None = None): - """Decorator to register an agent function. +# ============================================================================ +# Onboard / Setup +# ============================================================================ + + +@app.command() +def onboard(): + """Initialize nanobot configuration and workspace.""" + from nanobot.config.loader import get_config_path, save_config + from nanobot.config.schema import Config + from nanobot.utils.helpers import get_workspace_path - Args: - name: Optional name for the agent (defaults to function name) - model: Optional model override (e.g., "gpt-4o", "claude-3-opus") - prompt: Optional system prompt for the agent - """ - def decorator(func): - agent_name = name or func.__name__ - _agent_registry[agent_name] = func - func._agent_config = {"model": model, "prompt": prompt} - return func - return decorator + config_path = get_config_path() + + if config_path.exists(): + console.print(f"[yellow]Config already exists at {config_path}[/yellow]") + if not typer.confirm("Overwrite?"): + raise typer.Exit() + + # Create default config + config = Config() + save_config(config) + console.print(f"[green]✓[/green] Created config at {config_path}") + + # Create workspace + workspace = get_workspace_path() + console.print(f"[green]✓[/green] Created workspace at {workspace}") + + # Create default bootstrap files + _create_workspace_templates(workspace) + + console.print(f"\n{__logo__} nanobot is ready!") + console.print("\nNext steps:") + console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") + console.print(" Get one at: https://openrouter.ai/keys") + console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]") + console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") -# --------------------------------------------------------------------------- -# Built-in CLI commands -# --------------------------------------------------------------------------- -@app.command() -def login( - provider: str = typer.Argument(..., help="Provider to authenticate with (e.g., 'github-copilot')"), -): - """Authenticate with an OAuth provider.""" - _handle_oauth_login(provider) + +def _create_workspace_templates(workspace: Path): + """Create default workspace template files.""" + templates = { + "AGENTS.md": """# Agent Instructions + +You are a helpful AI assistant. Be concise, accurate, and friendly. + +## Guidelines + +- Always explain what you're doing before taking actions +- Ask for clarification when the request is ambiguous +- Use tools to help accomplish tasks +- Remember important information in your memory files +""", + "SOUL.md": """# Soul + +I am nanobot, a lightweight AI assistant. + +## Personality + +- Helpful and friendly +- Concise and to the point +- Curious and eager to learn + +## Values + +- Accuracy over speed +- User privacy and safety +- Transparency in actions +""", + "USER.md": """# User + +Information about the user goes here. + +## Preferences + +- Communication style: (casual/formal) +- Timezone: (your timezone) +- Language: (your preferred language) +""", + } + + for filename, content in templates.items(): + file_path = workspace / filename + if not file_path.exists(): + file_path.write_text(content) + console.print(f" [dim]Created {filename}[/dim]") + + # Create memory directory and MEMORY.md + memory_dir = workspace / "memory" + memory_dir.mkdir(exist_ok=True) + memory_file = memory_dir / "MEMORY.md" + if not memory_file.exists(): + memory_file.write_text("""# Long-term Memory + +This file stores important information that should persist across sessions. + +## User Information + +(Important facts about the user) + +## Preferences + +(User preferences learned over time) + +## Important Notes + +(Things to remember) +""") + console.print(" [dim]Created memory/MEMORY.md[/dim]") + + +def _make_provider(config): + """Create LiteLLMProvider from config. Exits if no API key found.""" + from nanobot.providers.litellm_provider import LiteLLMProvider + p = config.get_provider() + model = config.agents.defaults.model + if not (p and p.api_key) and not model.startswith("bedrock/"): + console.print("[red]Error: No API key configured.[/red]") + console.print("Set one in ~/.nanobot/config.json under providers section") + raise typer.Exit(1) + return LiteLLMProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(), + default_model=model, + extra_headers=p.extra_headers if p else None, + provider_name=config.get_provider_name(), + ) + + +# ============================================================================ +# Gateway / Server +# ============================================================================ @app.command() -def version(): - """Show version information.""" - console.print(f"{__logo__} nanobot {__version__}") - - -@app.command(name="agent") -def run_agent( - name: str | None = typer.Argument(None, help="Name of the agent to run"), - message: str = typer.Option(None, "--message", "-m", help="Single message to send to the agent"), - model: str = typer.Option(None, "--model", help="Override the model for this run"), - markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render response as markdown"), - session_id: str = typer.Option("cli", "--session", "-s", help="Session ID for this conversation"), +def gateway( + port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): - """Run an interactive AI agent session.""" - import asyncio + """Start the nanobot gateway.""" + from nanobot.config.loader import load_config, get_data_dir + from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop + from nanobot.channels.manager import ChannelManager + from nanobot.session.manager import SessionManager + from nanobot.cron.service import CronService + from nanobot.cron.types import CronJob + from nanobot.heartbeat.service import HeartbeatService - # Get the agent function - agent_func = _get_agent(name) - if agent_func is None: - if name: - console.print(f"[red]Agent '{name}' not found[/red]") - else: - console.print("[yellow]No agents registered. Use @agent decorator to register agents.[/yellow]") - raise typer.Exit(1) + if verbose: + import logging + logging.basicConfig(level=logging.DEBUG) - # Initialize agent loop - agent_config = getattr(agent_func, '_agent_config', {}) - agent_model = model or agent_config.get('model') - agent_prompt = agent_config.get('prompt') + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - agent_loop = AgentLoop(model=agent_model, system_prompt=agent_prompt) + config = load_config() + bus = MessageBus() + provider = _make_provider(config) + session_manager = SessionManager(config.workspace_path) + # Create cron service first (callback set after agent creation) + cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron = CronService(cron_store_path) + + # Create agent with cron service + agent = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + max_iterations=config.agents.defaults.max_tool_iterations, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + cron_service=cron, + restrict_to_workspace=config.tools.restrict_to_workspace, + session_manager=session_manager, + ) + + # Set cron callback (needs agent) + async def on_cron_job(job: CronJob) -> str | None: + """Execute a cron job through the agent.""" + response = await agent.process_direct( + job.payload.message, + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", + ) + if job.payload.deliver and job.payload.to: + from nanobot.bus.events import OutboundMessage + await bus.publish_outbound(OutboundMessage( + channel=job.payload.channel or "cli", + chat_id=job.payload.to, + content=response or "" + )) + return response + cron.on_job = on_cron_job + + # Create heartbeat service + async def on_heartbeat(prompt: str) -> str: + """Execute heartbeat through the agent.""" + return await agent.process_direct(prompt, session_key="heartbeat") + + heartbeat = HeartbeatService( + workspace=config.workspace_path, + on_heartbeat=on_heartbeat, + interval_s=30 * 60, # 30 minutes + enabled=True + ) + + # Create channel manager + channels = ChannelManager(config, bus, session_manager=session_manager) + + if channels.enabled_channels: + console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") + else: + console.print("[yellow]Warning: No channels enabled[/yellow]") + + cron_status = cron.status() + if cron_status["jobs"] > 0: + console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") + + console.print(f"[green]✓[/green] Heartbeat: every 30m") + + async def run(): + try: + await cron.start() + await heartbeat.start() + await asyncio.gather( + agent.run(), + channels.start_all(), + ) + except KeyboardInterrupt: + console.print("\nShutting down...") + heartbeat.stop() + cron.stop() + agent.stop() + await channels.stop_all() + + asyncio.run(run()) + + + + +# ============================================================================ +# Agent Commands +# ============================================================================ + + +@app.command() +def agent( + message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), + session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"), + markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"), + logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), +): + """Interact with the agent directly.""" + from nanobot.config.loader import load_config + from nanobot.bus.queue import MessageBus + from nanobot.agent.loop import AgentLoop + from loguru import logger + + config = load_config() + + bus = MessageBus() + provider = _make_provider(config) + + if logs: + logger.enable("nanobot") + else: + logger.disable("nanobot") + + agent_loop = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + restrict_to_workspace=config.tools.restrict_to_workspace, + ) + + # Show spinner when logs are off (no output to miss); skip when logs are on + def _thinking_ctx(): + if logs: + from contextlib import nullcontext + return nullcontext() + return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + if message: # Single message mode async def run_once(): @@ -283,55 +517,374 @@ def run_agent( _restore_terminal() console.print("\nGoodbye!") break + except EOFError: + _save_history() + _restore_terminal() + console.print("\nGoodbye!") + break asyncio.run(run_interactive()) -def _thinking_ctx(): - """Context manager for showing thinking indicator.""" - from rich.live import Live - from rich.spinner import Spinner +# ============================================================================ +# Channel Commands +# ============================================================================ + + +channels_app = typer.Typer(help="Manage channels") +app.add_typer(channels_app, name="channels") + + +@channels_app.command("status") +def channels_status(): + """Show channel status.""" + from nanobot.config.loader import load_config + + config = load_config() + + table = Table(title="Channel Status") + table.add_column("Channel", style="cyan") + table.add_column("Enabled", style="green") + table.add_column("Configuration", style="yellow") + + # WhatsApp + wa = config.channels.whatsapp + table.add_row( + "WhatsApp", + "✓" if wa.enabled else "✗", + wa.bridge_url + ) + + dc = config.channels.discord + table.add_row( + "Discord", + "✓" if dc.enabled else "✗", + dc.gateway_url + ) - class ThinkingSpinner: - def __enter__(self): - self.live = Live(Spinner("dots", text="Thinking..."), console=console, refresh_per_second=10) - self.live.start() - return self + # Telegram + tg = config.channels.telegram + tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" + table.add_row( + "Telegram", + "✓" if tg.enabled else "✗", + tg_config + ) + + console.print(table) + + +def _get_bridge_dir() -> Path: + """Get the bridge directory, setting it up if needed.""" + import shutil + import subprocess + + # User's bridge location + user_bridge = Path.home() / ".nanobot" / "bridge" + + # Check if already built + if (user_bridge / "dist" / "index.js").exists(): + return user_bridge + + # Check for npm + if not shutil.which("npm"): + console.print("[red]npm not found. Please install Node.js >= 18.[/red]") + raise typer.Exit(1) + + # Find source bridge: first check package data, then source dir + pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) + src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) + + source = None + if (pkg_bridge / "package.json").exists(): + source = pkg_bridge + elif (src_bridge / "package.json").exists(): + source = src_bridge + + if not source: + console.print("[red]Bridge source not found.[/red]") + console.print("Try reinstalling: pip install --force-reinstall nanobot") + raise typer.Exit(1) + + console.print(f"{__logo__} Setting up bridge...") + + # Copy to user directory + user_bridge.parent.mkdir(parents=True, exist_ok=True) + if user_bridge.exists(): + shutil.rmtree(user_bridge) + shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) + + # Install and build + try: + console.print(" Installing dependencies...") + subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - def __exit__(self, exc_type, exc_val, exc_tb): - self.live.stop() - return False + console.print(" Building...") + subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) + + console.print("[green]✓[/green] Bridge ready\n") + except subprocess.CalledProcessError as e: + console.print(f"[red]Build failed: {e}[/red]") + if e.stderr: + console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") + raise typer.Exit(1) - return ThinkingSpinner() + return user_bridge -def _print_agent_response(response: str, render_markdown: bool = True): - """Print agent response with optional markdown rendering.""" - if render_markdown: - console.print(Markdown(response)) +@channels_app.command("login") +def channels_login(): + """Link device via QR code.""" + import subprocess + + bridge_dir = _get_bridge_dir() + + console.print(f"{__logo__} Starting bridge...") + console.print("Scan the QR code to connect.\n") + + try: + subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) + except subprocess.CalledProcessError as e: + console.print(f"[red]Bridge failed: {e}[/red]") + except FileNotFoundError: + console.print("[red]npm not found. Please install Node.js.[/red]") + + +# ============================================================================ +# Cron Commands +# ============================================================================ + +cron_app = typer.Typer(help="Manage scheduled tasks") +app.add_typer(cron_app, name="cron") + + +@cron_app.command("list") +def cron_list( + all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), +): + """List scheduled jobs.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + jobs = service.list_jobs(include_disabled=all) + + if not jobs: + console.print("No scheduled jobs.") + return + + table = Table(title="Scheduled Jobs") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Schedule") + table.add_column("Status") + table.add_column("Next Run") + + import time + for job in jobs: + # Format schedule + if job.schedule.kind == "every": + sched = f"every {(job.schedule.every_ms or 0) // 1000}s" + elif job.schedule.kind == "cron": + sched = job.schedule.expr or "" + else: + sched = "one-time" + + # Format next run + next_run = "" + if job.state.next_run_at_ms: + next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)) + next_run = next_time + + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" + + table.add_row(job.id, job.name, sched, status, next_run) + + console.print(table) + + +@cron_app.command("add") +def cron_add( + name: str = typer.Option(..., "--name", "-n", help="Job name"), + message: str = typer.Option(..., "--message", "-m", help="Message for agent"), + every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), + cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), + at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), + deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), + to: str = typer.Option(None, "--to", help="Recipient for delivery"), + channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"), +): + """Add a scheduled job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + from nanobot.cron.types import CronSchedule + + # Determine schedule type + if every: + schedule = CronSchedule(kind="every", every_ms=every * 1000) + elif cron_expr: + schedule = CronSchedule(kind="cron", expr=cron_expr) + elif at: + import datetime + dt = datetime.datetime.fromisoformat(at) + schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) else: - console.print(response) - console.print() + console.print("[red]Error: Must specify --every, --cron, or --at[/red]") + raise typer.Exit(1) + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + job = service.add_job( + name=name, + schedule=schedule, + message=message, + deliver=deliver, + to=to, + channel=channel, + ) + + console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") + + +@cron_app.command("remove") +def cron_remove( + job_id: str = typer.Argument(..., help="Job ID to remove"), +): + """Remove a scheduled job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + if service.remove_job(job_id): + console.print(f"[green]✓[/green] Removed job {job_id}") + else: + console.print(f"[red]Job {job_id} not found[/red]") + + +@cron_app.command("enable") +def cron_enable( + job_id: str = typer.Argument(..., help="Job ID"), + disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), +): + """Enable or disable a job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + job = service.enable_job(job_id, enabled=not disable) + if job: + status = "disabled" if disable else "enabled" + console.print(f"[green]✓[/green] Job '{job.name}' {status}") + else: + console.print(f"[red]Job {job_id} not found[/red]") + + +@cron_app.command("run") +def cron_run( + job_id: str = typer.Argument(..., help="Job ID to run"), + force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), +): + """Manually run a job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + async def run(): + return await service.run_job(job_id, force=force) + + if asyncio.run(run()): + console.print(f"[green]✓[/green] Job executed") + else: + console.print(f"[red]Failed to run job {job_id}[/red]") + + +# ============================================================================ +# Status Commands +# ============================================================================ @app.command() -def setup(): - """Interactive setup wizard for nanobot.""" - console.print(Panel.fit( - f"{__logo__} Welcome to nanobot setup!\n\n" - "This wizard will help you configure nanobot.", - title="Setup", - border_style="green" - )) +def status(): + """Show nanobot status.""" + from nanobot.config.loader import load_config, get_config_path + + config_path = get_config_path() + config = load_config() + workspace = config.workspace_path + + console.print(f"{__logo__} nanobot Status\n") + + console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}") + console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}") + + if config_path.exists(): + from nanobot.providers.registry import PROVIDERS + + console.print(f"Model: {config.agents.defaults.model}") + + # Check API keys from registry + for spec in PROVIDERS: + p = getattr(config.providers, spec.name, None) + if p is None: + continue + if spec.is_local: + # Local deployments show api_base instead of api_key + if p.api_base: + console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]") + else: + console.print(f"{spec.label}: [dim]not set[/dim]") + else: + has_key = bool(p.api_key) + console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}") + + +# ============================================================================ +# OAuth Login +# ============================================================================ + + +@app.command() +def login( + provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"), +): + """Authenticate with an OAuth provider.""" + console.print(f"{__logo__} OAuth Login - {provider}\n") - # TODO: Implement setup wizard - console.print("[yellow]Setup wizard coming soon![/yellow]") - - -def main(): - """Main entry point for the CLI.""" - app() + if provider == "openai-codex": + try: + from oauth_cli_kit import get_token as get_codex_token + + console.print("[cyan]Starting OpenAI Codex authentication...[/cyan]") + console.print("A browser window will open for you to authenticate.\n") + + token = get_codex_token() + + if token and token.access: + console.print(f"[green]✓ Successfully authenticated with OpenAI Codex![/green]") + console.print(f"[dim]Account ID: {token.account_id}[/dim]") + else: + console.print("[red]✗ Authentication failed[/red]") + raise typer.Exit(1) + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Authentication error: {e}[/red]") + raise typer.Exit(1) + else: + console.print(f"[red]Unknown OAuth provider: {provider}[/red]") + console.print("[yellow]Supported providers: openai-codex[/yellow]") + raise typer.Exit(1) if __name__ == "__main__": - main() + app() From 51f97efcb89fab2b3288883df0c5284a3f3ac171 Mon Sep 17 00:00:00 2001 From: pinhua33 Date: Mon, 9 Feb 2026 16:04:04 +0800 Subject: [PATCH 011/415] refactor: simplify Codex URL handling by removing unnecessary function --- nanobot/providers/openai_codex_provider.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index f92db09..9c98db5 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -12,7 +12,7 @@ import httpx from oauth_cli_kit import get_token as get_codex_token from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest -DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" +DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_ORIGINATOR = "nanobot" @@ -53,7 +53,7 @@ class OpenAICodexProvider(LLMProvider): if tools: body["tools"] = _convert_tools(tools) - url = _resolve_codex_url(DEFAULT_CODEX_BASE_URL) + url = DEFAULT_CODEX_URL try: try: @@ -84,15 +84,6 @@ def _strip_model_prefix(model: str) -> str: return model -def _resolve_codex_url(base_url: str) -> str: - raw = base_url.rstrip("/") - if raw.endswith("/codex/responses"): - return raw - if raw.endswith("/codex"): - return f"{raw}/responses" - return f"{raw}/codex/responses" - - def _build_headers(account_id: str, token: str) -> dict[str, str]: return { "Authorization": f"Bearer {token}", From 4d6f02ec0dee02f532df2295e76ea7c6c2b15ae5 Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 9 Feb 2026 21:12:16 -0500 Subject: [PATCH 012/415] fix(telegram): preserve file extension for generic documents --- nanobot/channels/telegram.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ff46c86..99c64d4 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -309,7 +309,11 @@ class TelegramChannel(BaseChannel): if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) - ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) + ext = self._get_extension( + media_type, + getattr(media_file, 'mime_type', None), + getattr(media_file, 'file_name', None) + ) # Save to workspace/media/ from pathlib import Path @@ -386,8 +390,12 @@ class TelegramChannel(BaseChannel): except Exception as e: logger.debug(f"Typing indicator stopped for {chat_id}: {e}") - def _get_extension(self, media_type: str, mime_type: str | None) -> str: - """Get file extension based on media type.""" + def _get_extension(self, media_type: str, mime_type: str | None, filename: str | None = None) -> str: + """ + Get file extension based on media type. + If mime_type is unknown, try to get extension from filename. + """ + # 1. Try known mime types if mime_type: ext_map = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", @@ -396,5 +404,14 @@ class TelegramChannel(BaseChannel): if mime_type in ext_map: return ext_map[mime_type] - type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} - return type_map.get(media_type, "") + # 2. Try simple type mapping + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3"} + if media_type in type_map: + return type_map[media_type] + + # 3. Fallback: try to get extension from filename + if filename: + from pathlib import Path + return Path(filename).suffix + + return "" From 039ab717fa29258d250f011905f31744cee5879c Mon Sep 17 00:00:00 2001 From: zhengliyuan Date: Thu, 12 Feb 2026 10:44:26 +0800 Subject: [PATCH 013/415] update: Enable listening to both private and group messages. --- nanobot/channels/qq.py | 80 +++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 5964d30..8a74261 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -2,7 +2,7 @@ import asyncio from collections import deque -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from loguru import logger @@ -13,20 +13,22 @@ from nanobot.config.schema import QQConfig try: import botpy - from botpy.message import C2CMessage + from botpy.message import C2CMessage, GroupMessage # 1. Import GroupMessage QQ_AVAILABLE = True except ImportError: QQ_AVAILABLE = False botpy = None C2CMessage = None + GroupMessage = None if TYPE_CHECKING: - from botpy.message import C2CMessage + from botpy.message import C2CMessage, GroupMessage def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": """Create a botpy Client subclass bound to the given channel.""" + # 2. Ensure intents enable public_messages (required for group messages) intents = botpy.Intents(public_messages=True, direct_message=True) class _Bot(botpy.Client): @@ -37,10 +39,17 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": logger.info(f"QQ bot ready: {self.robot.name}") async def on_c2c_message_create(self, message: "C2CMessage"): - await channel._on_message(message) + # C2C (Private) message + await channel._on_message(message, is_group=False) + + async def on_group_at_message_create(self, message: "GroupMessage"): + # 3. Added: Listen for group @messages + # Note: Official bots only receive messages @mentioning them unless privileged + await channel._on_message(message, is_group=True) async def on_direct_message_create(self, message): - await channel._on_message(message) + # Guild Direct Message + await channel._on_message(message, is_group=False) return _Bot @@ -56,6 +65,9 @@ class QQChannel(BaseChannel): self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) self._bot_task: asyncio.Task | None = None + # Cache to track if chat_id is a group or individual to select the correct reply API + # Format: {chat_id: "group" | "c2c"} + self._chat_type_cache: Dict[str, str] = {} async def start(self) -> None: """Start the QQ bot.""" @@ -72,14 +84,14 @@ class QQChannel(BaseChannel): self._client = BotClass() self._bot_task = asyncio.create_task(self._run_bot()) - logger.info("QQ bot started (C2C private message)") + logger.info("QQ bot started (C2C & Group supported)") async def _run_bot(self) -> None: """Run the bot connection.""" try: await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}") + logger.error(f"QQ auth failed: {e}") self._running = False async def stop(self) -> None: @@ -98,16 +110,31 @@ class QQChannel(BaseChannel): if not self._client: logger.warning("QQ client not initialized") return - try: - await self._client.api.post_c2c_message( - openid=msg.chat_id, - msg_type=0, - content=msg.content, - ) - except Exception as e: - logger.error(f"Error sending QQ message: {e}") + + # 4. Modified send logic: Check chat_id type to call the correct API + msg_type = self._chat_type_cache.get(msg.chat_id, "c2c") # Default to c2c - async def _on_message(self, data: "C2CMessage") -> None: + try: + if msg_type == "group": + # Send group message + await self._client.api.post_group_message( + group_openid=msg.chat_id, + msg_type=0, + msg_id=msg.metadata.get("message_id"), # Reply to specific message ID (optional but recommended) + content=msg.content + ) + else: + # Send C2C (private) message + await self._client.api.post_c2c_message( + openid=msg.chat_id, + msg_type=0, + msg_id=msg.metadata.get("message_id"), + content=msg.content, + ) + except Exception as e: + logger.error(f"Error sending QQ message ({msg_type}): {e}") + + async def _on_message(self, data: "C2CMessage | GroupMessage", is_group: bool = False) -> None: """Handle incoming message from QQ.""" try: # Dedup by message ID @@ -115,17 +142,30 @@ class QQChannel(BaseChannel): return self._processed_ids.append(data.id) - author = data.author - user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) content = (data.content or "").strip() if not content: return + # 5. Extract ID and cache type + if is_group: + # Group message: chat_id uses group_openid + chat_id = data.group_openid + user_id = data.author.member_openid # Sender's ID + self._chat_type_cache[chat_id] = "group" + + # Remove @bot text (optional, prevents Nanobot from treating the name as prompt) + # content = content.replace("@BotName", "").strip() + else: + # Private message: chat_id uses user_openid + chat_id = str(getattr(data.author, 'id', None) or getattr(data.author, 'user_openid', 'unknown')) + user_id = chat_id + self._chat_type_cache[chat_id] = "c2c" + await self._handle_message( sender_id=user_id, - chat_id=user_id, + chat_id=chat_id, content=content, metadata={"message_id": data.id}, ) except Exception as e: - logger.error(f"Error handling QQ message: {e}") + logger.error(f"Error handling QQ message: {e}") \ No newline at end of file From 09c7e7adedb0bbd65a0910d63b8a0502da86ed98 Mon Sep 17 00:00:00 2001 From: qiupinhua Date: Fri, 13 Feb 2026 18:37:21 +0800 Subject: [PATCH 014/415] feat: change OAuth login command for providers --- README.md | 1 + nanobot/cli/commands.py | 36 ++++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index eb2ff7f..8d0ab4d 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,7 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot | `nanobot agent --logs` | Show runtime logs during chat | | `nanobot gateway` | Start the gateway | | `nanobot status` | Show status | +| `nanobot provider login openai-codex` | OAuth login for providers | | `nanobot channels login` | Link WhatsApp (scan QR) | | `nanobot channels status` | Show channel status | diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 1ee2332..f2a7ee3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -860,29 +860,37 @@ def status(): # OAuth Login # ============================================================================ +provider_app = typer.Typer(help="Manage providers") +app.add_typer(provider_app, name="provider") -@app.command() -def login( + +@provider_app.command("login") +def provider_login( provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"), ): """Authenticate with an OAuth provider.""" console.print(f"{__logo__} OAuth Login - {provider}\n") - + if provider == "openai-codex": try: - from oauth_cli_kit import get_token as get_codex_token - - console.print("[cyan]Starting OpenAI Codex authentication...[/cyan]") - console.print("A browser window will open for you to authenticate.\n") - - token = get_codex_token() - - if token and token.access: - console.print(f"[green]✓ Successfully authenticated with OpenAI Codex![/green]") - console.print(f"[dim]Account ID: {token.account_id}[/dim]") - else: + from oauth_cli_kit import get_token, login_oauth_interactive + token = None + try: + token = get_token() + except Exception: + token = None + if not (token and token.access): + console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") + console.print("A browser window may open for you to authenticate.\n") + token = login_oauth_interactive( + print_fn=lambda s: console.print(s), + prompt_fn=lambda s: typer.prompt(s), + ) + if not (token and token.access): console.print("[red]✗ Authentication failed[/red]") raise typer.Exit(1) + console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") + console.print(f"[dim]Account ID: {token.account_id}[/dim]") except ImportError: console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") raise typer.Exit(1) From 1ae47058d9479e2d4e0151ff15ed631a8ab649f5 Mon Sep 17 00:00:00 2001 From: qiupinhua Date: Fri, 13 Feb 2026 18:51:30 +0800 Subject: [PATCH 015/415] fix: refactor code structure for improved readability and maintainability --- nanobot/cli/commands.py | 23 +- nanobot/providers/litellm_provider.py | 3 + nanobot/providers/openai_codex_provider.py | 12 - uv.lock | 2385 ++++++++++++++++++++ 4 files changed, 2406 insertions(+), 17 deletions(-) create mode 100644 uv.lock diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f2a7ee3..68f2f30 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -16,6 +16,7 @@ from rich.table import Table from rich.text import Text from nanobot import __version__, __logo__ +from nanobot.config.schema import Config app = typer.Typer( name="nanobot", @@ -295,21 +296,33 @@ This file stores important information that should persist across sessions. console.print(" [dim]Created memory/MEMORY.md[/dim]") -def _make_provider(config): +def _make_provider(config: Config): """Create LiteLLMProvider from config. Exits if no API key found.""" from nanobot.providers.litellm_provider import LiteLLMProvider - p = config.get_provider() + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + model = config.agents.defaults.model - if not (p and p.api_key) and not model.startswith("bedrock/"): + provider_name = config.get_provider_name(model) + p = config.get_provider(model) + + # OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation. + if provider_name == "openai_codex" or model.startswith("openai-codex/"): + return OpenAICodexProvider( + default_model=model, + api_base=p.api_base if p else None, + ) + + if not model.startswith("bedrock/") and not (p and p.api_key): console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) + return LiteLLMProvider( api_key=p.api_key if p else None, - api_base=config.get_api_base(), + api_base=config.get_api_base(model), default_model=model, extra_headers=p.extra_headers if p else None, - provider_name=config.get_provider_name(), + provider_name=provider_name, ) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 9d76c2a..0ad77af 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -54,6 +54,9 @@ class LiteLLMProvider(LLMProvider): spec = self._gateway or find_by_model(model) if not spec: return + if not spec.env_key: + # OAuth/provider-only specs (for example: openai_codex) + return # Gateway/local overrides existing env; standard provider doesn't if self._gateway: diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 9c98db5..f6d56aa 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -77,7 +77,6 @@ class OpenAICodexProvider(LLMProvider): def get_default_model(self) -> str: return self.default_model - def _strip_model_prefix(model: str) -> str: if model.startswith("openai-codex/"): return model.split("/", 1)[1] @@ -95,7 +94,6 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]: "content-type": "application/json", } - async def _request_codex( url: str, headers: dict[str, str], @@ -109,7 +107,6 @@ async def _request_codex( raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore"))) return await _consume_sse(response) - def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: # Nanobot tool definitions already use the OpenAI function schema. converted: list[dict[str, Any]] = [] @@ -140,7 +137,6 @@ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: ) return converted - def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: system_prompt = "" input_items: list[dict[str, Any]] = [] @@ -200,7 +196,6 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st return system_prompt, input_items - def _convert_user_message(content: Any) -> dict[str, Any]: if isinstance(content, str): return {"role": "user", "content": [{"type": "input_text", "text": content}]} @@ -234,12 +229,10 @@ def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: return tool_call_id, None return "call_0", None - def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: raw = json.dumps(messages, ensure_ascii=True, sort_keys=True) return hashlib.sha256(raw.encode("utf-8")).hexdigest() - async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]: buffer: list[str] = [] async for line in response.aiter_lines(): @@ -259,9 +252,6 @@ async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], continue buffer.append(line) - - - async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]: content = "" tool_calls: list[ToolCallRequest] = [] @@ -318,7 +308,6 @@ async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequ return content, tool_calls, finish_reason - def _map_finish_reason(status: str | None) -> str: if not status: return "stop" @@ -330,7 +319,6 @@ def _map_finish_reason(status: str | None) -> str: return "error" return "stop" - def _friendly_error(status_code: int, raw: str) -> str: if status_code == 429: return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later." diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3eaeaba --- /dev/null +++ b/uv.lock @@ -0,0 +1,2385 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767" }, + { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2" }, + { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "croniter" +version = "6.0.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368" }, +] + +[[package]] +name = "cssselect" +version = "1.4.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8" }, +] + +[[package]] +name = "dingtalk-stream" +version = "0.24.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "aiohttp" }, + { name = "requests" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://bytedpypi.byted.org/packages/dingtalk-stream/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06" }, + { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a" }, +] + +[[package]] +name = "filelock" +version = "3.21.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79" }, + { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "hf-xet" +version = "1.2.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865" }, + { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.4.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "shellingham" }, + { name = "tqdm" }, + { name = "typer-slim" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59" }, + { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, +] + +[[package]] +name = "lark-oapi" +version = "1.5.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://bytedpypi.byted.org/packages/lark-oapi/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36" }, +] + +[[package]] +name = "litellm" +version = "1.81.11" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/litellm/litellm-1.81.11.tar.gz", hash = "sha256:fc55aceafda325bd5f704ada61c8be5bd322e6dfa5f8bdcab3290b8732c5857e" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/litellm/litellm-1.81.11-py3-none-any.whl", hash = "sha256:06a66c24742e082ddd2813c87f40f5c12fe7baa73ce1f9457eaf453dc44a0f65" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a" }, + { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e" }, +] + +[package.optional-dependencies] +html-clean = [ + { name = "lxml-html-clean" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9" }, + { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" }, + { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, +] + +[[package]] +name = "nanobot-ai" +version = "0.1.3.post5" +source = { editable = "." } +dependencies = [ + { name = "croniter" }, + { name = "dingtalk-stream" }, + { name = "httpx" }, + { name = "lark-oapi" }, + { name = "litellm" }, + { name = "loguru" }, + { name = "oauth-cli-kit" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-telegram-bot", extra = ["socks"] }, + { name = "qq-botpy" }, + { name = "readability-lxml" }, + { name = "rich" }, + { name = "slack-sdk" }, + { name = "socksio" }, + { name = "typer" }, + { name = "websocket-client" }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "croniter", specifier = ">=2.0.0" }, + { name = "dingtalk-stream", specifier = ">=0.4.0" }, + { name = "httpx", specifier = ">=0.25.0" }, + { name = "lark-oapi", specifier = ">=1.0.0" }, + { name = "litellm", specifier = ">=1.0.0" }, + { name = "loguru", specifier = ">=0.7.0" }, + { name = "oauth-cli-kit", specifier = ">=0.1.1" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "python-telegram-bot", extras = ["socks"], specifier = ">=21.0" }, + { name = "qq-botpy", specifier = ">=1.0.0" }, + { name = "readability-lxml", specifier = ">=0.8.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "slack-sdk", specifier = ">=3.26.0" }, + { name = "socksio", specifier = ">=1.0.0" }, + { name = "typer", specifier = ">=0.9.0" }, + { name = "websocket-client", specifier = ">=1.6.0" }, + { name = "websockets", specifier = ">=12.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "oauth-cli-kit" +version = "0.1.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "httpx" }, + { name = "platformdirs" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1.tar.gz", hash = "sha256:6a13f9b9ca738115e46314fff87506887603a21888527d1c0c9e420bc9401925" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1-py3-none-any.whl", hash = "sha256:4b74633847c24ae677cf404bddd491b92e4ca99b1f7cb6bc5d67a3167e9b9910" }, +] + +[[package]] +name = "openai" +version = "2.20.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" }, +] + +[[package]] +name = "platformdirs" +version = "4.7.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9" }, + { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" }, + { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f" }, + { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/python-dateutil/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/python-dateutil/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" }, +] + +[[package]] +name = "python-telegram-bot" +version = "22.6" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0" }, +] + +[package.optional-dependencies] +socks = [ + { name = "httpx", extra = ["socks"] }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, + { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, +] + +[[package]] +name = "qq-botpy" +version = "1.2.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "aiohttp" }, + { name = "apscheduler" }, + { name = "pyyaml" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq-botpy-1.2.1.tar.gz", hash = "sha256:442172a0557a9b43d2777d1c5e072090a9d1a54d588d1c5da8d3efc014f4887f" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01" }, +] + +[[package]] +name = "readability-lxml" +version = "0.8.4.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "chardet" }, + { name = "cssselect" }, + { name = "lxml", extra = ["html-clean"] }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1.tar.gz", hash = "sha256:9d2924f5942dd7f37fb4da353263b22a3e877ccf922d0e45e348e4177b035a53" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1-py3-none-any.whl", hash = "sha256:874c0cea22c3bf2b78c7f8df831bfaad3c0a89b7301d45a188db581652b4b465" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf" }, + { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4" }, + { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e" }, +] + +[[package]] +name = "ruff" +version = "0.15.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336" }, + { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "slack-sdk" +version = "3.40.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0.tar.gz", hash = "sha256:87b9a79d1d6e19a2b1877727a0ec6f016d82d30a6a410389fba87c221c99f10e" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0-py2.py3-none-any.whl", hash = "sha256:f2bada5ed3adb10a01e154e90db01d6d8938d0461b5790c12bcb807b2d28bbe2" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0" }, + { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48" }, + { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, +] + +[[package]] +name = "typer" +version = "0.23.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913" }, +] + +[[package]] +name = "typer-slim" +version = "0.23.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "typer" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0.tar.gz", hash = "sha256:be8b60243df27cfee444c6db1b10a85f4f3e54d940574f31a996f78aa35a8254" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0-py3-none-any.whl", hash = "sha256:1d693daf22d998a7b1edab8413cdcb8af07254154ce3956c1664dc11b01e2f8b" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767" }, + { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1" }, + { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://bytedpypi.byted.org/simple/" } +sdist = { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" } +wheels = [ + { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" }, +] From 442136a313acd37930296b954e205d44272bc267 Mon Sep 17 00:00:00 2001 From: qiupinhua Date: Fri, 13 Feb 2026 18:52:43 +0800 Subject: [PATCH 016/415] fix: remove uv.lock --- uv.lock | 2385 ------------------------------------------------------- 1 file changed, 2385 deletions(-) delete mode 100644 uv.lock diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 3eaeaba..0000000 --- a/uv.lock +++ /dev/null @@ -1,2385 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version < '3.14'", -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" }, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767" }, - { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, -] - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, -] - -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" }, -] - -[[package]] -name = "apscheduler" -version = "3.11.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "tzlocal" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c" }, -] - -[[package]] -name = "chardet" -version = "5.2.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2" }, - { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, -] - -[[package]] -name = "croniter" -version = "6.0.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "python-dateutil" }, - { name = "pytz" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368" }, -] - -[[package]] -name = "cssselect" -version = "1.4.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8" }, -] - -[[package]] -name = "dingtalk-stream" -version = "0.24.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "aiohttp" }, - { name = "requests" }, - { name = "websockets" }, -] -wheels = [ - { url = "https://bytedpypi.byted.org/packages/dingtalk-stream/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad" }, -] - -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, -] - -[[package]] -name = "fastuuid" -version = "0.14.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06" }, - { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a" }, -] - -[[package]] -name = "filelock" -version = "3.21.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560" }, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79" }, - { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, -] - -[[package]] -name = "fsspec" -version = "2026.2.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, -] - -[[package]] -name = "hf-xet" -version = "1.2.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865" }, - { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, -] - -[package.optional-dependencies] -socks = [ - { name = "socksio" }, -] - -[[package]] -name = "huggingface-hub" -version = "1.4.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "shellingham" }, - { name = "tqdm" }, - { name = "typer-slim" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" }, -] - -[[package]] -name = "jiter" -version = "0.13.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59" }, - { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19" }, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, -] - -[[package]] -name = "lark-oapi" -version = "1.5.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "httpx" }, - { name = "pycryptodome" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "websockets" }, -] -wheels = [ - { url = "https://bytedpypi.byted.org/packages/lark-oapi/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36" }, -] - -[[package]] -name = "litellm" -version = "1.81.11" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "aiohttp" }, - { name = "click" }, - { name = "fastuuid" }, - { name = "httpx" }, - { name = "importlib-metadata" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "tiktoken" }, - { name = "tokenizers" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/litellm/litellm-1.81.11.tar.gz", hash = "sha256:fc55aceafda325bd5f704ada61c8be5bd322e6dfa5f8bdcab3290b8732c5857e" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/litellm/litellm-1.81.11-py3-none-any.whl", hash = "sha256:06a66c24742e082ddd2813c87f40f5c12fe7baa73ce1f9457eaf453dc44a0f65" }, -] - -[[package]] -name = "loguru" -version = "0.7.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" }, -] - -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a" }, - { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e" }, -] - -[package.optional-dependencies] -html-clean = [ - { name = "lxml-html-clean" }, -] - -[[package]] -name = "lxml-html-clean" -version = "0.4.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "lxml" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9" }, - { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, -] - -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" }, - { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, -] - -[[package]] -name = "nanobot-ai" -version = "0.1.3.post5" -source = { editable = "." } -dependencies = [ - { name = "croniter" }, - { name = "dingtalk-stream" }, - { name = "httpx" }, - { name = "lark-oapi" }, - { name = "litellm" }, - { name = "loguru" }, - { name = "oauth-cli-kit" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "python-telegram-bot", extra = ["socks"] }, - { name = "qq-botpy" }, - { name = "readability-lxml" }, - { name = "rich" }, - { name = "slack-sdk" }, - { name = "socksio" }, - { name = "typer" }, - { name = "websocket-client" }, - { name = "websockets" }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "croniter", specifier = ">=2.0.0" }, - { name = "dingtalk-stream", specifier = ">=0.4.0" }, - { name = "httpx", specifier = ">=0.25.0" }, - { name = "lark-oapi", specifier = ">=1.0.0" }, - { name = "litellm", specifier = ">=1.0.0" }, - { name = "loguru", specifier = ">=0.7.0" }, - { name = "oauth-cli-kit", specifier = ">=0.1.1" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, - { name = "python-telegram-bot", extras = ["socks"], specifier = ">=21.0" }, - { name = "qq-botpy", specifier = ">=1.0.0" }, - { name = "readability-lxml", specifier = ">=0.8.0" }, - { name = "rich", specifier = ">=13.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "slack-sdk", specifier = ">=3.26.0" }, - { name = "socksio", specifier = ">=1.0.0" }, - { name = "typer", specifier = ">=0.9.0" }, - { name = "websocket-client", specifier = ">=1.6.0" }, - { name = "websockets", specifier = ">=12.0" }, -] -provides-extras = ["dev"] - -[[package]] -name = "oauth-cli-kit" -version = "0.1.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "httpx" }, - { name = "platformdirs" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1.tar.gz", hash = "sha256:6a13f9b9ca738115e46314fff87506887603a21888527d1c0c9e420bc9401925" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1-py3-none-any.whl", hash = "sha256:4b74633847c24ae677cf404bddd491b92e4ca99b1f7cb6bc5d67a3167e9b9910" }, -] - -[[package]] -name = "openai" -version = "2.20.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" }, -] - -[[package]] -name = "platformdirs" -version = "4.7.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, -] - -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9" }, - { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" }, -] - -[[package]] -name = "pycryptodome" -version = "3.23.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" }, - { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f" }, - { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/python-dateutil/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/python-dateutil/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" }, -] - -[[package]] -name = "python-telegram-bot" -version = "22.6" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "httpcore", marker = "python_full_version >= '3.14'" }, - { name = "httpx" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0" }, -] - -[package.optional-dependencies] -socks = [ - { name = "httpx", extra = ["socks"] }, -] - -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, - { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, -] - -[[package]] -name = "qq-botpy" -version = "1.2.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "aiohttp" }, - { name = "apscheduler" }, - { name = "pyyaml" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq-botpy-1.2.1.tar.gz", hash = "sha256:442172a0557a9b43d2777d1c5e072090a9d1a54d588d1c5da8d3efc014f4887f" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01" }, -] - -[[package]] -name = "readability-lxml" -version = "0.8.4.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "chardet" }, - { name = "cssselect" }, - { name = "lxml", extra = ["html-clean"] }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1.tar.gz", hash = "sha256:9d2924f5942dd7f37fb4da353263b22a3e877ccf922d0e45e348e4177b035a53" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1-py3-none-any.whl", hash = "sha256:874c0cea22c3bf2b78c7f8df831bfaad3c0a89b7301d45a188db581652b4b465" }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, -] - -[[package]] -name = "regex" -version = "2026.1.15" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf" }, - { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" }, -] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, -] - -[[package]] -name = "rich" -version = "14.3.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69" }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4" }, - { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e" }, -] - -[[package]] -name = "ruff" -version = "0.15.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336" }, - { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, -] - -[[package]] -name = "slack-sdk" -version = "3.40.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0.tar.gz", hash = "sha256:87b9a79d1d6e19a2b1877727a0ec6f016d82d30a6a410389fba87c221c99f10e" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0-py2.py3-none-any.whl", hash = "sha256:f2bada5ed3adb10a01e154e90db01d6d8938d0461b5790c12bcb807b2d28bbe2" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, -] - -[[package]] -name = "socksio" -version = "1.0.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3" }, -] - -[[package]] -name = "tiktoken" -version = "0.12.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0" }, - { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71" }, -] - -[[package]] -name = "tokenizers" -version = "0.22.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "huggingface-hub" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48" }, - { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc" }, -] - -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, -] - -[[package]] -name = "typer" -version = "0.23.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913" }, -] - -[[package]] -name = "typer-slim" -version = "0.23.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "typer" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0.tar.gz", hash = "sha256:be8b60243df27cfee444c6db1b10a85f4f3e54d940574f31a996f78aa35a8254" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0-py3-none-any.whl", hash = "sha256:1d693daf22d998a7b1edab8413cdcb8af07254154ce3956c1664dc11b01e2f8b" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, -] - -[[package]] -name = "tzdata" -version = "2025.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1" }, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef" }, -] - -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767" }, - { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" }, -] - -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" }, -] - -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1" }, - { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff" }, -] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://bytedpypi.byted.org/simple/" } -sdist = { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" } -wheels = [ - { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" }, -] From 66cd21e6eccdba245c4b99a2c5e1fd3b2f791995 Mon Sep 17 00:00:00 2001 From: Zhiwei Li Date: Sat, 14 Feb 2026 20:21:34 +1100 Subject: [PATCH 017/415] feat: add SiliconFlow provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SiliconFlow (硅基流动) as an OpenAI-compatible gateway provider. SiliconFlow hosts multiple models (Qwen, DeepSeek, etc.) via an OpenAI-compatible API at https://api.siliconflow.cn/v1. Changes: - Add ProviderSpec for siliconflow in providers/registry.py - Add siliconflow field to ProvidersConfig in config/schema.py Co-authored-by: Cursor --- nanobot/config/schema.py | 1 + nanobot/providers/registry.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 60bbc69..6140a65 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -192,6 +192,7 @@ class ProvidersConfig(BaseModel): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # 硅基流动 API gateway class GatewayConfig(BaseModel): diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index b9071a0..bf00e31 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -117,6 +117,27 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), + # SiliconFlow (硅基流动): OpenAI-compatible gateway hosting multiple models. + # strip_model_prefix=False: SiliconFlow model names include org prefix + # (e.g. "Qwen/Qwen2.5-14B-Instruct", "deepseek-ai/DeepSeek-V3") + # which is part of the model ID and must NOT be stripped. + ProviderSpec( + name="siliconflow", + keywords=("siliconflow",), + env_key="OPENAI_API_KEY", # OpenAI-compatible + display_name="SiliconFlow", + litellm_prefix="openai", # → openai/{model} + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="siliconflow", + default_api_base="https://api.siliconflow.cn/v1", + strip_model_prefix=False, + model_overrides=(), + ), + # === Standard providers (matched by model-name keywords) =============== # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. From b523b277b05c6ed1c51c5ea44364787b71e3c592 Mon Sep 17 00:00:00 2001 From: Harry Zhou Date: Sat, 14 Feb 2026 23:44:03 +0800 Subject: [PATCH 018/415] fix(agent): handle non-string values in memory consolidation Fix TypeError when LLM returns JSON objects instead of strings for history_entry or memory_update. Changes: - Update prompt to explicitly require string values with example - Add type checking and conversion for non-string values - Use json.dumps() for consistent JSON formatting Fixes potential memory consolidation failures when LLM interprets the prompt loosely and returns structured objects instead of strings. --- nanobot/agent/loop.py | 14 +++ tests/test_memory_consolidation_types.py | 133 +++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/test_memory_consolidation_types.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c256a56..e28166f 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -384,6 +384,14 @@ class AgentLoop: ## Conversation to Process {conversation} +**IMPORTANT**: Both values MUST be strings, not objects or arrays. + +Example: +{{ + "history_entry": "[2026-02-14 22:50] User asked about...", + "memory_update": "- Host: HARRYBOOK-T14P\n- Name: Nado" +}} + Respond with ONLY valid JSON, no markdown fences.""" try: @@ -400,8 +408,14 @@ Respond with ONLY valid JSON, no markdown fences.""" result = json.loads(text) if entry := result.get("history_entry"): + # Defensive: ensure entry is a string (LLM may return dict) + if not isinstance(entry, str): + entry = json.dumps(entry, ensure_ascii=False) memory.append_history(entry) if update := result.get("memory_update"): + # Defensive: ensure update is a string + if not isinstance(update, str): + update = json.dumps(update, ensure_ascii=False) if update != current_memory: memory.write_long_term(update) diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py new file mode 100644 index 0000000..3b76596 --- /dev/null +++ b/tests/test_memory_consolidation_types.py @@ -0,0 +1,133 @@ +"""Test memory consolidation handles non-string values from LLM. + +This test verifies the fix for the bug where memory consolidation fails +when LLM returns JSON objects instead of strings for history_entry or +memory_update fields. + +Related issue: Memory consolidation fails with TypeError when LLM returns dict +""" + +import json +import tempfile +from pathlib import Path + +import pytest + +from nanobot.agent.memory import MemoryStore + + +class TestMemoryConsolidationTypeHandling: + """Test that MemoryStore methods handle type conversion correctly.""" + + def test_append_history_accepts_string(self): + """MemoryStore.append_history should accept string values.""" + with tempfile.TemporaryDirectory() as tmpdir: + memory = MemoryStore(Path(tmpdir)) + + # Should not raise TypeError + memory.append_history("[2026-02-14] Test entry") + + # Verify content was written + history_content = memory.history_file.read_text() + assert "Test entry" in history_content + + def test_write_long_term_accepts_string(self): + """MemoryStore.write_long_term should accept string values.""" + with tempfile.TemporaryDirectory() as tmpdir: + memory = MemoryStore(Path(tmpdir)) + + # Should not raise TypeError + memory.write_long_term("- Fact 1\n- Fact 2") + + # Verify content was written + memory_content = memory.read_long_term() + assert "Fact 1" in memory_content + + def test_type_conversion_dict_to_str(self): + """Dict values should be converted to JSON strings.""" + input_val = {"timestamp": "2026-02-14", "summary": "test"} + expected = '{"timestamp": "2026-02-14", "summary": "test"}' + + # Simulate the fix logic + if not isinstance(input_val, str): + result = json.dumps(input_val, ensure_ascii=False) + else: + result = input_val + + assert result == expected + assert isinstance(result, str) + + def test_type_conversion_list_to_str(self): + """List values should be converted to JSON strings.""" + input_val = ["item1", "item2"] + expected = '["item1", "item2"]' + + # Simulate the fix logic + if not isinstance(input_val, str): + result = json.dumps(input_val, ensure_ascii=False) + else: + result = input_val + + assert result == expected + assert isinstance(result, str) + + def test_type_conversion_str_unchanged(self): + """String values should remain unchanged.""" + input_val = "already a string" + + # Simulate the fix logic + if not isinstance(input_val, str): + result = json.dumps(input_val, ensure_ascii=False) + else: + result = input_val + + assert result == input_val + assert isinstance(result, str) + + def test_memory_consolidation_simulation(self): + """Simulate full consolidation with dict values from LLM.""" + with tempfile.TemporaryDirectory() as tmpdir: + memory = MemoryStore(Path(tmpdir)) + + # Simulate LLM returning dict values (the bug scenario) + history_entry = {"timestamp": "2026-02-14", "summary": "User asked about..."} + memory_update = {"facts": ["Location: Beijing", "Skill: Python"]} + + # Apply the fix: convert to str + if not isinstance(history_entry, str): + history_entry = json.dumps(history_entry, ensure_ascii=False) + if not isinstance(memory_update, str): + memory_update = json.dumps(memory_update, ensure_ascii=False) + + # Should not raise TypeError after conversion + memory.append_history(history_entry) + memory.write_long_term(memory_update) + + # Verify content + assert memory.history_file.exists() + assert memory.memory_file.exists() + + history_content = memory.history_file.read_text() + memory_content = memory.read_long_term() + + assert "timestamp" in history_content + assert "facts" in memory_content + + +class TestPromptOptimization: + """Test that prompt optimization helps prevent the issue.""" + + def test_prompt_includes_string_requirement(self): + """The prompt should explicitly require string values.""" + # This is a documentation test - verify the fix is in place + # by checking the expected prompt content + expected_keywords = [ + "MUST be strings", + "not objects or arrays", + "Example:", + ] + + # The actual prompt content is in nanobot/agent/loop.py + # This test serves as documentation of the expected behavior + for keyword in expected_keywords: + assert keyword, f"Prompt should include: {keyword}" From fbbbdc727ddec942279a5d0ccc3c37b1bc08ab23 Mon Sep 17 00:00:00 2001 From: Oleg Medvedev Date: Sat, 14 Feb 2026 13:38:49 -0600 Subject: [PATCH 019/415] fix(tools): resolve relative file paths against workspace File tools now resolve relative paths (e.g., "test.txt") against the workspace directory instead of the current working directory. This fixes failures when models use simple filenames instead of full paths. - Add workspace parameter to _resolve_path() in filesystem.py - Update all file tools to accept workspace in constructor - Pass workspace when registering tools in AgentLoop --- nanobot/agent/loop.py | 10 +++--- nanobot/agent/tools/filesystem.py | 59 +++++++++++++++++-------------- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c256a56..3d9f77b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -86,12 +86,12 @@ class AgentLoop: def _register_default_tools(self) -> None: """Register the default set of tools.""" - # File tools (restrict to workspace if configured) + # File tools (workspace for relative paths, restrict if configured) allowed_dir = self.workspace if self.restrict_to_workspace else None - self.tools.register(ReadFileTool(allowed_dir=allowed_dir)) - self.tools.register(WriteFileTool(allowed_dir=allowed_dir)) - self.tools.register(EditFileTool(allowed_dir=allowed_dir)) - self.tools.register(ListDirTool(allowed_dir=allowed_dir)) + self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) # Shell tool self.tools.register(ExecTool( diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 6b3254a..419b088 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -6,9 +6,12 @@ from typing import Any from nanobot.agent.tools.base import Tool -def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path: - """Resolve path and optionally enforce directory restriction.""" - resolved = Path(path).expanduser().resolve() +def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path: + """Resolve path against workspace (if relative) and enforce directory restriction.""" + p = Path(path).expanduser() + if not p.is_absolute() and workspace: + p = workspace / p + resolved = p.resolve() if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())): raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") return resolved @@ -16,8 +19,9 @@ def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path: class ReadFileTool(Tool): """Tool to read file contents.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -43,12 +47,12 @@ class ReadFileTool(Tool): async def execute(self, path: str, **kwargs: Any) -> str: try: - file_path = _resolve_path(path, self._allowed_dir) + file_path = _resolve_path(path, self._workspace, self._allowed_dir) if not file_path.exists(): return f"Error: File not found: {path}" if not file_path.is_file(): return f"Error: Not a file: {path}" - + content = file_path.read_text(encoding="utf-8") return content except PermissionError as e: @@ -59,8 +63,9 @@ class ReadFileTool(Tool): class WriteFileTool(Tool): """Tool to write content to a file.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -90,10 +95,10 @@ class WriteFileTool(Tool): async def execute(self, path: str, content: str, **kwargs: Any) -> str: try: - file_path = _resolve_path(path, self._allowed_dir) + file_path = _resolve_path(path, self._workspace, self._allowed_dir) file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(content, encoding="utf-8") - return f"Successfully wrote {len(content)} bytes to {path}" + return f"Successfully wrote {len(content)} bytes to {file_path}" except PermissionError as e: return f"Error: {e}" except Exception as e: @@ -102,8 +107,9 @@ class WriteFileTool(Tool): class EditFileTool(Tool): """Tool to edit a file by replacing text.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -137,24 +143,24 @@ class EditFileTool(Tool): async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str: try: - file_path = _resolve_path(path, self._allowed_dir) + file_path = _resolve_path(path, self._workspace, self._allowed_dir) if not file_path.exists(): return f"Error: File not found: {path}" - + content = file_path.read_text(encoding="utf-8") - + if old_text not in content: return f"Error: old_text not found in file. Make sure it matches exactly." - + # Count occurrences count = content.count(old_text) if count > 1: return f"Warning: old_text appears {count} times. Please provide more context to make it unique." - + new_content = content.replace(old_text, new_text, 1) file_path.write_text(new_content, encoding="utf-8") - - return f"Successfully edited {path}" + + return f"Successfully edited {file_path}" except PermissionError as e: return f"Error: {e}" except Exception as e: @@ -163,8 +169,9 @@ class EditFileTool(Tool): class ListDirTool(Tool): """Tool to list directory contents.""" - - def __init__(self, allowed_dir: Path | None = None): + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace self._allowed_dir = allowed_dir @property @@ -190,20 +197,20 @@ class ListDirTool(Tool): async def execute(self, path: str, **kwargs: Any) -> str: try: - dir_path = _resolve_path(path, self._allowed_dir) + dir_path = _resolve_path(path, self._workspace, self._allowed_dir) if not dir_path.exists(): return f"Error: Directory not found: {path}" if not dir_path.is_dir(): return f"Error: Not a directory: {path}" - + items = [] for item in sorted(dir_path.iterdir()): prefix = "📁 " if item.is_dir() else "📄 " items.append(f"{prefix}{item.name}") - + if not items: return f"Directory {path} is empty" - + return "\n".join(items) except PermissionError as e: return f"Error: {e}" From 82074a7715cd7e3b8c4810f861401926b64139cf Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 14:03:51 +0000 Subject: [PATCH 020/415] docs: update news section --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9066d5a..e75f080 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ ## 📢 News +- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. - **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! +- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support! - **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). From 7e2d801ffc5071d68b07a30c06d8a3f97ce58e62 Mon Sep 17 00:00:00 2001 From: "Aleksander W. Oleszkiewicz (Alek)" <24917047+alekwo@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:51:19 +0100 Subject: [PATCH 021/415] Implement markdown conversion for Slack messages Add markdown conversion for Slack messages including italics, bold, and table formatting. --- nanobot/channels/slack.py | 114 +++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index be95dd2..7298435 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -84,7 +84,7 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" await self._web_client.chat_postMessage( channel=msg.chat_id, - text=msg.content or "", + text=self._convert_markdown(msg.content) or "", thread_ts=thread_ts if use_thread else None, ) except Exception as e: @@ -203,3 +203,115 @@ class SlackChannel(BaseChannel): if not text or not self._bot_user_id: return text return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() + + def _convert_markdown(self, text: str) -> str: + if not text: + return text + def convert_formatting(input: str) -> str: + # Convert italics + # Step 1: *text* -> _text_ + converted_text = re.sub( + r"(?m)(^|[^\*])\*([^\*].+?[^\*])\*([^\*]|$)", r"\1_\2_\3", input) + # Convert bold + # Step 2.a: **text** -> *text* + converted_text = re.sub( + r"(?m)(^|[^\*])\*\*([^\*].+?[^\*])\*\*([^\*]|$)", r"\1*\2*\3", converted_text) + # Step 2.b: __text__ -> *text* + converted_text = re.sub( + r"(?m)(^|[^_])__([^_].+?[^_])__([^_]|$)", r"\1*\2*\3", converted_text) + # convert bold italics + # Step 3.a: ***text*** -> *_text_* + converted_text = re.sub( + r"(?m)(^|[^\*])\*\*\*([^\*].+?[^\*])\*\*\*([^\*]|$)", r"\1*_\2_*\3", converted_text) + # Step 3.b - ___text___ to *_text_* + converted_text = re.sub( + r"(?m)(^|[^_])___([^_].+?[^_])___([^_]|$)", r"\1*_\2_*\3", converted_text) + return converted_text + def escape_mrkdwn(text: str) -> str: + return (text.replace('&', '&') + .replace('<', '<') + .replace('>', '>')) + def convert_table(match: re.Match) -> str: + # Slack doesn't support Markdown tables + # Convert table to bulleted list with sections + # -- input_md: + # Some text before the table. + # | Col1 | Col2 | Col3 | + # |-----|----------|------| + # | Row1 - A | Row1 - B | Row1 - C | + # | Row2 - D | Row2 - E | Row2 - F | + # + # Some text after the table. + # + # -- will be converted to: + # Some text before the table. + # > *Col1* : Row1 - A + # • *Col2*: Row1 - B + # • *Col3*: Row1 - C + # > *Col1* : Row2 - D + # • *Col2*: Row2 - E + # • *Col3*: Row2 - F + # + # Some text after the table. + + block = match.group(0).strip() + lines = [line.strip() + for line in block.split('\n') if line.strip()] + + if len(lines) < 2: + return block + + # 1. Parse Headers from the first line + # Split by pipe, filtering out empty start/end strings caused by outer pipes + header_line = lines[0].strip('|') + headers = [escape_mrkdwn(h.strip()) + for h in header_line.split('|')] + + # 2. Identify Data Start (Skip Separator) + data_start_idx = 1 + # If line 2 contains only separator chars (|-: ), skip it + if len(lines) > 1 and not re.search(r'[^|\-\s:]', lines[1]): + data_start_idx = 2 + + # 3. Process Data Rows + slack_lines = [] + for line in lines[data_start_idx:]: + # Clean and split cells + clean_line = line.strip('|') + cells = [escape_mrkdwn(c.strip()) + for c in clean_line.split('|')] + + # Normalize cell count to match headers + if len(cells) < len(headers): + cells += [''] * (len(headers) - len(cells)) + cells = cells[:len(headers)] + + # Skip empty rows + if not any(cells): + continue + + # Key is the first column + key = cells[0] + label = headers[0] + slack_lines.append( + f"> *{label}* : {key}" if key else "> *{label}* : --") + + # Sub-bullets for remaining columns + for i, cell in enumerate(cells[1:], 1): + if cell: + label = headers[i] if i < len(headers) else "Col" + slack_lines.append(f" • *{label}*: {cell}") + + slack_lines.append("") # Spacer between items + + return "\n".join(slack_lines).rstrip() + + # (?m) : Multiline mode so ^ matches start of line and $ end of line + # ^\| : Start of line and a literal pipe + # .*?\|$ : Rest of the line and a pipe at the end + # (?:\n(?:\|\:?-{3,}\:?)*?\|$) : A heading line with at least three dashes in each column, pipes, and : e.g. |:---|----|:---:| + # (?:\n\|.*?\|$)* : Zero or more subsequent lines that ALSO start and end with a pipe + table_pattern = r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*' + + input_md = convert_formatting(text) + return re.sub(table_pattern, convert_table, input_md) From a5265c263d1ea277dc3197e63364105be0503d79 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 16:41:27 +0000 Subject: [PATCH 022/415] docs: update readme structure --- README.md | 90 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index e75f080..c1b7e46 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,22 @@ nanobot onboard **2. Configure** (`~/.nanobot/config.json`) -For OpenRouter - recommended for global users: +Add or merge these **two parts** into your config (other options have defaults). + +*Set your API key* (e.g. OpenRouter, recommended for global users): ```json { "providers": { "openrouter": { "apiKey": "sk-or-v1-xxx" } - }, + } +} +``` + +*Set your model*: +```json +{ "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" @@ -128,48 +136,11 @@ For OpenRouter - recommended for global users: **3. Chat** ```bash -nanobot agent -m "What is 2+2?" +nanobot agent ``` That's it! You have a working AI assistant in 2 minutes. -## 🖥️ Local Models (vLLM) - -Run nanobot with your own local models using vLLM or any OpenAI-compatible server. - -**1. Start your vLLM server** - -```bash -vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 -``` - -**2. Configure** (`~/.nanobot/config.json`) - -```json -{ - "providers": { - "vllm": { - "apiKey": "dummy", - "apiBase": "http://localhost:8000/v1" - } - }, - "agents": { - "defaults": { - "model": "meta-llama/Llama-3.1-8B-Instruct" - } - } -} -``` - -**3. Chat** - -```bash -nanobot agent -m "Hello from my local LLM!" -``` - -> [!TIP] -> The `apiKey` can be any non-empty string for local servers that don't require authentication. - ## 💬 Chat Apps Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere. @@ -640,6 +611,43 @@ If your provider is not listed above but exposes an **OpenAI-compatible API** (e +
+vLLM (local / OpenAI-compatible) + +Run your own model with vLLM or any OpenAI-compatible server, then add to config: + +**1. Start the server** (example): +```bash +vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 +``` + +**2. Add to config** (partial — merge into `~/.nanobot/config.json`): + +*Provider (key can be any non-empty string for local):* +```json +{ + "providers": { + "vllm": { + "apiKey": "dummy", + "apiBase": "http://localhost:8000/v1" + } + } +} +``` + +*Model:* +```json +{ + "agents": { + "defaults": { + "model": "meta-llama/Llama-3.1-8B-Instruct" + } + } +} +``` + +
+
Adding a New Provider (Developer Guide) @@ -721,6 +729,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ### Security +> [!TIP] > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. | Option | Default | Description | @@ -815,7 +824,6 @@ PRs welcome! The codebase is intentionally small and readable. 🤗 **Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)! -- [x] **Voice Transcription** — Support for Groq Whisper (Issue #13) - [ ] **Multi-modal** — See and hear (images, voice, video) - [ ] **Long-term memory** — Never forget important context - [ ] **Better reasoning** — Multi-step planning and reflection From 203aa154d4c26b5c49a07923b74a5d928bb14300 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Sun, 15 Feb 2026 22:39:31 +0000 Subject: [PATCH 023/415] fix(telegram): split long messages to avoid Message is too long error Telegram has a 4096 character limit per message. This fix: - Splits messages longer than 4000 chars into multiple chunks - Prefers breaking at newline boundaries to preserve formatting - Falls back to space boundaries if no newlines available - Forces split at max length if no good boundaries exist - Adds comprehensive tests for message splitting logic --- .gitignore | 2 +- nanobot/channels/telegram.py | 57 +++-- tests/test_telegram_channel.py | 416 +++++++++++++++++++++++++++++++++ 3 files changed, 459 insertions(+), 16 deletions(-) create mode 100644 tests/test_telegram_channel.py diff --git a/.gitignore b/.gitignore index d7b930d..742d593 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log -tests/ + diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 32f8c67..a45178b 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -188,27 +188,54 @@ class TelegramChannel(BaseChannel): self._stop_typing(msg.chat_id) try: - # chat_id should be the Telegram chat ID (integer) chat_id = int(msg.chat_id) - # Convert markdown to Telegram HTML - html_content = _markdown_to_telegram_html(msg.content) - await self._app.bot.send_message( - chat_id=chat_id, - text=html_content, - parse_mode="HTML" - ) except ValueError: logger.error(f"Invalid chat_id: {msg.chat_id}") - except Exception as e: - # Fallback to plain text if HTML parsing fails - logger.warning(f"HTML parse failed, falling back to plain text: {e}") + return + + # Split content into chunks (Telegram limit: 4096 chars) + MAX_LENGTH = 4000 # Leave some margin for safety + content = msg.content + chunks = [] + + while content: + if len(content) <= MAX_LENGTH: + chunks.append(content) + break + + # Find a good break point (newline or space) + chunk = content[:MAX_LENGTH] + # Prefer breaking at newline + break_pos = chunk.rfind('\n') + if break_pos == -1: + # Fall back to last space + break_pos = chunk.rfind(' ') + if break_pos == -1: + # No good break point, force break at limit + break_pos = MAX_LENGTH + + chunks.append(content[:break_pos]) + content = content[break_pos:].lstrip() + + # Send each chunk + for i, chunk in enumerate(chunks): try: + html_content = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( - chat_id=int(msg.chat_id), - text=msg.content + chat_id=chat_id, + text=html_content, + parse_mode="HTML" ) - except Exception as e2: - logger.error(f"Error sending Telegram message: {e2}") + except Exception as e: + # Fallback to plain text if HTML parsing fails + logger.warning(f"HTML parse failed for chunk {i+1}, falling back to plain text: {e}") + try: + await self._app.bot.send_message( + chat_id=chat_id, + text=chunk + ) + except Exception as e2: + logger.error(f"Error sending Telegram chunk {i+1}: {e2}") async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py new file mode 100644 index 0000000..8e9a4d4 --- /dev/null +++ b/tests/test_telegram_channel.py @@ -0,0 +1,416 @@ +"""Tests for Telegram channel implementation.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.telegram import TelegramChannel, _markdown_to_telegram_html +from nanobot.config.schema import TelegramConfig + + +def _make_config() -> TelegramConfig: + return TelegramConfig( + enabled=True, + token="fake-token", + allow_from=[], + proxy=None, + ) + + +class TestMarkdownToTelegramHtml: + """Tests for markdown to Telegram HTML conversion.""" + + def test_empty_text(self) -> None: + assert _markdown_to_telegram_html("") == "" + + def test_plain_text_passthrough(self) -> None: + text = "Hello world" + assert _markdown_to_telegram_html(text) == "Hello world" + + def test_bold_double_asterisks(self) -> None: + text = "This is **bold** text" + assert _markdown_to_telegram_html(text) == "This is bold text" + + def test_bold_double_underscore(self) -> None: + text = "This is __bold__ text" + assert _markdown_to_telegram_html(text) == "This is bold text" + + def test_italic_underscore(self) -> None: + text = "This is _italic_ text" + assert _markdown_to_telegram_html(text) == "This is italic text" + + def test_italic_not_inside_words(self) -> None: + text = "some_var_name" + assert _markdown_to_telegram_html(text) == "some_var_name" + + def test_strikethrough(self) -> None: + text = "This is ~~deleted~~ text" + assert _markdown_to_telegram_html(text) == "This is deleted text" + + def test_inline_code(self) -> None: + text = "Use `print()` function" + result = _markdown_to_telegram_html(text) + assert "print()" in result + + def test_inline_code_escapes_html(self) -> None: + text = "Use `
` tag" + result = _markdown_to_telegram_html(text) + assert "<div>" in result + + def test_code_block(self) -> None: + text = """Here is code: +```python +def hello(): + return "world" +``` +Done. +""" + result = _markdown_to_telegram_html(text) + assert "
" in result
+        assert "def hello():" in result
+        assert "
" in result + + def test_code_block_escapes_html(self) -> None: + text = """``` +
test
+```""" + result = _markdown_to_telegram_html(text) + assert "<div>test</div>" in result + + def test_headers_stripped(self) -> None: + text = "# Header 1\n## Header 2\n### Header 3" + result = _markdown_to_telegram_html(text) + assert "# Header 1" not in result + assert "Header 1" in result + assert "Header 2" in result + assert "Header 3" in result + + def test_blockquotes_stripped(self) -> None: + text = "> This is a quote\nMore text" + result = _markdown_to_telegram_html(text) + assert "> " not in result + assert "This is a quote" in result + + def test_links_converted(self) -> None: + text = "Check [this link](https://example.com) out" + result = _markdown_to_telegram_html(text) + assert 'this link' in result + + def test_bullet_list_converted(self) -> None: + text = "- Item 1\n* Item 2" + result = _markdown_to_telegram_html(text) + assert "• Item 1" in result + assert "• Item 2" in result + + def test_html_special_chars_escaped(self) -> None: + text = "5 < 10 and 10 > 5" + result = _markdown_to_telegram_html(text) + assert "5 < 10" in result + assert "10 > 5" in result + + def test_complex_nested_formatting(self) -> None: + text = "**Bold _and italic_** and `code`" + result = _markdown_to_telegram_html(text) + assert "Bold and italic" in result + assert "code" in result + + +class TestTelegramChannelSend: + """Tests for TelegramChannel.send() method.""" + + @pytest.mark.asyncio + async def test_send_short_message_single_chunk(self, monkeypatch) -> None: + """Short messages are sent as a single message.""" + sent_messages = [] + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content="Hello world" + )) + + assert len(sent_messages) == 1 + assert sent_messages[0]["chat_id"] == 123456 + assert "Hello world" in sent_messages[0]["text"] + assert sent_messages[0]["parse_mode"] == "HTML" + + @pytest.mark.asyncio + async def test_send_long_message_split_into_chunks(self, monkeypatch) -> None: + """Long messages exceeding 4000 chars are split.""" + sent_messages = [] + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + + # Create a message longer than 4000 chars + long_content = "A" * 1000 + "\n" + "B" * 1000 + "\n" + "C" * 1000 + "\n" + "D" * 1000 + "\n" + "E" * 1000 + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content=long_content + )) + + assert len(sent_messages) == 2 # Should be split into 2 messages + assert all(m["chat_id"] == 123456 for m in sent_messages) + + @pytest.mark.asyncio + async def test_send_splits_at_newline_when_possible(self, monkeypatch) -> None: + """Message splitting prefers newline boundaries.""" + sent_messages = [] + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + + # Create content with clear paragraph breaks + paragraphs = [f"Paragraph {i}: " + "x" * 100 for i in range(50)] + content = "\n".join(paragraphs) + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content=content + )) + + # Each chunk should end with a complete paragraph (no partial lines) + for msg in sent_messages: + # Message should not start with whitespace after stripping + text = msg["text"] + assert text == text.lstrip() + + @pytest.mark.asyncio + async def test_send_falls_back_to_space_boundary(self, monkeypatch) -> None: + """When no newline available, split at space boundary.""" + sent_messages = [] + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + + # Long content without newlines but with spaces + content = "word " * 2000 # ~10000 chars + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content=content + )) + + assert len(sent_messages) >= 2 + + @pytest.mark.asyncio + async def test_send_forces_split_when_no_good_boundary(self, monkeypatch) -> None: + """When no newline or space, force split at max length.""" + sent_messages = [] + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + + # Long content without any spaces or newlines + content = "A" * 10000 + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content=content + )) + + assert len(sent_messages) >= 2 + # Verify all chunks combined equal original + combined = "".join(m["text"] for m in sent_messages) + assert combined == content + + @pytest.mark.asyncio + async def test_send_invalid_chat_id_logs_error(self, monkeypatch) -> None: + """Invalid chat_id should log error and not send.""" + sent_messages = [] + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + sent_messages.append({"chat_id": chat_id, "text": text}) + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="not-a-number", + content="Hello" + )) + + assert len(sent_messages) == 0 + + @pytest.mark.asyncio + async def test_send_html_parse_error_falls_back_to_plain_text(self, monkeypatch) -> None: + """When HTML parsing fails, fall back to plain text.""" + sent_messages = [] + call_count = 0 + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + nonlocal call_count + call_count += 1 + if parse_mode == "HTML" and call_count == 1: + raise Exception("Bad markup") + sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content="Hello **world**" + )) + + # Should have 2 calls: first HTML (fails), second plain text (succeeds) + assert call_count == 2 + assert len(sent_messages) == 1 + assert sent_messages[0]["parse_mode"] is None # Plain text + assert "Hello **world**" in sent_messages[0]["text"] + + @pytest.mark.asyncio + async def test_send_not_running_warns(self, monkeypatch) -> None: + """If bot not running, log warning.""" + warning_logged = [] + + def mock_warning(msg, *args): + warning_logged.append(msg) + + monkeypatch.setattr("nanobot.channels.telegram.logger", MagicMock(warning=mock_warning)) + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = None # Not running + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content="Hello" + )) + + assert any("not running" in str(m) for m in warning_logged) + + @pytest.mark.asyncio + async def test_send_stops_typing_indicator(self, monkeypatch) -> None: + """Sending message should stop typing indicator.""" + stopped_chats = [] + + class FakeBot: + async def send_message(self, chat_id, text, parse_mode=None): + pass + + fake_app = MagicMock() + fake_app.bot = FakeBot() + + channel = TelegramChannel(_make_config(), MessageBus()) + channel._app = fake_app + channel._stop_typing = lambda chat_id: stopped_chats.append(chat_id) + + await channel.send(OutboundMessage( + channel="telegram", + chat_id="123456", + content="Hello" + )) + + assert "123456" in stopped_chats + + +class TestTelegramChannelTyping: + """Tests for typing indicator functionality.""" + + @pytest.mark.asyncio + async def test_start_typing_creates_task(self) -> None: + channel = TelegramChannel(_make_config(), MessageBus()) + + # Mock _typing_loop to avoid actual async execution + channel._typing_loop = AsyncMock() + + channel._start_typing("123456") + + assert "123456" in channel._typing_tasks + assert not channel._typing_tasks["123456"].done() + + # Clean up + channel._stop_typing("123456") + + def test_stop_typing_cancels_task(self) -> None: + channel = TelegramChannel(_make_config(), MessageBus()) + + # Create a mock task + mock_task = MagicMock() + mock_task.done.return_value = False + channel._typing_tasks["123456"] = mock_task + + channel._stop_typing("123456") + + mock_task.cancel.assert_called_once() + assert "123456" not in channel._typing_tasks + + +class TestTelegramChannelMediaExtensions: + """Tests for media file extension detection.""" + + def test_get_extension_from_mime_type(self) -> None: + channel = TelegramChannel(_make_config(), MessageBus()) + + assert channel._get_extension("image", "image/jpeg") == ".jpg" + assert channel._get_extension("image", "image/png") == ".png" + assert channel._get_extension("image", "image/gif") == ".gif" + assert channel._get_extension("audio", "audio/ogg") == ".ogg" + assert channel._get_extension("audio", "audio/mpeg") == ".mp3" + + def test_get_extension_fallback_to_type(self) -> None: + channel = TelegramChannel(_make_config(), MessageBus()) + + assert channel._get_extension("image", None) == ".jpg" + assert channel._get_extension("voice", None) == ".ogg" + assert channel._get_extension("audio", None) == ".mp3" + + def test_get_extension_unknown_type(self) -> None: + channel = TelegramChannel(_make_config(), MessageBus()) + + assert channel._get_extension("unknown", None) == "" From 9bfc86af41144a2cc74ac0c5390156e29302cb26 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Sun, 15 Feb 2026 22:49:01 +0000 Subject: [PATCH 024/415] refactor(telegram): extract message splitting into helper function - Added _split_message() helper for cleaner separation of concerns - Simplified send() method by using the helper - Net -18 lines for the message splitting feature --- nanobot/channels/telegram.py | 68 +++++++++++++----------------------- 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index a45178b..09467af 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -78,6 +78,23 @@ def _markdown_to_telegram_html(text: str) -> str: return text +def _split_message(content: str, max_len: int = 4000) -> list[str]: + """Split content into chunks within max_len, preferring line breaks.""" + if len(content) <= max_len: + return [content] + chunks = [] + while len(content) > max_len: + chunk = content[:max_len] + break_pos = chunk.rfind('\n') + if break_pos == -1: + break_pos = chunk.rfind(' ') + if break_pos == -1: + break_pos = max_len + chunks.append(chunk[:break_pos]) + content = content[break_pos:].lstrip() + return chunks + [content] + + class TelegramChannel(BaseChannel): """ Telegram channel using long polling. @@ -183,59 +200,24 @@ class TelegramChannel(BaseChannel): if not self._app: logger.warning("Telegram bot not running") return - - # Stop typing indicator for this chat + self._stop_typing(msg.chat_id) - + try: chat_id = int(msg.chat_id) except ValueError: logger.error(f"Invalid chat_id: {msg.chat_id}") return - - # Split content into chunks (Telegram limit: 4096 chars) - MAX_LENGTH = 4000 # Leave some margin for safety - content = msg.content - chunks = [] - - while content: - if len(content) <= MAX_LENGTH: - chunks.append(content) - break - - # Find a good break point (newline or space) - chunk = content[:MAX_LENGTH] - # Prefer breaking at newline - break_pos = chunk.rfind('\n') - if break_pos == -1: - # Fall back to last space - break_pos = chunk.rfind(' ') - if break_pos == -1: - # No good break point, force break at limit - break_pos = MAX_LENGTH - - chunks.append(content[:break_pos]) - content = content[break_pos:].lstrip() - - # Send each chunk - for i, chunk in enumerate(chunks): + + for chunk in _split_message(msg.content): try: - html_content = _markdown_to_telegram_html(chunk) - await self._app.bot.send_message( - chat_id=chat_id, - text=html_content, - parse_mode="HTML" - ) + await self._app.bot.send_message(chat_id=chat_id, text=_markdown_to_telegram_html(chunk), parse_mode="HTML") except Exception as e: - # Fallback to plain text if HTML parsing fails - logger.warning(f"HTML parse failed for chunk {i+1}, falling back to plain text: {e}") + logger.warning(f"HTML parse failed, falling back to plain text: {e}") try: - await self._app.bot.send_message( - chat_id=chat_id, - text=chunk - ) + await self._app.bot.send_message(chat_id=chat_id, text=chunk) except Exception as e2: - logger.error(f"Error sending Telegram chunk {i+1}: {e2}") + logger.error(f"Error sending Telegram message: {e2}") async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" From 51d22b7ef46cfcd188068422cbc6bb23937382d9 Mon Sep 17 00:00:00 2001 From: Thomas Lisankie Date: Mon, 16 Feb 2026 00:14:34 -0500 Subject: [PATCH 025/415] Fix: _forward_command now builds sender_id with username for allowlist matching --- nanobot/channels/telegram.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 32f8c67..efd009e 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -226,8 +226,14 @@ class TelegramChannel(BaseChannel): """Forward slash commands to the bus for unified handling in AgentLoop.""" if not update.message or not update.effective_user: return + + user = update.effective_user + sender_id = str(user.id) + if user.username: + sender_id = f"{sender_id}|{user.username}" + await self._handle_message( - sender_id=str(update.effective_user.id), + sender_id=sender_id, chat_id=str(update.message.chat_id), content=update.message.text, ) From 90be9004487f74060bcb7f8a605a3656c5b4dfdf Mon Sep 17 00:00:00 2001 From: "Aleksander W. Oleszkiewicz (Alek)" <24917047+alekwo@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:49:44 +0100 Subject: [PATCH 026/415] Enhance Slack message formatting with new regex rules Added regex substitutions for strikethrough, URL formatting, and image URLs in Slack message conversion. --- nanobot/channels/slack.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 7298435..6b685d1 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -223,9 +223,21 @@ class SlackChannel(BaseChannel): # Step 3.a: ***text*** -> *_text_* converted_text = re.sub( r"(?m)(^|[^\*])\*\*\*([^\*].+?[^\*])\*\*\*([^\*]|$)", r"\1*_\2_*\3", converted_text) - # Step 3.b - ___text___ to *_text_* + # Step 3.b - ___text___ -> *_text_* converted_text = re.sub( r"(?m)(^|[^_])___([^_].+?[^_])___([^_]|$)", r"\1*_\2_*\3", converted_text) + # Convert strikethrough + # Step 4: ~~text~~ -> ~text~ + converted_text = re.sub( + r"(?m)(^|[^~])~~([^~].+?[^~])~~([^~]|$)", r"\1~\2~\3", converted_text) + # Convert URL formatting + # Step 6: [text](URL) -> + converted_text = re.sub( + r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"<\2|\1>", converted_text) + # Convert image URL + # Step 6: ![alt text](URL "title") -> + converted_text = re.sub( + r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\2>", converted_text) return converted_text def escape_mrkdwn(text: str) -> str: return (text.replace('&', '&') From 5d683da38f577a66a3ae7f1ceacbcdffcb4c56df Mon Sep 17 00:00:00 2001 From: "Aleksander W. Oleszkiewicz (Alek)" <24917047+alekwo@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:53:20 +0100 Subject: [PATCH 027/415] Fix regex for URL and image URL formatting --- nanobot/channels/slack.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 6b685d1..ef78887 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -233,11 +233,11 @@ class SlackChannel(BaseChannel): # Convert URL formatting # Step 6: [text](URL) -> converted_text = re.sub( - r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"<\2|\1>", converted_text) + r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text) # Convert image URL # Step 6: ![alt text](URL "title") -> converted_text = re.sub( - r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\2>", converted_text) + r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\1>", converted_text) return converted_text def escape_mrkdwn(text: str) -> str: return (text.replace('&', '&') From fe0341da5ba862bd206821467118f29cec9640d9 Mon Sep 17 00:00:00 2001 From: "Aleksander W. Oleszkiewicz (Alek)" <24917047+alekwo@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:58:38 +0100 Subject: [PATCH 028/415] Fix regex for URL formatting in Slack channel --- nanobot/channels/slack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index ef78887..25af83b 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -233,7 +233,7 @@ class SlackChannel(BaseChannel): # Convert URL formatting # Step 6: [text](URL) -> converted_text = re.sub( - r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text) + r"(?m)(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text) # Convert image URL # Step 6: ![alt text](URL "title") -> converted_text = re.sub( From 1ce586e9f515ca537353331f726307844e1b4e2f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 16 Feb 2026 11:43:36 +0000 Subject: [PATCH 029/415] fix: resolve Codex provider bugs and simplify implementation --- README.md | 31 ++++++++++ nanobot/cli/commands.py | 5 +- nanobot/config/schema.py | 10 ++-- nanobot/providers/openai_codex_provider.py | 69 +++++++++------------- nanobot/providers/registry.py | 2 - 5 files changed, 65 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index f6362bb..6a3ec3e 100644 --- a/README.md +++ b/README.md @@ -585,6 +585,37 @@ Config file: `~/.nanobot/config.json` | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `vllm` | LLM (local, any OpenAI-compatible server) | — | +| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | + +
+OpenAI Codex (OAuth) + +Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. + +**1. Login:** +```bash +nanobot provider login openai-codex +``` + +**2. Set model** (merge into `~/.nanobot/config.json`): +```json +{ + "agents": { + "defaults": { + "model": "openai-codex/gpt-5.1-codex" + } + } +} +``` + +**3. Chat:** +```bash +nanobot agent -m "Hello!" +``` + +> Docker users: use `docker run -it` for interactive OAuth login. + +
Custom Provider (Any OpenAI-compatible API) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b2d3f5a..235bfdc 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -290,10 +290,7 @@ def _make_provider(config: Config): # OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation. if provider_name == "openai_codex" or model.startswith("openai-codex/"): - return OpenAICodexProvider( - default_model=model, - api_base=p.api_base if p else None, - ) + return OpenAICodexProvider(default_model=model) if not model.startswith("bedrock/") and not (p and p.api_key): console.print("[red]Error: No API key configured.[/red]") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9d648be..15b6bb2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -192,7 +192,7 @@ class ProvidersConfig(BaseModel): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) # AiHubMix API gateway + openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) class GatewayConfig(BaseModel): @@ -252,19 +252,19 @@ class Config(BaseSettings): model_lower = (model or self.agents.defaults.model).lower() # Match by keyword (order follows PROVIDERS registry) - # Note: OAuth providers don't require api_key, so we check is_oauth flag for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and any(kw in model_lower for kw in spec.keywords): - # OAuth providers don't need api_key if spec.is_oauth or p.api_key: return p, spec.name # Fallback: gateways first, then others (follows registry order) - # OAuth providers are also valid fallbacks + # OAuth providers are NOT valid fallbacks — they require explicit model selection for spec in PROVIDERS: + if spec.is_oauth: + continue p = getattr(self.providers, spec.name, None) - if p and (spec.is_oauth or p.api_key): + if p and p.api_key: return p, spec.name return None, None diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index f6d56aa..5067438 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -8,6 +8,7 @@ import json from typing import Any, AsyncGenerator import httpx +from loguru import logger from oauth_cli_kit import get_token as get_codex_token from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest @@ -59,9 +60,9 @@ class OpenAICodexProvider(LLMProvider): try: content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True) except Exception as e: - # Certificate verification failed, downgrade to disable verification (security risk) if "CERTIFICATE_VERIFY_FAILED" not in str(e): raise + logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False") content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False) return LLMResponse( content=content, @@ -77,6 +78,7 @@ class OpenAICodexProvider(LLMProvider): def get_default_model(self) -> str: return self.default_model + def _strip_model_prefix(model: str) -> str: if model.startswith("openai-codex/"): return model.split("/", 1)[1] @@ -94,6 +96,7 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]: "content-type": "application/json", } + async def _request_codex( url: str, headers: dict[str, str], @@ -107,36 +110,25 @@ async def _request_codex( raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore"))) return await _consume_sse(response) + def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: - # Nanobot tool definitions already use the OpenAI function schema. + """Convert OpenAI function-calling schema to Codex flat format.""" converted: list[dict[str, Any]] = [] for tool in tools: - fn = tool.get("function") if isinstance(tool, dict) and tool.get("type") == "function" else None - if fn and isinstance(fn, dict): - name = fn.get("name") - desc = fn.get("description") - params = fn.get("parameters") - else: - name = tool.get("name") - desc = tool.get("description") - params = tool.get("parameters") - if not isinstance(name, str) or not name: - # Skip invalid tools to avoid Codex rejection. + fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool + name = fn.get("name") + if not name: continue - params = params or {} - if not isinstance(params, dict): - # Parameters must be a JSON Schema object. - params = {} - converted.append( - { - "type": "function", - "name": name, - "description": desc or "", - "parameters": params, - } - ) + params = fn.get("parameters") or {} + converted.append({ + "type": "function", + "name": name, + "description": fn.get("description") or "", + "parameters": params if isinstance(params, dict) else {}, + }) return converted + def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: system_prompt = "" input_items: list[dict[str, Any]] = [] @@ -183,7 +175,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st continue if role == "tool": - call_id = _extract_call_id(msg.get("tool_call_id")) + call_id, _ = _split_tool_call_id(msg.get("tool_call_id")) output_text = content if isinstance(content, str) else json.dumps(content) input_items.append( { @@ -196,6 +188,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st return system_prompt, input_items + def _convert_user_message(content: Any) -> dict[str, Any]: if isinstance(content, str): return {"role": "user", "content": [{"type": "input_text", "text": content}]} @@ -215,12 +208,6 @@ def _convert_user_message(content: Any) -> dict[str, Any]: return {"role": "user", "content": [{"type": "input_text", "text": ""}]} -def _extract_call_id(tool_call_id: Any) -> str: - if isinstance(tool_call_id, str) and tool_call_id: - return tool_call_id.split("|", 1)[0] - return "call_0" - - def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: if isinstance(tool_call_id, str) and tool_call_id: if "|" in tool_call_id: @@ -229,10 +216,12 @@ def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: return tool_call_id, None return "call_0", None + def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: raw = json.dumps(messages, ensure_ascii=True, sort_keys=True) return hashlib.sha256(raw.encode("utf-8")).hexdigest() + async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]: buffer: list[str] = [] async for line in response.aiter_lines(): @@ -252,6 +241,7 @@ async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], continue buffer.append(line) + async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]: content = "" tool_calls: list[ToolCallRequest] = [] @@ -308,16 +298,13 @@ async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequ return content, tool_calls, finish_reason + +_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"} + + def _map_finish_reason(status: str | None) -> str: - if not status: - return "stop" - if status == "completed": - return "stop" - if status == "incomplete": - return "length" - if status in {"failed", "cancelled"}: - return "error" - return "stop" + return _FINISH_REASON_MAP.get(status or "completed", "stop") + def _friendly_error(status_code: int, raw: str) -> str: if status_code == 429: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 1b4a776..59af5e1 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -53,7 +53,6 @@ class ProviderSpec: # OAuth-based providers (e.g., OpenAI Codex) don't use API keys is_oauth: bool = False # if True, uses OAuth flow instead of API key - oauth_provider: str = "" # OAuth provider name for token retrieval @property def label(self) -> str: @@ -176,7 +175,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), is_oauth=True, # OAuth-based authentication - oauth_provider="openai-codex", # OAuth provider identifier ), # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. From e8e7215d3ea2a99ec36859603a40c06b8fec26a9 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 16 Feb 2026 11:57:55 +0000 Subject: [PATCH 030/415] refactor: simplify Slack markdown-to-mrkdwn conversion --- nanobot/channels/slack.py | 154 +++++++++----------------------------- 1 file changed, 37 insertions(+), 117 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 25af83b..dd18e79 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -204,126 +204,46 @@ class SlackChannel(BaseChannel): return text return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() + # Markdown → Slack mrkdwn formatting rules (order matters: longest markers first) + _MD_TO_SLACK = ( + (r'(?m)(^|[^\*])\*\*\*(.+?)\*\*\*([^\*]|$)', r'\1*_\2_*\3'), # ***bold italic*** + (r'(?m)(^|[^_])___(.+?)___([^_]|$)', r'\1*_\2_*\3'), # ___bold italic___ + (r'(?m)(^|[^\*])\*\*(.+?)\*\*([^\*]|$)', r'\1*\2*\3'), # **bold** + (r'(?m)(^|[^_])__(.+?)__([^_]|$)', r'\1*\2*\3'), # __bold__ + (r'(?m)(^|[^\*])\*(.+?)\*([^\*]|$)', r'\1_\2_\3'), # *italic* + (r'(?m)(^|[^~])~~(.+?)~~([^~]|$)', r'\1~\2~\3'), # ~~strike~~ + (r'(?m)(^|[^!])\[(.+?)\]\((http.+?)\)', r'\1<\3|\2>'), # [text](url) + (r'!\[.+?\]\((http.+?)(?:\s".*?")?\)', r'<\1>'), # ![alt](url) + ) + _TABLE_RE = re.compile(r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*') + def _convert_markdown(self, text: str) -> str: + """Convert standard Markdown to Slack mrkdwn format.""" if not text: return text - def convert_formatting(input: str) -> str: - # Convert italics - # Step 1: *text* -> _text_ - converted_text = re.sub( - r"(?m)(^|[^\*])\*([^\*].+?[^\*])\*([^\*]|$)", r"\1_\2_\3", input) - # Convert bold - # Step 2.a: **text** -> *text* - converted_text = re.sub( - r"(?m)(^|[^\*])\*\*([^\*].+?[^\*])\*\*([^\*]|$)", r"\1*\2*\3", converted_text) - # Step 2.b: __text__ -> *text* - converted_text = re.sub( - r"(?m)(^|[^_])__([^_].+?[^_])__([^_]|$)", r"\1*\2*\3", converted_text) - # convert bold italics - # Step 3.a: ***text*** -> *_text_* - converted_text = re.sub( - r"(?m)(^|[^\*])\*\*\*([^\*].+?[^\*])\*\*\*([^\*]|$)", r"\1*_\2_*\3", converted_text) - # Step 3.b - ___text___ -> *_text_* - converted_text = re.sub( - r"(?m)(^|[^_])___([^_].+?[^_])___([^_]|$)", r"\1*_\2_*\3", converted_text) - # Convert strikethrough - # Step 4: ~~text~~ -> ~text~ - converted_text = re.sub( - r"(?m)(^|[^~])~~([^~].+?[^~])~~([^~]|$)", r"\1~\2~\3", converted_text) - # Convert URL formatting - # Step 6: [text](URL) -> - converted_text = re.sub( - r"(?m)(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text) - # Convert image URL - # Step 6: ![alt text](URL "title") -> - converted_text = re.sub( - r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\1>", converted_text) - return converted_text - def escape_mrkdwn(text: str) -> str: - return (text.replace('&', '&') - .replace('<', '<') - .replace('>', '>')) - def convert_table(match: re.Match) -> str: - # Slack doesn't support Markdown tables - # Convert table to bulleted list with sections - # -- input_md: - # Some text before the table. - # | Col1 | Col2 | Col3 | - # |-----|----------|------| - # | Row1 - A | Row1 - B | Row1 - C | - # | Row2 - D | Row2 - E | Row2 - F | - # - # Some text after the table. - # - # -- will be converted to: - # Some text before the table. - # > *Col1* : Row1 - A - # • *Col2*: Row1 - B - # • *Col3*: Row1 - C - # > *Col1* : Row2 - D - # • *Col2*: Row2 - E - # • *Col3*: Row2 - F - # - # Some text after the table. - - block = match.group(0).strip() - lines = [line.strip() - for line in block.split('\n') if line.strip()] + for pattern, repl in self._MD_TO_SLACK: + text = re.sub(pattern, repl, text) + return self._TABLE_RE.sub(self._convert_table, text) - if len(lines) < 2: - return block + @staticmethod + def _convert_table(match: re.Match) -> str: + """Convert Markdown table to Slack quote + bullet format.""" + lines = [l.strip() for l in match.group(0).strip().split('\n') if l.strip()] + if len(lines) < 2: + return match.group(0) - # 1. Parse Headers from the first line - # Split by pipe, filtering out empty start/end strings caused by outer pipes - header_line = lines[0].strip('|') - headers = [escape_mrkdwn(h.strip()) - for h in header_line.split('|')] + headers = [h.strip() for h in lines[0].strip('|').split('|')] + start = 2 if not re.search(r'[^|\-\s:]', lines[1]) else 1 - # 2. Identify Data Start (Skip Separator) - data_start_idx = 1 - # If line 2 contains only separator chars (|-: ), skip it - if len(lines) > 1 and not re.search(r'[^|\-\s:]', lines[1]): - data_start_idx = 2 - - # 3. Process Data Rows - slack_lines = [] - for line in lines[data_start_idx:]: - # Clean and split cells - clean_line = line.strip('|') - cells = [escape_mrkdwn(c.strip()) - for c in clean_line.split('|')] - - # Normalize cell count to match headers - if len(cells) < len(headers): - cells += [''] * (len(headers) - len(cells)) - cells = cells[:len(headers)] - - # Skip empty rows - if not any(cells): - continue - - # Key is the first column - key = cells[0] - label = headers[0] - slack_lines.append( - f"> *{label}* : {key}" if key else "> *{label}* : --") - - # Sub-bullets for remaining columns - for i, cell in enumerate(cells[1:], 1): - if cell: - label = headers[i] if i < len(headers) else "Col" - slack_lines.append(f" • *{label}*: {cell}") - - slack_lines.append("") # Spacer between items - - return "\n".join(slack_lines).rstrip() - - # (?m) : Multiline mode so ^ matches start of line and $ end of line - # ^\| : Start of line and a literal pipe - # .*?\|$ : Rest of the line and a pipe at the end - # (?:\n(?:\|\:?-{3,}\:?)*?\|$) : A heading line with at least three dashes in each column, pipes, and : e.g. |:---|----|:---:| - # (?:\n\|.*?\|$)* : Zero or more subsequent lines that ALSO start and end with a pipe - table_pattern = r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*' - - input_md = convert_formatting(text) - return re.sub(table_pattern, convert_table, input_md) + result: list[str] = [] + for line in lines[start:]: + cells = [c.strip() for c in line.strip('|').split('|')] + cells = (cells + [''] * len(headers))[:len(headers)] + if not any(cells): + continue + result.append(f"> *{headers[0]}*: {cells[0] or '--'}") + for i, cell in enumerate(cells[1:], 1): + if cell and i < len(headers): + result.append(f" \u2022 *{headers[i]}*: {cell}") + result.append("") + return '\n'.join(result).rstrip() From ffbb264a5d41941f793ba4910de26df04874ec26 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 16 Feb 2026 12:11:03 +0000 Subject: [PATCH 031/415] fix: consistent sender_id for Telegram command allowlist matching --- nanobot/channels/telegram.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index efd009e..d2ce74c 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -222,18 +222,18 @@ class TelegramChannel(BaseChannel): "Type /help to see available commands." ) + @staticmethod + def _sender_id(user) -> str: + """Build sender_id with username for allowlist matching.""" + sid = str(user.id) + return f"{sid}|{user.username}" if user.username else sid + async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Forward slash commands to the bus for unified handling in AgentLoop.""" if not update.message or not update.effective_user: return - - user = update.effective_user - sender_id = str(user.id) - if user.username: - sender_id = f"{sender_id}|{user.username}" - await self._handle_message( - sender_id=sender_id, + sender_id=self._sender_id(update.effective_user), chat_id=str(update.message.chat_id), content=update.message.text, ) @@ -246,11 +246,7 @@ class TelegramChannel(BaseChannel): message = update.message user = update.effective_user chat_id = message.chat_id - - # Use stable numeric ID, but keep username for allowlist compatibility - sender_id = str(user.id) - if user.username: - sender_id = f"{sender_id}|{user.username}" + sender_id = self._sender_id(user) # Store chat_id for replies self._chat_ids[sender_id] = chat_id From 8f49b52079137af90aa56cb164848add110f284e Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Mon, 16 Feb 2026 15:22:15 +0300 Subject: [PATCH 032/415] Scope sessions to workspace with legacy fallback --- nanobot/session/manager.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index bce12a1..e2d8e5c 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -42,8 +42,30 @@ class Session: self.updated_at = datetime.now() def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: - """Get recent messages in LLM format (role + content only).""" - return [{"role": m["role"], "content": m["content"]} for m in self.messages[-max_messages:]] + """ + Get recent messages in LLM format. + + Preserves tool metadata for replay/debugging fidelity. + """ + history: list[dict[str, Any]] = [] + for msg in self.messages[-max_messages:]: + llm_msg: dict[str, Any] = { + "role": msg["role"], + "content": msg.get("content", ""), + } + + if msg["role"] == "assistant" and "tool_calls" in msg: + llm_msg["tool_calls"] = msg["tool_calls"] + + if msg["role"] == "tool": + if "tool_call_id" in msg: + llm_msg["tool_call_id"] = msg["tool_call_id"] + if "name" in msg: + llm_msg["name"] = msg["name"] + + history.append(llm_msg) + + return history def clear(self) -> None: """Clear all messages and reset session to initial state.""" @@ -61,13 +83,19 @@ class SessionManager: def __init__(self, workspace: Path): self.workspace = workspace - self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions") + self.sessions_dir = ensure_dir(self.workspace / "sessions") + self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions" self._cache: dict[str, Session] = {} def _get_session_path(self, key: str) -> Path: """Get the file path for a session.""" safe_key = safe_filename(key.replace(":", "_")) return self.sessions_dir / f"{safe_key}.jsonl" + + def _get_legacy_session_path(self, key: str) -> Path: + """Get the legacy global session path for backward compatibility.""" + safe_key = safe_filename(key.replace(":", "_")) + return self.legacy_sessions_dir / f"{safe_key}.jsonl" def get_or_create(self, key: str) -> Session: """ @@ -92,6 +120,10 @@ class SessionManager: def _load(self, key: str) -> Session | None: """Load a session from disk.""" path = self._get_session_path(key) + if not path.exists(): + legacy_path = self._get_legacy_session_path(key) + if legacy_path.exists(): + path = legacy_path if not path.exists(): return None From db0e8aa61b2a69fc81a43a04c86dfbe67d3b2631 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 16 Feb 2026 12:39:39 +0000 Subject: [PATCH 033/415] fix: handle Telegram message length limit with smart splitting --- .gitignore | 2 +- README.md | 2 +- nanobot/channels/telegram.py | 28 ++- tests/test_telegram_channel.py | 416 --------------------------------- 4 files changed, 18 insertions(+), 430 deletions(-) delete mode 100644 tests/test_telegram_channel.py diff --git a/.gitignore b/.gitignore index 742d593..d7b930d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log - +tests/ diff --git a/README.md b/README.md index 6a3ec3e..0584dd8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,663 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,668 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 8166a9d..c9978c2 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -82,17 +82,20 @@ def _split_message(content: str, max_len: int = 4000) -> list[str]: """Split content into chunks within max_len, preferring line breaks.""" if len(content) <= max_len: return [content] - chunks = [] - while len(content) > max_len: - chunk = content[:max_len] - break_pos = chunk.rfind('\n') - if break_pos == -1: - break_pos = chunk.rfind(' ') - if break_pos == -1: - break_pos = max_len - chunks.append(chunk[:break_pos]) - content = content[break_pos:].lstrip() - return chunks + [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + pos = cut.rfind('\n') + if pos == -1: + pos = cut.rfind(' ') + if pos == -1: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks class TelegramChannel(BaseChannel): @@ -211,7 +214,8 @@ class TelegramChannel(BaseChannel): for chunk in _split_message(msg.content): try: - await self._app.bot.send_message(chat_id=chat_id, text=_markdown_to_telegram_html(chunk), parse_mode="HTML") + html = _markdown_to_telegram_html(chunk) + await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") except Exception as e: logger.warning(f"HTML parse failed, falling back to plain text: {e}") try: diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py deleted file mode 100644 index 8e9a4d4..0000000 --- a/tests/test_telegram_channel.py +++ /dev/null @@ -1,416 +0,0 @@ -"""Tests for Telegram channel implementation.""" - -import pytest -from unittest.mock import AsyncMock, MagicMock - -from nanobot.bus.events import OutboundMessage -from nanobot.bus.queue import MessageBus -from nanobot.channels.telegram import TelegramChannel, _markdown_to_telegram_html -from nanobot.config.schema import TelegramConfig - - -def _make_config() -> TelegramConfig: - return TelegramConfig( - enabled=True, - token="fake-token", - allow_from=[], - proxy=None, - ) - - -class TestMarkdownToTelegramHtml: - """Tests for markdown to Telegram HTML conversion.""" - - def test_empty_text(self) -> None: - assert _markdown_to_telegram_html("") == "" - - def test_plain_text_passthrough(self) -> None: - text = "Hello world" - assert _markdown_to_telegram_html(text) == "Hello world" - - def test_bold_double_asterisks(self) -> None: - text = "This is **bold** text" - assert _markdown_to_telegram_html(text) == "This is bold text" - - def test_bold_double_underscore(self) -> None: - text = "This is __bold__ text" - assert _markdown_to_telegram_html(text) == "This is bold text" - - def test_italic_underscore(self) -> None: - text = "This is _italic_ text" - assert _markdown_to_telegram_html(text) == "This is italic text" - - def test_italic_not_inside_words(self) -> None: - text = "some_var_name" - assert _markdown_to_telegram_html(text) == "some_var_name" - - def test_strikethrough(self) -> None: - text = "This is ~~deleted~~ text" - assert _markdown_to_telegram_html(text) == "This is deleted text" - - def test_inline_code(self) -> None: - text = "Use `print()` function" - result = _markdown_to_telegram_html(text) - assert "print()" in result - - def test_inline_code_escapes_html(self) -> None: - text = "Use `
` tag" - result = _markdown_to_telegram_html(text) - assert "<div>" in result - - def test_code_block(self) -> None: - text = """Here is code: -```python -def hello(): - return "world" -``` -Done. -""" - result = _markdown_to_telegram_html(text) - assert "
" in result
-        assert "def hello():" in result
-        assert "
" in result - - def test_code_block_escapes_html(self) -> None: - text = """``` -
test
-```""" - result = _markdown_to_telegram_html(text) - assert "<div>test</div>" in result - - def test_headers_stripped(self) -> None: - text = "# Header 1\n## Header 2\n### Header 3" - result = _markdown_to_telegram_html(text) - assert "# Header 1" not in result - assert "Header 1" in result - assert "Header 2" in result - assert "Header 3" in result - - def test_blockquotes_stripped(self) -> None: - text = "> This is a quote\nMore text" - result = _markdown_to_telegram_html(text) - assert "> " not in result - assert "This is a quote" in result - - def test_links_converted(self) -> None: - text = "Check [this link](https://example.com) out" - result = _markdown_to_telegram_html(text) - assert 'this link' in result - - def test_bullet_list_converted(self) -> None: - text = "- Item 1\n* Item 2" - result = _markdown_to_telegram_html(text) - assert "• Item 1" in result - assert "• Item 2" in result - - def test_html_special_chars_escaped(self) -> None: - text = "5 < 10 and 10 > 5" - result = _markdown_to_telegram_html(text) - assert "5 < 10" in result - assert "10 > 5" in result - - def test_complex_nested_formatting(self) -> None: - text = "**Bold _and italic_** and `code`" - result = _markdown_to_telegram_html(text) - assert "Bold and italic" in result - assert "code" in result - - -class TestTelegramChannelSend: - """Tests for TelegramChannel.send() method.""" - - @pytest.mark.asyncio - async def test_send_short_message_single_chunk(self, monkeypatch) -> None: - """Short messages are sent as a single message.""" - sent_messages = [] - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content="Hello world" - )) - - assert len(sent_messages) == 1 - assert sent_messages[0]["chat_id"] == 123456 - assert "Hello world" in sent_messages[0]["text"] - assert sent_messages[0]["parse_mode"] == "HTML" - - @pytest.mark.asyncio - async def test_send_long_message_split_into_chunks(self, monkeypatch) -> None: - """Long messages exceeding 4000 chars are split.""" - sent_messages = [] - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - - # Create a message longer than 4000 chars - long_content = "A" * 1000 + "\n" + "B" * 1000 + "\n" + "C" * 1000 + "\n" + "D" * 1000 + "\n" + "E" * 1000 - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content=long_content - )) - - assert len(sent_messages) == 2 # Should be split into 2 messages - assert all(m["chat_id"] == 123456 for m in sent_messages) - - @pytest.mark.asyncio - async def test_send_splits_at_newline_when_possible(self, monkeypatch) -> None: - """Message splitting prefers newline boundaries.""" - sent_messages = [] - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - - # Create content with clear paragraph breaks - paragraphs = [f"Paragraph {i}: " + "x" * 100 for i in range(50)] - content = "\n".join(paragraphs) - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content=content - )) - - # Each chunk should end with a complete paragraph (no partial lines) - for msg in sent_messages: - # Message should not start with whitespace after stripping - text = msg["text"] - assert text == text.lstrip() - - @pytest.mark.asyncio - async def test_send_falls_back_to_space_boundary(self, monkeypatch) -> None: - """When no newline available, split at space boundary.""" - sent_messages = [] - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - - # Long content without newlines but with spaces - content = "word " * 2000 # ~10000 chars - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content=content - )) - - assert len(sent_messages) >= 2 - - @pytest.mark.asyncio - async def test_send_forces_split_when_no_good_boundary(self, monkeypatch) -> None: - """When no newline or space, force split at max length.""" - sent_messages = [] - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - - # Long content without any spaces or newlines - content = "A" * 10000 - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content=content - )) - - assert len(sent_messages) >= 2 - # Verify all chunks combined equal original - combined = "".join(m["text"] for m in sent_messages) - assert combined == content - - @pytest.mark.asyncio - async def test_send_invalid_chat_id_logs_error(self, monkeypatch) -> None: - """Invalid chat_id should log error and not send.""" - sent_messages = [] - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - sent_messages.append({"chat_id": chat_id, "text": text}) - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="not-a-number", - content="Hello" - )) - - assert len(sent_messages) == 0 - - @pytest.mark.asyncio - async def test_send_html_parse_error_falls_back_to_plain_text(self, monkeypatch) -> None: - """When HTML parsing fails, fall back to plain text.""" - sent_messages = [] - call_count = 0 - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - nonlocal call_count - call_count += 1 - if parse_mode == "HTML" and call_count == 1: - raise Exception("Bad markup") - sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode}) - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content="Hello **world**" - )) - - # Should have 2 calls: first HTML (fails), second plain text (succeeds) - assert call_count == 2 - assert len(sent_messages) == 1 - assert sent_messages[0]["parse_mode"] is None # Plain text - assert "Hello **world**" in sent_messages[0]["text"] - - @pytest.mark.asyncio - async def test_send_not_running_warns(self, monkeypatch) -> None: - """If bot not running, log warning.""" - warning_logged = [] - - def mock_warning(msg, *args): - warning_logged.append(msg) - - monkeypatch.setattr("nanobot.channels.telegram.logger", MagicMock(warning=mock_warning)) - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = None # Not running - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content="Hello" - )) - - assert any("not running" in str(m) for m in warning_logged) - - @pytest.mark.asyncio - async def test_send_stops_typing_indicator(self, monkeypatch) -> None: - """Sending message should stop typing indicator.""" - stopped_chats = [] - - class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None): - pass - - fake_app = MagicMock() - fake_app.bot = FakeBot() - - channel = TelegramChannel(_make_config(), MessageBus()) - channel._app = fake_app - channel._stop_typing = lambda chat_id: stopped_chats.append(chat_id) - - await channel.send(OutboundMessage( - channel="telegram", - chat_id="123456", - content="Hello" - )) - - assert "123456" in stopped_chats - - -class TestTelegramChannelTyping: - """Tests for typing indicator functionality.""" - - @pytest.mark.asyncio - async def test_start_typing_creates_task(self) -> None: - channel = TelegramChannel(_make_config(), MessageBus()) - - # Mock _typing_loop to avoid actual async execution - channel._typing_loop = AsyncMock() - - channel._start_typing("123456") - - assert "123456" in channel._typing_tasks - assert not channel._typing_tasks["123456"].done() - - # Clean up - channel._stop_typing("123456") - - def test_stop_typing_cancels_task(self) -> None: - channel = TelegramChannel(_make_config(), MessageBus()) - - # Create a mock task - mock_task = MagicMock() - mock_task.done.return_value = False - channel._typing_tasks["123456"] = mock_task - - channel._stop_typing("123456") - - mock_task.cancel.assert_called_once() - assert "123456" not in channel._typing_tasks - - -class TestTelegramChannelMediaExtensions: - """Tests for media file extension detection.""" - - def test_get_extension_from_mime_type(self) -> None: - channel = TelegramChannel(_make_config(), MessageBus()) - - assert channel._get_extension("image", "image/jpeg") == ".jpg" - assert channel._get_extension("image", "image/png") == ".png" - assert channel._get_extension("image", "image/gif") == ".gif" - assert channel._get_extension("audio", "audio/ogg") == ".ogg" - assert channel._get_extension("audio", "audio/mpeg") == ".mp3" - - def test_get_extension_fallback_to_type(self) -> None: - channel = TelegramChannel(_make_config(), MessageBus()) - - assert channel._get_extension("image", None) == ".jpg" - assert channel._get_extension("voice", None) == ".ogg" - assert channel._get_extension("audio", None) == ".mp3" - - def test_get_extension_unknown_type(self) -> None: - channel = TelegramChannel(_make_config(), MessageBus()) - - assert channel._get_extension("unknown", None) == "" From ed5593bbe094683135078407acd2d263d056f6c3 Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Mon, 16 Feb 2026 13:53:24 +0100 Subject: [PATCH 034/415] slack: use slackify-markdown for proper mrkdwn formatting Replace the regex-based Markdown-to-Slack converter with the slackify-markdown library, which uses a proper Markdown parser (markdown-it-py, already a dependency) to correctly handle headings, bold/italic, code blocks, links, bullet lists, and strikethrough. The regex approach didn't handle headings (###), bullet lists (* ), or code block protection, causing raw Markdown to leak into Slack messages. Net -40 lines. Assisted-by: Claude 4.6 Opus (Anthropic) --- nanobot/channels/slack.py | 47 +++------------------------------------ pyproject.toml | 1 + 2 files changed, 4 insertions(+), 44 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index dd18e79..a56ee1a 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -10,6 +10,8 @@ from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.web.async_client import AsyncWebClient +from slackify_markdown import slackify_markdown + from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel @@ -84,7 +86,7 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" await self._web_client.chat_postMessage( channel=msg.chat_id, - text=self._convert_markdown(msg.content) or "", + text=slackify_markdown(msg.content) if msg.content else "", thread_ts=thread_ts if use_thread else None, ) except Exception as e: @@ -204,46 +206,3 @@ class SlackChannel(BaseChannel): return text return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() - # Markdown → Slack mrkdwn formatting rules (order matters: longest markers first) - _MD_TO_SLACK = ( - (r'(?m)(^|[^\*])\*\*\*(.+?)\*\*\*([^\*]|$)', r'\1*_\2_*\3'), # ***bold italic*** - (r'(?m)(^|[^_])___(.+?)___([^_]|$)', r'\1*_\2_*\3'), # ___bold italic___ - (r'(?m)(^|[^\*])\*\*(.+?)\*\*([^\*]|$)', r'\1*\2*\3'), # **bold** - (r'(?m)(^|[^_])__(.+?)__([^_]|$)', r'\1*\2*\3'), # __bold__ - (r'(?m)(^|[^\*])\*(.+?)\*([^\*]|$)', r'\1_\2_\3'), # *italic* - (r'(?m)(^|[^~])~~(.+?)~~([^~]|$)', r'\1~\2~\3'), # ~~strike~~ - (r'(?m)(^|[^!])\[(.+?)\]\((http.+?)\)', r'\1<\3|\2>'), # [text](url) - (r'!\[.+?\]\((http.+?)(?:\s".*?")?\)', r'<\1>'), # ![alt](url) - ) - _TABLE_RE = re.compile(r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*') - - def _convert_markdown(self, text: str) -> str: - """Convert standard Markdown to Slack mrkdwn format.""" - if not text: - return text - for pattern, repl in self._MD_TO_SLACK: - text = re.sub(pattern, repl, text) - return self._TABLE_RE.sub(self._convert_table, text) - - @staticmethod - def _convert_table(match: re.Match) -> str: - """Convert Markdown table to Slack quote + bullet format.""" - lines = [l.strip() for l in match.group(0).strip().split('\n') if l.strip()] - if len(lines) < 2: - return match.group(0) - - headers = [h.strip() for h in lines[0].strip('|').split('|')] - start = 2 if not re.search(r'[^|\-\s:]', lines[1]) else 1 - - result: list[str] = [] - for line in lines[start:]: - cells = [c.strip() for c in line.strip('|').split('|')] - cells = (cells + [''] * len(headers))[:len(headers)] - if not any(cells): - continue - result.append(f"> *{headers[0]}*: {cells[0] or '--'}") - for i, cell in enumerate(cells[1:], 1): - if cell and i < len(headers): - result.append(f" \u2022 *{headers[i]}*: {cell}") - result.append("") - return '\n'.join(result).rstrip() diff --git a/pyproject.toml b/pyproject.toml index f5fd60c..6261653 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "python-socketio>=5.11.0", "msgpack>=1.0.8", "slack-sdk>=3.26.0", + "slackify-markdown>=0.2.0", "qq-botpy>=1.0.0", "python-socks[asyncio]>=2.4.0", "prompt-toolkit>=3.0.0", From c9926153b263e0a451c37de89a75024ab5a2d4bf Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Mon, 16 Feb 2026 14:03:33 +0100 Subject: [PATCH 035/415] Add table-to-text conversion for Slack messages Slack has no native table support, so Markdown tables are passed through verbatim by slackify-markdown. Pre-process tables into readable key-value rows before converting to mrkdwn. Assisted-by: Claude 4.6 Opus (Anthropic) --- nanobot/channels/slack.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index a56ee1a..e5fff63 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -86,7 +86,7 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" await self._web_client.chat_postMessage( channel=msg.chat_id, - text=slackify_markdown(msg.content) if msg.content else "", + text=self._to_mrkdwn(msg.content), thread_ts=thread_ts if use_thread else None, ) except Exception as e: @@ -206,3 +206,30 @@ class SlackChannel(BaseChannel): return text return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() + _TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*") + + @classmethod + def _to_mrkdwn(cls, text: str) -> str: + """Convert Markdown to Slack mrkdwn, including tables.""" + if not text: + return "" + text = cls._TABLE_RE.sub(cls._convert_table, text) + return slackify_markdown(text) + + @staticmethod + def _convert_table(match: re.Match) -> str: + """Convert a Markdown table to a Slack-readable list.""" + lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()] + if len(lines) < 2: + return match.group(0) + headers = [h.strip() for h in lines[0].strip("|").split("|")] + start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1 + rows: list[str] = [] + for line in lines[start:]: + cells = [c.strip() for c in line.strip("|").split("|")] + cells = (cells + [""] * len(headers))[: len(headers)] + parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]] + if parts: + rows.append(" · ".join(parts)) + return "\n".join(rows) + From a219a91bc5e41a311d7e48b752aa489039ccd281 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 16 Feb 2026 13:42:33 +0000 Subject: [PATCH 036/415] feat: support openclaw/clawhub skill metadata format --- nanobot/agent/skills.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index ead9f5b..5b841f3 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -167,10 +167,10 @@ class SkillsLoader: return content def _parse_nanobot_metadata(self, raw: str) -> dict: - """Parse nanobot metadata JSON from frontmatter.""" + """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" try: data = json.loads(raw) - return data.get("nanobot", {}) if isinstance(data, dict) else {} + return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {} except (json.JSONDecodeError, TypeError): return {} From 5033ac175987d08f8e28f39c1fe346a593d72d73 Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:02:12 +0100 Subject: [PATCH 037/415] Added Github Copilot Provider --- nanobot/cli/commands.py | 2 +- nanobot/config/schema.py | 1 + nanobot/providers/registry.py | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 235bfdc..affd421 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -292,7 +292,7 @@ def _make_provider(config: Config): if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider(default_model=model) - if not model.startswith("bedrock/") and not (p and p.api_key): + if not model.startswith("bedrock/") and not (p and p.api_key) and provider_name != "github_copilot": console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 15b6bb2..64609ec 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -193,6 +193,7 @@ class ProvidersConfig(BaseModel): minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) + github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) class GatewayConfig(BaseModel): diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 59af5e1..1e760d6 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -177,6 +177,25 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( is_oauth=True, # OAuth-based authentication ), + # Github Copilot: uses OAuth, not API key. + ProviderSpec( + name="github_copilot", + keywords=("github_copilot", "copilot"), + env_key="", # OAuth-based, no API key + display_name="Github Copilot", + litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model + skip_prefixes=("github_copilot/",), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + is_oauth=True, # OAuth-based authentication + ), + # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. ProviderSpec( name="deepseek", From 23b7e1ef5e944a05653342a70efa2e1fbba9109f Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:29:03 +0100 Subject: [PATCH 038/415] Handle media files (voice messages, audio, images, documents) on Telegram Channel --- nanobot/agent/tools/message.py | 12 +++++-- nanobot/channels/telegram.py | 64 +++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 347830f..3853725 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -52,6 +52,11 @@ class MessageTool(Tool): "chat_id": { "type": "string", "description": "Optional: target chat/user ID" + }, + "media": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: list of file paths to attach (images, audio, documents)" } }, "required": ["content"] @@ -62,6 +67,7 @@ class MessageTool(Tool): content: str, channel: str | None = None, chat_id: str | None = None, + media: list[str] | None = None, **kwargs: Any ) -> str: channel = channel or self._default_channel @@ -76,11 +82,13 @@ class MessageTool(Tool): msg = OutboundMessage( channel=channel, chat_id=chat_id, - content=content + content=content, + media=media or [] ) try: await self._send_callback(msg) - return f"Message sent to {channel}:{chat_id}" + media_info = f" with {len(media)} attachments" if media else "" + return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: return f"Error sending message: {str(e)}" diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c9978c2..8f135e4 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -198,6 +198,18 @@ class TelegramChannel(BaseChannel): await self._app.shutdown() self._app = None + def _get_media_type(self, path: str) -> str: + """Guess media type from file extension.""" + path = path.lower() + if path.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): + return "photo" + elif path.endswith('.ogg'): + return "voice" + elif path.endswith(('.mp3', '.m4a', '.wav', '.aac')): + return "audio" + else: + return "document" + async def send(self, msg: OutboundMessage) -> None: """Send a message through Telegram.""" if not self._app: @@ -212,16 +224,50 @@ class TelegramChannel(BaseChannel): logger.error(f"Invalid chat_id: {msg.chat_id}") return - for chunk in _split_message(msg.content): - try: - html = _markdown_to_telegram_html(chunk) - await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") - except Exception as e: - logger.warning(f"HTML parse failed, falling back to plain text: {e}") + # Handle media files + if msg.media: + for media_path in msg.media: try: - await self._app.bot.send_message(chat_id=chat_id, text=chunk) - except Exception as e2: - logger.error(f"Error sending Telegram message: {e2}") + media_type = self._get_media_type(media_path) + + # Determine caption (only for first media or if explicitly set, + # but here we keep it simple: content is sent separately if media is present + # to avoid length issues, unless we want to attach it to the first media) + # For simplicity: send media first, then text if present. + # Or: if single media, attach text as caption. + + # Let's attach content as caption to the last media if single, + # otherwise send text separately. + + with open(media_path, 'rb') as f: + if media_type == "photo": + await self._app.bot.send_photo(chat_id=chat_id, photo=f) + elif media_type == "voice": + await self._app.bot.send_voice(chat_id=chat_id, voice=f) + elif media_type == "audio": + await self._app.bot.send_audio(chat_id=chat_id, audio=f) + else: + await self._app.bot.send_document(chat_id=chat_id, document=f) + + except Exception as e: + logger.error(f"Failed to send media {media_path}: {e}") + await self._app.bot.send_message( + chat_id=chat_id, + text=f"[Failed to send file: {media_path}]" + ) + + # Send text content if present + if msg.content and msg.content != "[empty message]": + for chunk in _split_message(msg.content): + try: + html = _markdown_to_telegram_html(chunk) + await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") + except Exception as e: + logger.warning(f"HTML parse failed, falling back to plain text: {e}") + try: + await self._app.bot.send_message(chat_id=chat_id, text=chunk) + except Exception as e2: + logger.error(f"Error sending Telegram message: {e2}") async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" From ae903e983c2c0a7aac3f57fc9a66f7e13dbf4456 Mon Sep 17 00:00:00 2001 From: jopo Date: Mon, 16 Feb 2026 17:49:19 -0800 Subject: [PATCH 039/415] fix(cron): improve timezone scheduling and tz propagation --- nanobot/agent/tools/cron.py | 20 +++++++++++++++++--- nanobot/cli/commands.py | 22 +++++++++++++++++++--- nanobot/cron/service.py | 3 ++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 9f1ecdb..9dc31c2 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -50,6 +50,10 @@ class CronTool(Tool): "type": "string", "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" }, + "tz": { + "type": "string", + "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')" + }, "at": { "type": "string", "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" @@ -68,30 +72,40 @@ class CronTool(Tool): message: str = "", every_seconds: int | None = None, cron_expr: str | None = None, + tz: str | None = None, at: str | None = None, job_id: str | None = None, **kwargs: Any ) -> str: if action == "add": - return self._add_job(message, every_seconds, cron_expr, at) + return self._add_job(message, every_seconds, cron_expr, tz, at) elif action == "list": return self._list_jobs() elif action == "remove": return self._remove_job(job_id) return f"Unknown action: {action}" - def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str: + def _add_job( + self, + message: str, + every_seconds: int | None, + cron_expr: str | None, + tz: str | None, + at: str | None, + ) -> str: if not message: return "Error: message is required for add" if not self._channel or not self._chat_id: return "Error: no session context (channel/chat_id)" + if tz and not cron_expr: + return "Error: tz can only be used with cron_expr" # Build schedule delete_after = False if every_seconds: schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr) + schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) elif at: from datetime import datetime dt = datetime.fromisoformat(at) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 235bfdc..3b58db5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -719,21 +719,32 @@ def cron_list( table.add_column("Status") table.add_column("Next Run") + import datetime import time + from zoneinfo import ZoneInfo for job in jobs: # Format schedule if job.schedule.kind == "every": sched = f"every {(job.schedule.every_ms or 0) // 1000}s" elif job.schedule.kind == "cron": sched = job.schedule.expr or "" + if job.schedule.tz: + sched = f"{sched} ({job.schedule.tz})" else: sched = "one-time" # Format next run next_run = "" if job.state.next_run_at_ms: - next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)) - next_run = next_time + ts = job.state.next_run_at_ms / 1000 + if job.schedule.kind == "cron" and job.schedule.tz: + try: + dt = datetime.fromtimestamp(ts, ZoneInfo(job.schedule.tz)) + next_run = dt.strftime("%Y-%m-%d %H:%M") + except Exception: + next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + else: + next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" @@ -748,6 +759,7 @@ def cron_add( message: str = typer.Option(..., "--message", "-m", help="Message for agent"), every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), + tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"), at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), to: str = typer.Option(None, "--to", help="Recipient for delivery"), @@ -758,11 +770,15 @@ def cron_add( from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule + if tz and not cron_expr: + console.print("[red]Error: --tz can only be used with --cron[/red]") + raise typer.Exit(1) + # Determine schedule type if every: schedule = CronSchedule(kind="every", every_ms=every * 1000) elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr) + schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) elif at: import datetime dt = datetime.datetime.fromisoformat(at) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 4da845a..9fda214 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -32,7 +32,8 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: try: from croniter import croniter from zoneinfo import ZoneInfo - base_time = time.time() + # Use the caller-provided reference time for deterministic scheduling. + base_time = now_ms / 1000 tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo base_dt = datetime.fromtimestamp(base_time, tz=tz) cron = croniter(schedule.expr, base_dt) From 778a93370a7cfaa17c5efdec09487d1ff67f8389 Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Tue, 17 Feb 2026 03:52:54 +0100 Subject: [PATCH 040/415] Enable Cron management on CLI Agent. --- nanobot/cli/commands.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 235bfdc..1f6b2f5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -439,9 +439,10 @@ def agent( logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): """Interact with the agent directly.""" - from nanobot.config.loader import load_config + from nanobot.config.loader import load_config, get_data_dir from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop + from nanobot.cron.service import CronService from loguru import logger config = load_config() @@ -449,6 +450,10 @@ def agent( bus = MessageBus() provider = _make_provider(config) + # Create cron service for tool usage (no callback needed for CLI unless running) + cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron = CronService(cron_store_path) + if logs: logger.enable("nanobot") else: @@ -465,6 +470,7 @@ def agent( memory_window=config.agents.defaults.memory_window, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, + cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, ) From 56bc8b567733480091d0f4eb4636d8bd31ded1ac Mon Sep 17 00:00:00 2001 From: nano bot Date: Tue, 17 Feb 2026 03:52:08 +0000 Subject: [PATCH 041/415] fix: avoid sending empty content entries in assistant messages --- nanobot/agent/context.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index f460f2b..bed9e36 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -225,14 +225,20 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md""" Returns: Updated message list. """ - msg: dict[str, Any] = {"role": "assistant", "content": content or ""} - + msg: dict[str, Any] = {"role": "assistant"} + + # Only include the content key when there is non-empty text. + # Some LLM backends reject empty text blocks, so omit the key + # to avoid sending empty content entries. + if content is not None and content != "": + msg["content"] = content + if tool_calls: msg["tool_calls"] = tool_calls - - # Thinking models reject history without this + + # Include reasoning content when provided (required by some thinking models) if reasoning_content: msg["reasoning_content"] = reasoning_content - + messages.append(msg) return messages From 5735f9bdcee8f7a415cad6b206dd315c1f94f5f1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:14:16 +0000 Subject: [PATCH 042/415] feat: add ClawHub skill for searching and installing agent skills from the public registry --- nanobot/skills/README.md | 1 + nanobot/skills/clawhub/SKILL.md | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 nanobot/skills/clawhub/SKILL.md diff --git a/nanobot/skills/README.md b/nanobot/skills/README.md index f0dcea7..5192796 100644 --- a/nanobot/skills/README.md +++ b/nanobot/skills/README.md @@ -21,4 +21,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai | `weather` | Get weather info using wttr.in and Open-Meteo | | `summarize` | Summarize URLs, files, and YouTube videos | | `tmux` | Remote-control tmux sessions | +| `clawhub` | Search and install skills from ClawHub registry | | `skill-creator` | Create new skills | \ No newline at end of file diff --git a/nanobot/skills/clawhub/SKILL.md b/nanobot/skills/clawhub/SKILL.md new file mode 100644 index 0000000..7409bf4 --- /dev/null +++ b/nanobot/skills/clawhub/SKILL.md @@ -0,0 +1,53 @@ +--- +name: clawhub +description: Search and install agent skills from ClawHub, the public skill registry. +homepage: https://clawhub.ai +metadata: {"nanobot":{"emoji":"🦞"}} +--- + +# ClawHub + +Public skill registry for AI agents. Search by natural language (vector search). + +## When to use + +Use this skill when the user asks any of: +- "find a skill for …" +- "search for skills" +- "install a skill" +- "what skills are available?" +- "update my skills" + +## Search + +```bash +npx --yes clawhub@latest search "web scraping" --limit 5 +``` + +## Install + +```bash +npx --yes clawhub@latest install --workdir ~/.nanobot/workspace +``` + +Replace `` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`. + +## Update + +```bash +npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace +``` + +## List installed + +```bash +npx --yes clawhub@latest list --workdir ~/.nanobot/workspace +``` + +## Notes + +- Requires Node.js (`npx` comes with it). +- No API key needed for search and install. +- Login (`npx --yes clawhub@latest login`) is only required for publishing. +- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace. +- After install, remind the user to start a new session to load the skill. From 8509a81120201dd4783277f202698984fe9ee151 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:19:23 +0000 Subject: [PATCH 043/415] docs: update 15/16 Feb news --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0584dd8..a27bbc8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ ## 📢 News +- **2026-02-16** 🦞 nanobot now integrates with [ClawHub](https://clawhub.ai) — search and install agent skills from the public registry. +- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. - **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! From cf4dce5df05008b2da0b88445a1b4bd76e1aaf5a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:20:50 +0000 Subject: [PATCH 044/415] docs: update clawhub news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a27bbc8..38afa82 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 📢 News -- **2026-02-16** 🦞 nanobot now integrates with [ClawHub](https://clawhub.ai) — search and install agent skills from the public registry. +- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills. - **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. - **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. From 6bae6a617f7130e0a1021811b8cd8b379c2c0820 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:30:52 +0000 Subject: [PATCH 045/415] fix(cron): fix timezone display bug, add tz validation and skill docs --- README.md | 2 +- nanobot/agent/tools/cron.py | 6 ++++++ nanobot/cli/commands.py | 17 ++++++----------- nanobot/cron/service.py | 2 +- nanobot/skills/cron/SKILL.md | 10 ++++++++++ 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 38afa82..de517d7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,668 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,689 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 9dc31c2..b10e34b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -99,6 +99,12 @@ class CronTool(Tool): return "Error: no session context (channel/chat_id)" if tz and not cron_expr: return "Error: tz can only be used with cron_expr" + if tz: + from zoneinfo import ZoneInfo + try: + ZoneInfo(tz) + except (KeyError, Exception): + return f"Error: unknown timezone '{tz}'" # Build schedule delete_after = False diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3b58db5..3798813 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -719,17 +719,15 @@ def cron_list( table.add_column("Status") table.add_column("Next Run") - import datetime import time + from datetime import datetime as _dt from zoneinfo import ZoneInfo for job in jobs: # Format schedule if job.schedule.kind == "every": sched = f"every {(job.schedule.every_ms or 0) // 1000}s" elif job.schedule.kind == "cron": - sched = job.schedule.expr or "" - if job.schedule.tz: - sched = f"{sched} ({job.schedule.tz})" + sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") else: sched = "one-time" @@ -737,13 +735,10 @@ def cron_list( next_run = "" if job.state.next_run_at_ms: ts = job.state.next_run_at_ms / 1000 - if job.schedule.kind == "cron" and job.schedule.tz: - try: - dt = datetime.fromtimestamp(ts, ZoneInfo(job.schedule.tz)) - next_run = dt.strftime("%Y-%m-%d %H:%M") - except Exception: - next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - else: + try: + tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None + next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") + except Exception: next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 9fda214..14666e8 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -32,7 +32,7 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: try: from croniter import croniter from zoneinfo import ZoneInfo - # Use the caller-provided reference time for deterministic scheduling. + # Use caller-provided reference time for deterministic scheduling base_time = now_ms / 1000 tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo base_dt = datetime.fromtimestamp(base_time, tz=tz) diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md index 7db25d8..cc3516e 100644 --- a/nanobot/skills/cron/SKILL.md +++ b/nanobot/skills/cron/SKILL.md @@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time): cron(action="add", message="Remind me about the meeting", at="") ``` +Timezone-aware cron: +``` +cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver") +``` + List/remove: ``` cron(action="list") @@ -44,4 +49,9 @@ cron(action="remove", job_id="abc123") | every hour | every_seconds: 3600 | | every day at 8am | cron_expr: "0 8 * * *" | | weekdays at 5pm | cron_expr: "0 17 * * 1-5" | +| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" | | at a specific time | at: ISO datetime string (compute from current time) | + +## Timezone + +Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used. From f5c5b13ff03316b925bd37b58adce144d4153c92 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:41:09 +0000 Subject: [PATCH 046/415] refactor: use is_oauth flag instead of hardcoded provider name check --- README.md | 25 +++++++++++++------------ nanobot/cli/commands.py | 4 +++- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index de517d7..0e20449 100644 --- a/README.md +++ b/README.md @@ -145,19 +145,19 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere. +Connect nanobot to your favorite chat platform. -| Channel | Setup | -|---------|-------| -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **WhatsApp** | Medium (scan QR) | -| **Feishu** | Medium (app credentials) | -| **Mochat** | Medium (claw token + websocket) | -| **DingTalk** | Medium (app credentials) | -| **Slack** | Medium (bot + app tokens) | -| **Email** | Medium (IMAP/SMTP credentials) | -| **QQ** | Easy (app credentials) | +| Channel | What you need | +|---------|---------------| +| **Telegram** | Bot token from @BotFather | +| **Discord** | Bot token + Message Content intent | +| **WhatsApp** | QR code scan | +| **Feishu** | App ID + App Secret | +| **Mochat** | Claw token (auto-setup available) | +| **DingTalk** | App Key + App Secret | +| **Slack** | Bot token + App-Level token | +| **Email** | IMAP/SMTP credentials | +| **QQ** | App ID + App Secret |
Telegram (Recommended) @@ -588,6 +588,7 @@ Config file: `~/.nanobot/config.json` | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `vllm` | LLM (local, any OpenAI-compatible server) | — | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | +| `github_copilot` | LLM (GitHub Copilot, OAuth) | Requires [GitHub Copilot](https://github.com/features/copilot) subscription |
OpenAI Codex (OAuth) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a24929f..b61d9aa 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -292,7 +292,9 @@ def _make_provider(config: Config): if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider(default_model=model) - if not model.startswith("bedrock/") and not (p and p.api_key) and provider_name != "github_copilot": + from nanobot.providers.registry import find_by_name + spec = find_by_name(provider_name) + if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) From 1db05c881ddf7b3958edda7f99ff02cd49581f5f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:59:05 +0000 Subject: [PATCH 047/415] fix: omit empty content in assistant messages --- nanobot/agent/context.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index bed9e36..cfd6318 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -227,10 +227,8 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md""" """ msg: dict[str, Any] = {"role": "assistant"} - # Only include the content key when there is non-empty text. - # Some LLM backends reject empty text blocks, so omit the key - # to avoid sending empty content entries. - if content is not None and content != "": + # Omit empty content — some backends reject empty text blocks + if content: msg["content"] = content if tool_calls: From 5ad9c837df8879717a01760530b830dd3c67cd7b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 10:37:55 +0000 Subject: [PATCH 048/415] refactor: clean up telegram media sending logic --- nanobot/channels/telegram.py | 63 ++++++++++++++---------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 8f135e4..39924b3 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -198,17 +198,17 @@ class TelegramChannel(BaseChannel): await self._app.shutdown() self._app = None - def _get_media_type(self, path: str) -> str: + @staticmethod + def _get_media_type(path: str) -> str: """Guess media type from file extension.""" - path = path.lower() - if path.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + if ext in ("jpg", "jpeg", "png", "gif", "webp"): return "photo" - elif path.endswith('.ogg'): + if ext == "ogg": return "voice" - elif path.endswith(('.mp3', '.m4a', '.wav', '.aac')): + if ext in ("mp3", "m4a", "wav", "aac"): return "audio" - else: - return "document" + return "document" async def send(self, msg: OutboundMessage) -> None: """Send a message through Telegram.""" @@ -224,39 +224,24 @@ class TelegramChannel(BaseChannel): logger.error(f"Invalid chat_id: {msg.chat_id}") return - # Handle media files - if msg.media: - for media_path in msg.media: - try: - media_type = self._get_media_type(media_path) - - # Determine caption (only for first media or if explicitly set, - # but here we keep it simple: content is sent separately if media is present - # to avoid length issues, unless we want to attach it to the first media) - # For simplicity: send media first, then text if present. - # Or: if single media, attach text as caption. - - # Let's attach content as caption to the last media if single, - # otherwise send text separately. - - with open(media_path, 'rb') as f: - if media_type == "photo": - await self._app.bot.send_photo(chat_id=chat_id, photo=f) - elif media_type == "voice": - await self._app.bot.send_voice(chat_id=chat_id, voice=f) - elif media_type == "audio": - await self._app.bot.send_audio(chat_id=chat_id, audio=f) - else: - await self._app.bot.send_document(chat_id=chat_id, document=f) - - except Exception as e: - logger.error(f"Failed to send media {media_path}: {e}") - await self._app.bot.send_message( - chat_id=chat_id, - text=f"[Failed to send file: {media_path}]" - ) + # Send media files + for media_path in (msg.media or []): + try: + media_type = self._get_media_type(media_path) + sender = { + "photo": self._app.bot.send_photo, + "voice": self._app.bot.send_voice, + "audio": self._app.bot.send_audio, + }.get(media_type, self._app.bot.send_document) + param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" + with open(media_path, 'rb') as f: + await sender(chat_id=chat_id, **{param: f}) + except Exception as e: + filename = media_path.rsplit("/", 1)[-1] + logger.error(f"Failed to send media {media_path}: {e}") + await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]") - # Send text content if present + # Send text content if msg.content and msg.content != "[empty message]": for chunk in _split_message(msg.content): try: From c03f2b670bda14557a75e77865b8a89a6670c409 Mon Sep 17 00:00:00 2001 From: Rajasimman S Date: Tue, 17 Feb 2026 18:50:03 +0530 Subject: [PATCH 049/415] =?UTF-8?q?=F0=9F=90=B3=20feat:=20add=20Docker=20C?= =?UTF-8?q?ompose=20support=20for=20easy=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docker-compose.yml with gateway and CLI services, resource limits, and comprehensive documentation for Docker Compose usage. --- README.md | 33 +++++++++++++++++++++++++++++++++ docker-compose.yml | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 docker-compose.yml diff --git a/README.md b/README.md index 0e20449..f5a92fd 100644 --- a/README.md +++ b/README.md @@ -811,6 +811,39 @@ nanobot cron remove > [!TIP] > The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. +### Using Docker Compose (Recommended) + +The easiest way to run nanobot with Docker: + +```bash +# 1. Initialize config (first time only) +docker compose run --rm nanobot-cli onboard + +# 2. Edit config to add API keys +vim ~/.nanobot/config.json + +# 3. Start gateway service +docker compose up -d nanobot-gateway + +# 4. Check logs +docker compose logs -f nanobot-gateway + +# 5. Run CLI commands +docker compose run --rm nanobot-cli status +docker compose run --rm nanobot-cli agent -m "Hello!" + +# 6. Stop services +docker compose down +``` + +**Features:** +- ✅ Resource limits (1 CPU, 1GB memory) +- ✅ Auto-restart on failure +- ✅ Shared configuration using YAML anchors +- ✅ Separate CLI profile for on-demand commands + +### Using Docker directly + Build and run nanobot in a container: ```bash diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..446f5e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +x-common-config: &common-config + build: + context: . + dockerfile: Dockerfile + volumes: + - ~/.nanobot:/root/.nanobot + +services: + nanobot-gateway: + container_name: nanobot-gateway + <<: *common-config + command: ["gateway"] + restart: unless-stopped + ports: + - 18790:18790 + deploy: + resources: + limits: + cpus: '1' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M + + nanobot-cli: + container_name: nanobot-cli + <<: *common-config + profiles: + - cli + command: ["status"] + stdin_open: true + tty: true From 4d4d6299283f51c31357984e3a34d75518fd0501 Mon Sep 17 00:00:00 2001 From: Simon Guigui Date: Tue, 17 Feb 2026 15:19:21 +0100 Subject: [PATCH 050/415] fix(config): mcpServers env variables should not be converted to snake case --- nanobot/config/loader.py | 55 ++++---------------- nanobot/config/schema.py | 109 ++++++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index fd7d1e8..560c1f5 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -2,7 +2,6 @@ import json from pathlib import Path -from typing import Any from nanobot.config.schema import Config @@ -21,43 +20,41 @@ def get_data_dir() -> Path: def load_config(config_path: Path | None = None) -> Config: """ Load configuration from file or create default. - + Args: config_path: Optional path to config file. Uses default if not provided. - + Returns: Loaded configuration object. """ path = config_path or get_config_path() - + if path.exists(): try: with open(path) as f: data = json.load(f) data = _migrate_config(data) - return Config.model_validate(convert_keys(data)) + return Config.model_validate(data) except (json.JSONDecodeError, ValueError) as e: print(f"Warning: Failed to load config from {path}: {e}") print("Using default configuration.") - + return Config() def save_config(config: Config, config_path: Path | None = None) -> None: """ Save configuration to file. - + Args: config: Configuration to save. config_path: Optional path to save to. Uses default if not provided. """ path = config_path or get_config_path() path.parent.mkdir(parents=True, exist_ok=True) - - # Convert to camelCase format - data = config.model_dump() - data = convert_to_camel(data) - + + data = config.model_dump(by_alias=True) + with open(path, "w") as f: json.dump(data, f, indent=2) @@ -70,37 +67,3 @@ def _migrate_config(data: dict) -> dict: if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") return data - - -def convert_keys(data: Any) -> Any: - """Convert camelCase keys to snake_case for Pydantic.""" - if isinstance(data, dict): - return {camel_to_snake(k): convert_keys(v) for k, v in data.items()} - if isinstance(data, list): - return [convert_keys(item) for item in data] - return data - - -def convert_to_camel(data: Any) -> Any: - """Convert snake_case keys to camelCase.""" - if isinstance(data, dict): - return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()} - if isinstance(data, list): - return [convert_to_camel(item) for item in data] - return data - - -def camel_to_snake(name: str) -> str: - """Convert camelCase to snake_case.""" - result = [] - for i, char in enumerate(name): - if char.isupper() and i > 0: - result.append("_") - result.append(char.lower()) - return "".join(result) - - -def snake_to_camel(name: str) -> str: - """Convert snake_case to camelCase.""" - components = name.split("_") - return components[0] + "".join(x.title() for x in components[1:]) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 64609ec..0786080 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -2,27 +2,39 @@ from pathlib import Path from pydantic import BaseModel, Field, ConfigDict +from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings -class WhatsAppConfig(BaseModel): +class Base(BaseModel): + """Base model that accepts both camelCase and snake_case keys.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class WhatsAppConfig(Base): """WhatsApp channel configuration.""" + enabled: bool = False bridge_url: str = "ws://localhost:3001" bridge_token: str = "" # Shared token for bridge auth (optional, recommended) allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers -class TelegramConfig(BaseModel): +class TelegramConfig(Base): """Telegram channel configuration.""" + enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) -class FeishuConfig(BaseModel): +class FeishuConfig(Base): """Feishu/Lark channel configuration using WebSocket long connection.""" + enabled: bool = False app_id: str = "" # App ID from Feishu Open Platform app_secret: str = "" # App Secret from Feishu Open Platform @@ -31,24 +43,28 @@ class FeishuConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids -class DingTalkConfig(BaseModel): +class DingTalkConfig(Base): """DingTalk channel configuration using Stream mode.""" + enabled: bool = False client_id: str = "" # AppKey client_secret: str = "" # AppSecret allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids -class DiscordConfig(BaseModel): +class DiscordConfig(Base): """Discord channel configuration.""" + enabled: bool = False token: str = "" # Bot token from Discord Developer Portal allow_from: list[str] = Field(default_factory=list) # Allowed user IDs gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT -class EmailConfig(BaseModel): + +class EmailConfig(Base): """Email channel configuration (IMAP inbound + SMTP outbound).""" + enabled: bool = False consent_granted: bool = False # Explicit owner permission to access mailbox data @@ -70,7 +86,9 @@ class EmailConfig(BaseModel): from_address: str = "" # Behavior - auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + auto_reply_enabled: bool = ( + True # If false, inbound email is read but no automatic reply is sent + ) poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -78,18 +96,21 @@ class EmailConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses -class MochatMentionConfig(BaseModel): +class MochatMentionConfig(Base): """Mochat mention behavior configuration.""" + require_in_groups: bool = False -class MochatGroupRule(BaseModel): +class MochatGroupRule(Base): """Mochat per-group mention requirement.""" + require_mention: bool = False -class MochatConfig(BaseModel): +class MochatConfig(Base): """Mochat channel configuration.""" + enabled: bool = False base_url: str = "https://mochat.io" socket_url: str = "" @@ -114,15 +135,17 @@ class MochatConfig(BaseModel): reply_delay_ms: int = 120000 -class SlackDMConfig(BaseModel): +class SlackDMConfig(Base): """Slack DM policy configuration.""" + enabled: bool = True policy: str = "open" # "open" or "allowlist" allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs -class SlackConfig(BaseModel): +class SlackConfig(Base): """Slack channel configuration.""" + enabled: bool = False mode: str = "socket" # "socket" supported webhook_path: str = "/slack/events" @@ -134,16 +157,20 @@ class SlackConfig(BaseModel): dm: SlackDMConfig = Field(default_factory=SlackDMConfig) -class QQConfig(BaseModel): +class QQConfig(Base): """QQ channel configuration using botpy SDK.""" + enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + allow_from: list[str] = Field( + default_factory=list + ) # Allowed user openids (empty = public access) -class ChannelsConfig(BaseModel): +class ChannelsConfig(Base): """Configuration for chat channels.""" + whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) @@ -155,8 +182,9 @@ class ChannelsConfig(BaseModel): qq: QQConfig = Field(default_factory=QQConfig) -class AgentDefaults(BaseModel): +class AgentDefaults(Base): """Default agent configuration.""" + workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 @@ -165,20 +193,23 @@ class AgentDefaults(BaseModel): memory_window: int = 50 -class AgentsConfig(BaseModel): +class AgentsConfig(Base): """Agent configuration.""" + defaults: AgentDefaults = Field(default_factory=AgentDefaults) -class ProviderConfig(BaseModel): +class ProviderConfig(Base): """LLM provider configuration.""" + api_key: str = "" api_base: str | None = None extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) -class ProvidersConfig(BaseModel): +class ProvidersConfig(Base): """Configuration for LLM providers.""" + custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) @@ -196,38 +227,44 @@ class ProvidersConfig(BaseModel): github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) -class GatewayConfig(BaseModel): +class GatewayConfig(Base): """Gateway/server configuration.""" + host: str = "0.0.0.0" port: int = 18790 -class WebSearchConfig(BaseModel): +class WebSearchConfig(Base): """Web search tool configuration.""" + api_key: str = "" # Brave Search API key max_results: int = 5 -class WebToolsConfig(BaseModel): +class WebToolsConfig(Base): """Web tools configuration.""" + search: WebSearchConfig = Field(default_factory=WebSearchConfig) -class ExecToolConfig(BaseModel): +class ExecToolConfig(Base): """Shell exec tool configuration.""" + timeout: int = 60 -class MCPServerConfig(BaseModel): +class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" + command: str = "" # Stdio: command to run (e.g. "npx") args: list[str] = Field(default_factory=list) # Stdio: command arguments env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars url: str = "" # HTTP: streamable HTTP endpoint URL -class ToolsConfig(BaseModel): +class ToolsConfig(Base): """Tools configuration.""" + web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory @@ -236,20 +273,24 @@ class ToolsConfig(BaseModel): class Config(BaseSettings): """Root configuration for nanobot.""" + agents: AgentsConfig = Field(default_factory=AgentsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) - + @property def workspace_path(self) -> Path: """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - - def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + + def _match_provider( + self, model: str | None = None + ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS + model_lower = (model or self.agents.defaults.model).lower() # Match by keyword (order follows PROVIDERS registry) @@ -283,10 +324,11 @@ class Config(BaseSettings): """Get API key for the given model. Falls back to first available key.""" p = self.get_provider(model) return p.api_key if p else None - + def get_api_base(self, model: str | None = None) -> str | None: """Get API base URL for the given model. Applies default URLs for known gateways.""" from nanobot.providers.registry import find_by_name + p, name = self._match_provider(model) if p and p.api_base: return p.api_base @@ -298,8 +340,5 @@ class Config(BaseSettings): if spec and spec.is_gateway and spec.default_api_base: return spec.default_api_base return None - - model_config = ConfigDict( - env_prefix="NANOBOT_", - env_nested_delimiter="__" - ) + + model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") From 941c3d98264303c7494b98c306c2696499bdc45a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 17:34:24 +0000 Subject: [PATCH 051/415] style: restore single-line formatting for readability --- nanobot/config/schema.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0786080..76ec74d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -27,9 +27,7 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = ( - None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" - ) + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" class FeishuConfig(Base): @@ -86,9 +84,7 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = ( - True # If false, inbound email is read but no automatic reply is sent - ) + auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -163,9 +159,7 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field( - default_factory=list - ) # Allowed user openids (empty = public access) + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) class ChannelsConfig(Base): @@ -285,9 +279,7 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider( - self, model: str | None = None - ) -> tuple["ProviderConfig | None", str | None]: + def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS From aad1df5b9b13f8bd0e233af7cc26cc47e0615ff4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 17:55:48 +0000 Subject: [PATCH 052/415] Simplify Docker Compose docs and remove fixed CLI container name --- README.md | 39 ++++++++++----------------------------- docker-compose.yml | 1 - 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f5a92fd..96ff557 100644 --- a/README.md +++ b/README.md @@ -811,40 +811,21 @@ nanobot cron remove > [!TIP] > The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts. -### Using Docker Compose (Recommended) - -The easiest way to run nanobot with Docker: +### Docker Compose ```bash -# 1. Initialize config (first time only) -docker compose run --rm nanobot-cli onboard - -# 2. Edit config to add API keys -vim ~/.nanobot/config.json - -# 3. Start gateway service -docker compose up -d nanobot-gateway - -# 4. Check logs -docker compose logs -f nanobot-gateway - -# 5. Run CLI commands -docker compose run --rm nanobot-cli status -docker compose run --rm nanobot-cli agent -m "Hello!" - -# 6. Stop services -docker compose down +docker compose run --rm nanobot-cli onboard # first-time setup +vim ~/.nanobot/config.json # add API keys +docker compose up -d nanobot-gateway # start gateway ``` -**Features:** -- ✅ Resource limits (1 CPU, 1GB memory) -- ✅ Auto-restart on failure -- ✅ Shared configuration using YAML anchors -- ✅ Separate CLI profile for on-demand commands +```bash +docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI +docker compose logs -f nanobot-gateway # view logs +docker compose down # stop +``` -### Using Docker directly - -Build and run nanobot in a container: +### Docker ```bash # Build the image diff --git a/docker-compose.yml b/docker-compose.yml index 446f5e3..5c27f81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,6 @@ services: memory: 256M nanobot-cli: - container_name: nanobot-cli <<: *common-config profiles: - cli From 05d06b1eb8a2492992da89f7dc0534df158cff4f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 17:58:36 +0000 Subject: [PATCH 053/415] docs: update line count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 96ff557..325aa64 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,689 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,696 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News From 831eb07945e0ee4cfb8bdfd927cf2e25cf7ed433 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:00:30 +0800 Subject: [PATCH 054/415] docs: update security guideline --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index af3448c..405ce52 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ If you discover a security vulnerability in nanobot, please report it by: 1. **DO NOT** open a public GitHub issue -2. Create a private security advisory on GitHub or contact the repository maintainers +2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com) 3. Include: - Description of the vulnerability - Steps to reproduce From 72db01db636c4ec2e63b50a7c79dfbdbf758b8a9 Mon Sep 17 00:00:00 2001 From: Hyudryu Date: Tue, 17 Feb 2026 13:42:57 -0800 Subject: [PATCH 055/415] slack: Added replyInThread logic and custom react emoji in config --- nanobot/channels/slack.py | 6 ++++-- nanobot/config/schema.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index e5fff63..dca5055 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -152,13 +152,15 @@ class SlackChannel(BaseChannel): text = self._strip_bot_mention(text) - thread_ts = event.get("thread_ts") or event.get("ts") + thread_ts = event.get("thread_ts") + if self.config.reply_in_thread and not thread_ts: + thread_ts = event.get("ts") # Add :eyes: reaction to the triggering message (best-effort) try: if self._web_client and event.get("ts"): await self._web_client.reactions_add( channel=chat_id, - name="eyes", + name=self.config.react_emoji, timestamp=event.get("ts"), ) except Exception as e: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 76ec74d..3cacbde 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -148,6 +148,8 @@ class SlackConfig(Base): bot_token: str = "" # xoxb-... app_token: str = "" # xapp-... user_token_read_only: bool = True + reply_in_thread: bool = True + react_emoji: str = "eyes" group_policy: str = "mention" # "mention", "open", "allowlist" group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist dm: SlackDMConfig = Field(default_factory=SlackDMConfig) From b161fa4f9a7521d9868f2bd83d240cc9a15dc21f Mon Sep 17 00:00:00 2001 From: Jeroen Evens Date: Fri, 6 Feb 2026 18:38:18 +0100 Subject: [PATCH 056/415] [github] Add Github Copilot --- nanobot/cli/commands.py | 4 +++- nanobot/config/schema.py | 4 +++- nanobot/providers/litellm_provider.py | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5280d0f..ff3493a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -892,7 +892,9 @@ def status(): p = getattr(config.providers, spec.name, None) if p is None: continue - if spec.is_local: + if spec.is_oauth: + console.print(f"{spec.label}: [green]✓ (OAuth)[/green]") + elif spec.is_local: # Local deployments show api_base instead of api_key if p.api_base: console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 76ec74d..99c659c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -298,7 +298,9 @@ class Config(BaseSettings): if spec.is_oauth: continue p = getattr(self.providers, spec.name, None) - if p and p.api_key: + if p is None: + continue + if p.api_key: return p, spec.name return None, None diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..43dfbb5 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -38,6 +38,9 @@ class LiteLLMProvider(LLMProvider): # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) + # Detect GitHub Copilot (uses OAuth device flow, no API key) + self.is_github_copilot = "github_copilot" in default_model + # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) @@ -76,6 +79,10 @@ class LiteLLMProvider(LLMProvider): def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" + # GitHub Copilot models pass through directly + if self.is_github_copilot: + return model + if self._gateway: # Gateway mode: apply gateway prefix, skip provider-specific prefixes prefix = self._gateway.litellm_prefix From 16127d49f98cccb47fdf99306f41c38934821bc9 Mon Sep 17 00:00:00 2001 From: Jeroen Evens Date: Tue, 17 Feb 2026 22:50:39 +0100 Subject: [PATCH 057/415] [github] Fix Oauth login --- nanobot/cli/commands.py | 112 +++++++++++++++++++------- nanobot/providers/litellm_provider.py | 7 -- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index ff3493a..c1104da 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -915,40 +915,92 @@ app.add_typer(provider_app, name="provider") @provider_app.command("login") def provider_login( - provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"), + provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex', 'github-copilot')"), ): """Authenticate with an OAuth provider.""" - console.print(f"{__logo__} OAuth Login - {provider}\n") + from nanobot.providers.registry import PROVIDERS - if provider == "openai-codex": - try: - from oauth_cli_kit import get_token, login_oauth_interactive - token = None - try: - token = get_token() - except Exception: - token = None - if not (token and token.access): - console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") - console.print("A browser window may open for you to authenticate.\n") - token = login_oauth_interactive( - print_fn=lambda s: console.print(s), - prompt_fn=lambda s: typer.prompt(s), - ) - if not (token and token.access): - console.print("[red]✗ Authentication failed[/red]") - raise typer.Exit(1) - console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") - console.print(f"[dim]Account ID: {token.account_id}[/dim]") - except ImportError: - console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") - raise typer.Exit(1) - except Exception as e: - console.print(f"[red]Authentication error: {e}[/red]") - raise typer.Exit(1) - else: + # Normalize: "github-copilot" → "github_copilot" + provider_key = provider.replace("-", "_") + + # Validate against the registry — only OAuth providers support login + spec = None + for s in PROVIDERS: + if s.name == provider_key and s.is_oauth: + spec = s + break + if not spec: + oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth] console.print(f"[red]Unknown OAuth provider: {provider}[/red]") - console.print("[yellow]Supported providers: openai-codex[/yellow]") + console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]") + raise typer.Exit(1) + + console.print(f"{__logo__} OAuth Login - {spec.display_name}\n") + + if spec.name == "openai_codex": + _login_openai_codex() + elif spec.name == "github_copilot": + _login_github_copilot() + + +def _login_openai_codex() -> None: + """Authenticate with OpenAI Codex via oauth_cli_kit.""" + try: + from oauth_cli_kit import get_token, login_oauth_interactive + token = None + try: + token = get_token() + except Exception: + token = None + if not (token and token.access): + console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") + console.print("A browser window may open for you to authenticate.\n") + token = login_oauth_interactive( + print_fn=lambda s: console.print(s), + prompt_fn=lambda s: typer.prompt(s), + ) + if not (token and token.access): + console.print("[red]✗ Authentication failed[/red]") + raise typer.Exit(1) + console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") + console.print(f"[dim]Account ID: {token.account_id}[/dim]") + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Authentication error: {e}[/red]") + raise typer.Exit(1) + + +def _login_github_copilot() -> None: + """Authenticate with GitHub Copilot via LiteLLM's device flow. + + LiteLLM handles the full OAuth device flow (device code → poll → token + storage) internally when a github_copilot/ model is first called. + We trigger that flow by sending a minimal completion request. + """ + import asyncio + + console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]") + console.print("You will be prompted to visit a URL and enter a device code.\n") + + async def _trigger_device_flow() -> None: + from litellm import acompletion + await acompletion( + model="github_copilot/gpt-4o", + messages=[{"role": "user", "content": "hi"}], + max_tokens=1, + ) + + try: + asyncio.run(_trigger_device_flow()) + console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]") + except Exception as e: + error_msg = str(e) + # A successful device flow still returns a valid response; + # any exception here means the flow genuinely failed. + console.print(f"[red]Authentication error: {error_msg}[/red]") + console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]") raise typer.Exit(1) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 43dfbb5..8cc4e35 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -38,9 +38,6 @@ class LiteLLMProvider(LLMProvider): # api_key / api_base are fallback for auto-detection. self._gateway = find_gateway(provider_name, api_key, api_base) - # Detect GitHub Copilot (uses OAuth device flow, no API key) - self.is_github_copilot = "github_copilot" in default_model - # Configure environment variables if api_key: self._setup_env(api_key, api_base, default_model) @@ -79,10 +76,6 @@ class LiteLLMProvider(LLMProvider): def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" - # GitHub Copilot models pass through directly - if self.is_github_copilot: - return model - if self._gateway: # Gateway mode: apply gateway prefix, skip provider-specific prefixes prefix = self._gateway.litellm_prefix From e2a0d639099372e4da89173a9a3ce5c76d3264ba Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 02:39:15 +0000 Subject: [PATCH 058/415] feat: add custom provider with direct openai-compatible support --- README.md | 6 ++-- nanobot/cli/commands.py | 13 ++++++-- nanobot/providers/custom_provider.py | 47 ++++++++++++++++++++++++++++ nanobot/providers/registry.py | 15 +++++---- 4 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 nanobot/providers/custom_provider.py diff --git a/README.md b/README.md index 325aa64..30210a7 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ Config file: `~/.nanobot/config.json` | Provider | Purpose | Get API Key | |----------|---------|-------------| -| `custom` | Any OpenAI-compatible endpoint | — | +| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | @@ -623,7 +623,7 @@ nanobot agent -m "Hello!"
Custom Provider (Any OpenAI-compatible API) -If your provider is not listed above but exposes an **OpenAI-compatible API** (e.g. Together AI, Fireworks, Azure OpenAI, self-hosted endpoints), use the `custom` provider: +Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is. ```json { @@ -641,7 +641,7 @@ If your provider is not listed above but exposes an **OpenAI-compatible API** (e } ``` -> The `custom` provider routes through LiteLLM's OpenAI-compatible path. It works with any endpoint that follows the OpenAI chat completions API format. The model name is passed directly to the endpoint without any prefix. +> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5280d0f..6b245bf 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -280,18 +280,27 @@ This file stores important information that should persist across sessions. def _make_provider(config: Config): - """Create LiteLLMProvider from config. Exits if no API key found.""" + """Create the appropriate LLM provider from config.""" from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from nanobot.providers.custom_provider import CustomProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) p = config.get_provider(model) - # OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation. + # OpenAI Codex (OAuth) if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider(default_model=model) + # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM + if provider_name == "custom": + return CustomProvider( + api_key=p.api_key if p else "no-key", + api_base=config.get_api_base(model) or "http://localhost:8000/v1", + default_model=model, + ) + from nanobot.providers.registry import find_by_name spec = find_by_name(provider_name) if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py new file mode 100644 index 0000000..f190ccf --- /dev/null +++ b/nanobot/providers/custom_provider.py @@ -0,0 +1,47 @@ +"""Direct OpenAI-compatible provider — bypasses LiteLLM.""" + +from __future__ import annotations + +from typing import Any + +import json_repair +from openai import AsyncOpenAI + +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest + + +class CustomProvider(LLMProvider): + + def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): + super().__init__(api_key, api_base) + self.default_model = default_model + self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) + + async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, + model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: + kwargs: dict[str, Any] = {"model": model or self.default_model, "messages": messages, + "max_tokens": max(1, max_tokens), "temperature": temperature} + if tools: + kwargs.update(tools=tools, tool_choice="auto") + try: + return self._parse(await self._client.chat.completions.create(**kwargs)) + except Exception as e: + return LLMResponse(content=f"Error: {e}", finish_reason="error") + + def _parse(self, response: Any) -> LLMResponse: + choice = response.choices[0] + msg = choice.message + tool_calls = [ + ToolCallRequest(id=tc.id, name=tc.function.name, + arguments=json_repair.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments) + for tc in (msg.tool_calls or []) + ] + u = response.usage + return LLMResponse( + content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop", + usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {}, + reasoning_content=getattr(msg, "reasoning_content", None), + ) + + def get_default_model(self) -> str: + return self.default_model diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 1e760d6..7d951fa 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -54,6 +54,9 @@ class ProviderSpec: # OAuth-based providers (e.g., OpenAI Codex) don't use API keys is_oauth: bool = False # if True, uses OAuth flow instead of API key + # Direct providers bypass LiteLLM entirely (e.g., CustomProvider) + is_direct: bool = False + @property def label(self) -> str: return self.display_name or self.name.title() @@ -65,18 +68,14 @@ class ProviderSpec: PROVIDERS: tuple[ProviderSpec, ...] = ( - # === Custom (user-provided OpenAI-compatible endpoint) ================= - # No auto-detection — only activates when user explicitly configures "custom". - + # === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ====== ProviderSpec( name="custom", keywords=(), - env_key="OPENAI_API_KEY", + env_key="", display_name="Custom", - litellm_prefix="openai", - skip_prefixes=("openai/",), - is_gateway=True, - strip_model_prefix=True, + litellm_prefix="", + is_direct=True, ), # === Gateways (detected by api_key / api_base, not model name) ========= From d54831a35f25c09450054a451fef3f6a4cda7941 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 03:09:09 +0000 Subject: [PATCH 059/415] feat: add github copilot oauth login and improve provider status display --- README.md | 2 +- nanobot/cli/commands.py | 96 +++++++++++++++++----------------------- nanobot/config/schema.py | 4 +- 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 30210a7..03789de 100644 --- a/README.md +++ b/README.md @@ -588,7 +588,7 @@ Config file: `~/.nanobot/config.json` | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `vllm` | LLM (local, any OpenAI-compatible server) | — | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | -| `github_copilot` | LLM (GitHub Copilot, OAuth) | Requires [GitHub Copilot](https://github.com/features/copilot) subscription | +| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
OpenAI Codex (OAuth) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5efc297..8e17139 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -922,48 +922,50 @@ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") +_LOGIN_HANDLERS: dict[str, callable] = {} + + +def _register_login(name: str): + def decorator(fn): + _LOGIN_HANDLERS[name] = fn + return fn + return decorator + + @provider_app.command("login") def provider_login( - provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex', 'github-copilot')"), + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), ): """Authenticate with an OAuth provider.""" from nanobot.providers.registry import PROVIDERS - # Normalize: "github-copilot" → "github_copilot" - provider_key = provider.replace("-", "_") - - # Validate against the registry — only OAuth providers support login - spec = None - for s in PROVIDERS: - if s.name == provider_key and s.is_oauth: - spec = s - break + key = provider.replace("-", "_") + spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None) if not spec: - oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth] - console.print(f"[red]Unknown OAuth provider: {provider}[/red]") - console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]") + names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) + console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") raise typer.Exit(1) - console.print(f"{__logo__} OAuth Login - {spec.display_name}\n") + handler = _LOGIN_HANDLERS.get(spec.name) + if not handler: + console.print(f"[red]Login not implemented for {spec.label}[/red]") + raise typer.Exit(1) - if spec.name == "openai_codex": - _login_openai_codex() - elif spec.name == "github_copilot": - _login_github_copilot() + console.print(f"{__logo__} OAuth Login - {spec.label}\n") + handler() +@_register_login("openai_codex") def _login_openai_codex() -> None: - """Authenticate with OpenAI Codex via oauth_cli_kit.""" try: from oauth_cli_kit import get_token, login_oauth_interactive token = None try: token = get_token() except Exception: - token = None + pass if not (token and token.access): - console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]") - console.print("A browser window may open for you to authenticate.\n") + console.print("[cyan]Starting interactive OAuth login...[/cyan]\n") token = login_oauth_interactive( print_fn=lambda s: console.print(s), prompt_fn=lambda s: typer.prompt(s), @@ -971,47 +973,29 @@ def _login_openai_codex() -> None: if not (token and token.access): console.print("[red]✗ Authentication failed[/red]") raise typer.Exit(1) - console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]") - console.print(f"[dim]Account ID: {token.account_id}[/dim]") + console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]") except ImportError: console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") raise typer.Exit(1) + + +@_register_login("github_copilot") +def _login_github_copilot() -> None: + import asyncio + + console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n") + + async def _trigger(): + from litellm import acompletion + await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1) + + try: + asyncio.run(_trigger()) + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") except Exception as e: console.print(f"[red]Authentication error: {e}[/red]") raise typer.Exit(1) -def _login_github_copilot() -> None: - """Authenticate with GitHub Copilot via LiteLLM's device flow. - - LiteLLM handles the full OAuth device flow (device code → poll → token - storage) internally when a github_copilot/ model is first called. - We trigger that flow by sending a minimal completion request. - """ - import asyncio - - console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]") - console.print("You will be prompted to visit a URL and enter a device code.\n") - - async def _trigger_device_flow() -> None: - from litellm import acompletion - await acompletion( - model="github_copilot/gpt-4o", - messages=[{"role": "user", "content": "hi"}], - max_tokens=1, - ) - - try: - asyncio.run(_trigger_device_flow()) - console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]") - except Exception as e: - error_msg = str(e) - # A successful device flow still returns a valid response; - # any exception here means the flow genuinely failed. - console.print(f"[red]Authentication error: {error_msg}[/red]") - console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]") - raise typer.Exit(1) - - if __name__ == "__main__": app() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fe909ef..3cacbde 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -300,9 +300,7 @@ class Config(BaseSettings): if spec.is_oauth: continue p = getattr(self.providers, spec.name, None) - if p is None: - continue - if p.api_key: + if p and p.api_key: return p, spec.name return None, None From 80a5a8c983de351b62060b9d1bc3d40d1a122228 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 03:52:53 +0000 Subject: [PATCH 060/415] feat: add siliconflow provider support --- README.md | 1 + nanobot/providers/registry.py | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 03789de..dfe799a 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,7 @@ Config file: `~/.nanobot/config.json` | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | +| `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index d267069..49b735c 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -119,16 +119,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), - # SiliconFlow (硅基流动): OpenAI-compatible gateway hosting multiple models. - # strip_model_prefix=False: SiliconFlow model names include org prefix - # (e.g. "Qwen/Qwen2.5-14B-Instruct", "deepseek-ai/DeepSeek-V3") - # which is part of the model ID and must NOT be stripped. + # SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix ProviderSpec( name="siliconflow", keywords=("siliconflow",), - env_key="OPENAI_API_KEY", # OpenAI-compatible + env_key="OPENAI_API_KEY", display_name="SiliconFlow", - litellm_prefix="openai", # → openai/{model} + litellm_prefix="openai", skip_prefixes=(), env_extras=(), is_gateway=True, From 27a131830f31ae8560aa4f0f44fad577d6e940c4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 05:09:57 +0000 Subject: [PATCH 061/415] refine: migrate legacy sessions on load and simplify get_history --- nanobot/session/manager.py | 39 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index e2d8e5c..752fce4 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -42,30 +42,15 @@ class Session: self.updated_at = datetime.now() def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: - """ - Get recent messages in LLM format. - - Preserves tool metadata for replay/debugging fidelity. - """ - history: list[dict[str, Any]] = [] - for msg in self.messages[-max_messages:]: - llm_msg: dict[str, Any] = { - "role": msg["role"], - "content": msg.get("content", ""), - } - - if msg["role"] == "assistant" and "tool_calls" in msg: - llm_msg["tool_calls"] = msg["tool_calls"] - - if msg["role"] == "tool": - if "tool_call_id" in msg: - llm_msg["tool_call_id"] = msg["tool_call_id"] - if "name" in msg: - llm_msg["name"] = msg["name"] - - history.append(llm_msg) - - return history + """Get recent messages in LLM format, preserving tool metadata.""" + out: list[dict[str, Any]] = [] + for m in self.messages[-max_messages:]: + entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")} + for k in ("tool_calls", "tool_call_id", "name"): + if k in m: + entry[k] = m[k] + out.append(entry) + return out def clear(self) -> None: """Clear all messages and reset session to initial state.""" @@ -93,7 +78,7 @@ class SessionManager: return self.sessions_dir / f"{safe_key}.jsonl" def _get_legacy_session_path(self, key: str) -> Path: - """Get the legacy global session path for backward compatibility.""" + """Legacy global session path (~/.nanobot/sessions/).""" safe_key = safe_filename(key.replace(":", "_")) return self.legacy_sessions_dir / f"{safe_key}.jsonl" @@ -123,7 +108,9 @@ class SessionManager: if not path.exists(): legacy_path = self._get_legacy_session_path(key) if legacy_path.exists(): - path = legacy_path + import shutil + shutil.move(str(legacy_path), str(path)) + logger.info(f"Migrated session {key} from legacy path") if not path.exists(): return None From e44f14379a289f900556fa3d6f255f446aee634f Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 18 Feb 2026 11:57:58 +0300 Subject: [PATCH 062/415] fix: sanitize messages and ensure 'content' for strict LLM providers - Strip non-standard keys like 'reasoning_content' before sending to LLM - Always include 'content' key in assistant messages (required by StepFun) - Add _sanitize_messages to LiteLLMProvider to prevent 400 BadRequest errors --- nanobot/agent/context.py | 6 +++--- nanobot/providers/litellm_provider.py | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index cfd6318..458016e 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -227,9 +227,9 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md""" """ msg: dict[str, Any] = {"role": "assistant"} - # Omit empty content — some backends reject empty text blocks - if content: - msg["content"] = content + # Always include content — some providers (e.g. StepFun) reject + # assistant messages that omit the key entirely. + msg["content"] = content if tool_calls: msg["tool_calls"] = tool_calls diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..58acf95 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -12,6 +12,12 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway +# Keys that are part of the OpenAI chat-completion message schema. +# Anything else (e.g. reasoning_content, timestamp) is stripped before sending +# to avoid "Unrecognized chat message" errors from strict providers like StepFun. +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) + + class LiteLLMProvider(LLMProvider): """ LLM provider using LiteLLM for multi-provider support. @@ -103,6 +109,24 @@ class LiteLLMProvider(LLMProvider): kwargs.update(overrides) return + @staticmethod + def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Strip non-standard keys from messages for strict providers. + + Some providers (e.g. StepFun via OpenRouter) reject messages that + contain extra keys like ``reasoning_content``. This method keeps + only the keys defined in the OpenAI chat-completion schema and + ensures every assistant message has a ``content`` key. + """ + sanitized = [] + for msg in messages: + clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS} + # Strict providers require "content" even when assistant only has tool_calls + if clean.get("role") == "assistant" and "content" not in clean: + clean["content"] = None + sanitized.append(clean) + return sanitized + async def chat( self, messages: list[dict[str, Any]], @@ -132,7 +156,7 @@ class LiteLLMProvider(LLMProvider): kwargs: dict[str, Any] = { "model": model, - "messages": messages, + "messages": self._sanitize_messages(messages), "max_tokens": max_tokens, "temperature": temperature, } From 715b2db24b312cd81d6601651cc17f7a523d722b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 14:23:51 +0000 Subject: [PATCH 063/415] feat: stream intermediate progress to user during tool execution --- README.md | 2 +- nanobot/agent/context.py | 2 +- nanobot/agent/loop.py | 57 +++++++++++++++++++++++++++++++++++----- nanobot/cli/commands.py | 7 +++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index dfe799a..fcbc878 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,696 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,761 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index cfd6318..876d43d 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -105,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). For normal conversation, just respond with text - do not call the message tool. -Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. +Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md To recall past events, grep {workspace_path}/memory/HISTORY.md""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6342f56..e5a5183 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,7 +5,8 @@ from contextlib import AsyncExitStack import json import json_repair from pathlib import Path -from typing import Any +import re +from typing import Any, Awaitable, Callable from loguru import logger @@ -146,12 +147,34 @@ class AgentLoop: if isinstance(cron_tool, CronTool): cron_tool.set_context(channel, chat_id) - async def _run_agent_loop(self, initial_messages: list[dict]) -> tuple[str | None, list[str]]: + @staticmethod + def _strip_think(text: str | None) -> str | None: + """Remove blocks that some models embed in content.""" + if not text: + return None + return re.sub(r"[\s\S]*?", "", text).strip() or None + + @staticmethod + def _tool_hint(tool_calls: list) -> str: + """Format tool calls as concise hint, e.g. 'web_search("query")'.""" + def _fmt(tc): + val = next(iter(tc.arguments.values()), None) if tc.arguments else None + if not isinstance(val, str): + return tc.name + return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' + return ", ".join(_fmt(tc) for tc in tool_calls) + + async def _run_agent_loop( + self, + initial_messages: list[dict], + on_progress: Callable[[str], Awaitable[None]] | None = None, + ) -> tuple[str | None, list[str]]: """ Run the agent iteration loop. Args: initial_messages: Starting messages for the LLM conversation. + on_progress: Optional callback to push intermediate content to the user. Returns: Tuple of (final_content, list_of_tools_used). @@ -173,6 +196,10 @@ class AgentLoop: ) if response.has_tool_calls: + if on_progress: + clean = self._strip_think(response.content) + await on_progress(clean or self._tool_hint(response.tool_calls)) + tool_call_dicts = [ { "id": tc.id, @@ -197,9 +224,8 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) - messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: - final_content = response.content + final_content = self._strip_think(response.content) break return final_content, tools_used @@ -244,13 +270,19 @@ class AgentLoop: self._running = False logger.info("Agent loop stopping") - async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None: + async def _process_message( + self, + msg: InboundMessage, + session_key: str | None = None, + on_progress: Callable[[str], Awaitable[None]] | None = None, + ) -> OutboundMessage | None: """ Process a single inbound message. Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). + on_progress: Optional callback for intermediate output (defaults to bus publish). Returns: The response message, or None if no response needed. @@ -297,7 +329,16 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, ) - final_content, tools_used = await self._run_agent_loop(initial_messages) + + async def _bus_progress(content: str) -> None: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + metadata=msg.metadata or {}, + )) + + final_content, tools_used = await self._run_agent_loop( + initial_messages, on_progress=on_progress or _bus_progress, + ) if final_content is None: final_content = "I've completed processing but have no response to give." @@ -451,6 +492,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session_key: str = "cli:direct", channel: str = "cli", chat_id: str = "direct", + on_progress: Callable[[str], Awaitable[None]] | None = None, ) -> str: """ Process a message directly (for CLI or cron usage). @@ -460,6 +502,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). + on_progress: Optional callback for intermediate output. Returns: The agent's response. @@ -472,5 +515,5 @@ Respond with ONLY valid JSON, no markdown fences.""" content=content ) - response = await self._process_message(msg, session_key=session_key) + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 8e17139..2f4ba7b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -494,11 +494,14 @@ def agent( # Animated spinner is safe to use with prompt_toolkit input handling return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + async def _cli_progress(content: str) -> None: + console.print(f" [dim]↳ {content}[/dim]") + if message: # Single message mode async def run_once(): with _thinking_ctx(): - response = await agent_loop.process_direct(message, session_id) + response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() @@ -531,7 +534,7 @@ def agent( break with _thinking_ctx(): - response = await agent_loop.process_direct(user_input, session_id) + response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) except KeyboardInterrupt: _restore_terminal() From b14d4711c0cbff34d030b47018bf1bcbf5e33f26 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 14:31:26 +0000 Subject: [PATCH 064/415] release: v0.1.4 --- nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/__init__.py b/nanobot/__init__.py index ee0445b..a68777c 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,5 +2,5 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.0" +__version__ = "0.1.4" __logo__ = "🐈" diff --git a/pyproject.toml b/pyproject.toml index 6261653..bbd6feb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post7" +version = "0.1.4" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 1f1f5b2d275c177e457efc0cafed650f2f707bf7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Wed, 18 Feb 2026 14:41:13 +0000 Subject: [PATCH 065/415] docs: update v0.1.4 release news --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index fcbc878..7fad9ce 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## 📢 News +- **2026-02-17** 🎉 Released v0.1.4 — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills. - **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. @@ -29,6 +30,10 @@ - **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). + +
+Earlier news + - **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! @@ -36,6 +41,8 @@ - **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot! +
+ ## Key Features of nanobot: 🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot. From 8de36d398fb017af42ee831c653a9e8c5cd90783 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:09:55 +0800 Subject: [PATCH 066/415] docs: update news about release information --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7fad9ce..a474367 100644 --- a/README.md +++ b/README.md @@ -20,24 +20,24 @@ ## 📢 News -- **2026-02-17** 🎉 Released v0.1.4 — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. +- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills. - **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. -- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. +- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support! -- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
Earlier news -- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. +- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! -- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. +- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot! From c5b4331e692c09bb285a5c8e3d811dbfca52b273 Mon Sep 17 00:00:00 2001 From: dxtime Date: Thu, 19 Feb 2026 01:21:17 +0800 Subject: [PATCH 067/415] feature: Added custom headers for MCP Auth use. --- nanobot/agent/tools/mcp.py | 18 +++++++++++++++--- nanobot/config/schema.py | 1 + 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 1c8eac4..4d5c053 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -59,9 +59,21 @@ async def connect_mcp_servers( read, write = await stack.enter_async_context(stdio_client(params)) elif cfg.url: from mcp.client.streamable_http import streamable_http_client - read, write, _ = await stack.enter_async_context( - streamable_http_client(cfg.url) - ) + import httpx + if cfg.headers: + http_client = await stack.enter_async_context( + httpx.AsyncClient( + headers=cfg.headers, + follow_redirects=True + ) + ) + read, write, _ = await stack.enter_async_context( + streamable_http_client(cfg.url, http_client=http_client) + ) + else: + read, write, _ = await stack.enter_async_context( + streamable_http_client(cfg.url) + ) else: logger.warning(f"MCP server '{name}': no command or url configured, skipping") continue diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ce9634c..e404d3c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -257,6 +257,7 @@ class MCPServerConfig(Base): args: list[str] = Field(default_factory=list) # Stdio: command arguments env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars url: str = "" # HTTP: streamable HTTP endpoint URL + headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers class ToolsConfig(Base): From 4a85cd9a1102871aa900b906ffb8ca4c89d206d0 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 17 Feb 2026 13:18:43 +0100 Subject: [PATCH 068/415] fix(cron): add service-layer timezone validation Adds `_validate_schedule_for_add()` to `CronService.add_job` so that invalid or misplaced `tz` values are rejected before a job is persisted, regardless of which caller (CLI, tool, etc.) invoked the service. Surfaces the resulting `ValueError` in `nanobot cron add` via a `try/except` so the CLI exits cleanly with a readable error message. Co-Authored-By: Claude Sonnet 4.6 --- nanobot/cli/commands.py | 22 +++++++++++++--------- nanobot/cron/service.py | 15 +++++++++++++++ tests/test_cron_commands.py | 29 +++++++++++++++++++++++++++++ tests/test_cron_service.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 tests/test_cron_commands.py create mode 100644 tests/test_cron_service.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b61d9aa..668fcb5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -787,15 +787,19 @@ def cron_add( store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - job = service.add_job( - name=name, - schedule=schedule, - message=message, - deliver=deliver, - to=to, - channel=channel, - ) - + try: + job = service.add_job( + name=name, + schedule=schedule, + message=message, + deliver=deliver, + to=to, + channel=channel, + ) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) from e + console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 14666e8..7ae1153 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -45,6 +45,20 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: return None +def _validate_schedule_for_add(schedule: CronSchedule) -> None: + """Validate schedule fields that would otherwise create non-runnable jobs.""" + if schedule.tz and schedule.kind != "cron": + raise ValueError("tz can only be used with cron schedules") + + if schedule.kind == "cron" and schedule.tz: + try: + from zoneinfo import ZoneInfo + + ZoneInfo(schedule.tz) + except Exception: + raise ValueError(f"unknown timezone '{schedule.tz}'") from None + + class CronService: """Service for managing and executing scheduled jobs.""" @@ -272,6 +286,7 @@ class CronService: ) -> CronJob: """Add a new job.""" store = self._load_store() + _validate_schedule_for_add(schedule) now = _now_ms() job = CronJob( diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py new file mode 100644 index 0000000..bce1ef5 --- /dev/null +++ b/tests/test_cron_commands.py @@ -0,0 +1,29 @@ +from typer.testing import CliRunner + +from nanobot.cli.commands import app + +runner = CliRunner() + + +def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) + + result = runner.invoke( + app, + [ + "cron", + "add", + "--name", + "demo", + "--message", + "hello", + "--cron", + "0 9 * * *", + "--tz", + "America/Vancovuer", + ], + ) + + assert result.exit_code == 1 + assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout + assert not (tmp_path / "cron" / "jobs.json").exists() diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py new file mode 100644 index 0000000..07e990a --- /dev/null +++ b/tests/test_cron_service.py @@ -0,0 +1,30 @@ +import pytest + +from nanobot.cron.service import CronService +from nanobot.cron.types import CronSchedule + + +def test_add_job_rejects_unknown_timezone(tmp_path) -> None: + service = CronService(tmp_path / "cron" / "jobs.json") + + with pytest.raises(ValueError, match="unknown timezone 'America/Vancovuer'"): + service.add_job( + name="tz typo", + schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancovuer"), + message="hello", + ) + + assert service.list_jobs(include_disabled=True) == [] + + +def test_add_job_accepts_valid_timezone(tmp_path) -> None: + service = CronService(tmp_path / "cron" / "jobs.json") + + job = service.add_job( + name="tz ok", + schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"), + message="hello", + ) + + assert job.schedule.tz == "America/Vancouver" + assert job.state.next_run_at_ms is not None From 166351799894d2159465ad7b3753ece78b977cec Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 03:00:44 +0800 Subject: [PATCH 069/415] feat: Add VolcEngine LLM provider support - Add VolcEngine ProviderSpec entry in registry.py - Add volcengine to ProvidersConfig class in schema.py - Update model providers table in README.md - Add description about VolcEngine coding plan endpoint --- README.md | 2 ++ nanobot/config/schema.py | 1 + nanobot/providers/registry.py | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index a474367..94cdc88 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,7 @@ Config file: `~/.nanobot/config.json` > - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. +> - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config. | Provider | Purpose | Get API Key | |----------|---------|-------------| @@ -591,6 +592,7 @@ Config file: `~/.nanobot/config.json` | `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | | `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) | +| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ce9634c..0d0c68f 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -220,6 +220,7 @@ class ProvidersConfig(Base): minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway + volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 49b735c..e44720a 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -137,6 +137,24 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), ), + # VolcEngine (火山引擎): OpenAI-compatible gateway + ProviderSpec( + name="volcengine", + keywords=("volcengine", "volces", "ark"), + env_key="OPENAI_API_KEY", + display_name="VolcEngine", + litellm_prefix="openai", + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="volces", + default_api_base="https://ark.cn-beijing.volces.com/api/v3", + strip_model_prefix=False, + model_overrides=(), + ), + # === Standard providers (matched by model-name keywords) =============== # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. From c865b293a9a772c000eed0cda63eecbd2efa472e Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:18:27 +0100 Subject: [PATCH 070/415] feat: enhance message context handling by adding message_id parameter --- nanobot/agent/loop.py | 8 ++++---- nanobot/agent/tools/message.py | 14 +++++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..7855297 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -133,11 +133,11 @@ class AgentLoop: await self._mcp_stack.__aenter__() await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) - def _set_tool_context(self, channel: str, chat_id: str) -> None: + def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" if message_tool := self.tools.get("message"): if isinstance(message_tool, MessageTool): - message_tool.set_context(channel, chat_id) + message_tool.set_context(channel, chat_id, message_id) if spawn_tool := self.tools.get("spawn"): if isinstance(spawn_tool, SpawnTool): @@ -321,7 +321,7 @@ class AgentLoop: if len(session.messages) > self.memory_window: asyncio.create_task(self._consolidate_memory(session)) - self._set_tool_context(msg.channel, msg.chat_id) + self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) initial_messages = self.context.build_messages( history=session.get_history(max_messages=self.memory_window), current_message=msg.content, @@ -379,7 +379,7 @@ class AgentLoop: session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) - self._set_tool_context(origin_channel, origin_chat_id) + self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) initial_messages = self.context.build_messages( history=session.get_history(max_messages=self.memory_window), current_message=msg.content, diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 3853725..10947c4 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -13,16 +13,19 @@ class MessageTool(Tool): self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", - default_chat_id: str = "" + default_chat_id: str = "", + default_message_id: str | None = None ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id + self._default_message_id = default_message_id - def set_context(self, channel: str, chat_id: str) -> None: + def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id + self._default_message_id = message_id def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" @@ -67,11 +70,13 @@ class MessageTool(Tool): content: str, channel: str | None = None, chat_id: str | None = None, + message_id: str | None = None, media: list[str] | None = None, **kwargs: Any ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id + message_id = message_id or self._default_message_id if not channel or not chat_id: return "Error: No target channel/chat specified" @@ -83,7 +88,10 @@ class MessageTool(Tool): channel=channel, chat_id=chat_id, content=content, - media=media or [] + media=media or [], + metadata={ + "message_id": message_id, + } ) try: From 3ac55130042ad7b98a2416ef3802bc1a576df248 Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:27:48 +0100 Subject: [PATCH 071/415] If given a message_id to telegram provider send, the bot will try to reply to that message --- nanobot/channels/telegram.py | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 39924b3..3a90d42 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import re from loguru import logger -from telegram import BotCommand, Update +from telegram import BotCommand, Update, ReplyParameters from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.request import HTTPXRequest @@ -224,6 +224,15 @@ class TelegramChannel(BaseChannel): logger.error(f"Invalid chat_id: {msg.chat_id}") return + # Build reply parameters (Will reply to the message if it exists) + reply_to_message_id = msg.metadata.get("message_id") + reply_params = None + if reply_to_message_id: + reply_params = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=True + ) + # Send media files for media_path in (msg.media or []): try: @@ -235,22 +244,39 @@ class TelegramChannel(BaseChannel): }.get(media_type, self._app.bot.send_document) param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" with open(media_path, 'rb') as f: - await sender(chat_id=chat_id, **{param: f}) + await sender( + chat_id=chat_id, + **{param: f}, + reply_parameters=reply_params + ) except Exception as e: filename = media_path.rsplit("/", 1)[-1] logger.error(f"Failed to send media {media_path}: {e}") - await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]") + await self._app.bot.send_message( + chat_id=chat_id, + text=f"[Failed to send: {filename}]", + reply_parameters=reply_params + ) # Send text content if msg.content and msg.content != "[empty message]": for chunk in _split_message(msg.content): try: html = _markdown_to_telegram_html(chunk) - await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") + await self._app.bot.send_message( + chat_id=chat_id, + text=html, + parse_mode="HTML", + reply_parameters=reply_params + ) except Exception as e: logger.warning(f"HTML parse failed, falling back to plain text: {e}") try: - await self._app.bot.send_message(chat_id=chat_id, text=chunk) + await self._app.bot.send_message( + chat_id=chat_id, + text=chunk, + reply_parameters=reply_params + ) except Exception as e2: logger.error(f"Error sending Telegram message: {e2}") From 536ed60a05fcd1510d17e61d1e30a6a926f5d319 Mon Sep 17 00:00:00 2001 From: ruby childs Date: Wed, 18 Feb 2026 16:39:06 -0500 Subject: [PATCH 072/415] Fix safety guard false positive on 'format' in URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny pattern `\b(format|mkfs|diskpart)\b` incorrectly blocked commands containing "format" inside URLs (e.g. `curl https://wttr.in?format=3`) because `\b` fires at the boundary between `?` (non-word) and `f` (word). Split into two patterns: - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command (start of line or after shell operators) - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) Co-Authored-By: Claude Opus 4.6 --- nanobot/agent/tools/shell.py | 3 +- tests/test_exec_curl.py | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/test_exec_curl.py diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 18eff64..9c6f1f6 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -26,7 +26,8 @@ class ExecTool(Tool): r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr r"\bdel\s+/[fq]\b", # del /f, del /q r"\brmdir\s+/s\b", # rmdir /s - r"\b(format|mkfs|diskpart)\b", # disk operations + r"(?:^|[;&|]\s*)format\b", # format (as standalone command only) + r"\b(mkfs|diskpart)\b", # disk operations r"\bdd\s+if=", # dd r">\s*/dev/sd", # write to disk r"\b(shutdown|reboot|poweroff)\b", # system power diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py new file mode 100644 index 0000000..2f53fcf --- /dev/null +++ b/tests/test_exec_curl.py @@ -0,0 +1,97 @@ +r"""Tests for ExecTool safety guard — format pattern false positive. + +The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside +URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word +character, so `\b` fires between `?` and `f`. + +The fix splits the pattern: + - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command + - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) +""" + +import re + +import pytest + +from nanobot.agent.tools.shell import ExecTool + + +# --- Guard regression: "format" in URLs must not be blocked --- + + +@pytest.mark.asyncio +async def test_curl_with_format_in_url_not_blocked(): + """curl with ?format= in URL should NOT be blocked by the guard.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute( + command="curl -s 'https://wttr.in/Brooklyn?format=3'" + ) + assert "blocked by safety guard" not in result + + +@pytest.mark.asyncio +async def test_curl_with_format_in_post_body_not_blocked(): + """curl with 'format=json' in POST body should NOT be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute( + command="curl -s -d 'format=json' https://httpbin.org/post" + ) + assert "blocked by safety guard" not in result + + +@pytest.mark.asyncio +async def test_curl_without_format_not_blocked(): + """Plain curl commands should pass the guard.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="curl -s https://httpbin.org/get") + assert "blocked by safety guard" not in result + + +# --- The guard still blocks actual format commands --- + + +@pytest.mark.asyncio +async def test_guard_blocks_standalone_format_command(): + """'format c:' as a standalone command must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="format c:") + assert "blocked by safety guard" in result + + +@pytest.mark.asyncio +async def test_guard_blocks_format_after_semicolon(): + """'echo hi; format c:' must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="echo hi; format c:") + assert "blocked by safety guard" in result + + +@pytest.mark.asyncio +async def test_guard_blocks_format_after_pipe(): + """'echo hi | format' must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="echo hi | format") + assert "blocked by safety guard" in result + + +# --- Regex unit tests (no I/O) --- + + +def test_format_pattern_blocks_disk_commands(): + """The tightened pattern still catches actual format commands.""" + pattern = r"(?:^|[;&|]\s*)format\b" + + assert re.search(pattern, "format c:") + assert re.search(pattern, "echo hi; format c:") + assert re.search(pattern, "echo hi | format") + assert re.search(pattern, "cmd && format d:") + + +def test_format_pattern_allows_urls_and_flags(): + """The tightened pattern does NOT match format inside URLs or flags.""" + pattern = r"(?:^|[;&|]\s*)format\b" + + assert not re.search(pattern, "curl https://wttr.in?format=3") + assert not re.search(pattern, "echo --output-format=json") + assert not re.search(pattern, "curl -d 'format=json' https://api.example.com") + assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'") From 4367038a95a8ae96e0fdfbb37b5488cd9a9fbe49 Mon Sep 17 00:00:00 2001 From: Clayton Wilson Date: Wed, 18 Feb 2026 13:32:06 -0600 Subject: [PATCH 073/415] fix: make cron run command actually execute the agent Wire up an AgentLoop with an on_job callback in the cron_run CLI command so the job's message is sent to the agent and the response is printed. Previously, CronService was created with no on_job callback, causing _execute_job to skip execution silently and always report success. Co-Authored-By: Claude Sonnet 4.6 --- nanobot/cli/commands.py | 44 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2f4ba7b..a45b4a7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -860,17 +860,51 @@ def cron_run( force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), ): """Manually run a job.""" - from nanobot.config.loader import get_data_dir + from nanobot.config.loader import load_config, get_data_dir from nanobot.cron.service import CronService - + from nanobot.bus.queue import MessageBus + from nanobot.agent.loop import AgentLoop + from loguru import logger + logger.disable("nanobot") + + config = load_config() + provider = _make_provider(config) + bus = MessageBus() + agent_loop = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + max_iterations=config.agents.defaults.max_tool_iterations, + memory_window=config.agents.defaults.memory_window, + exec_config=config.tools.exec, + restrict_to_workspace=config.tools.restrict_to_workspace, + ) + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + + result_holder = [] + + async def on_job(job): + response = await agent_loop.process_direct( + job.payload.message, + session_key=f"cron:{job.id}", + channel=job.payload.channel or "cli", + chat_id=job.payload.to or "direct", + ) + result_holder.append(response) + return response + + service.on_job = on_job + async def run(): return await service.run_job(job_id, force=force) - + if asyncio.run(run()): - console.print(f"[green]✓[/green] Job executed") + console.print("[green]✓[/green] Job executed") + if result_holder: + _print_agent_response(result_holder[0], render_markdown=True) else: console.print(f"[red]Failed to run job {job_id}[/red]") From 107a380e61a57d72a23ea84c6ba8b68e0e2936cb Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Wed, 18 Feb 2026 21:22:22 -0300 Subject: [PATCH 074/415] fix: prevent duplicate memory consolidation tasks per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `_consolidating` set to track which sessions have an active consolidation task. Skip creating a new task if one is already in progress for the same session key, and clean up the flag when done. This prevents the excessive API calls reported when messages exceed the memory_window threshold — previously every single message after the threshold triggered a new background consolidation. Closes #751 --- nanobot/agent/loop.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..0e5d3b3 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -89,6 +89,7 @@ class AgentLoop: self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False + self._consolidating: set[str] = set() # Session keys with consolidation in progress self._register_default_tools() def _register_default_tools(self) -> None: @@ -318,8 +319,16 @@ class AgentLoop: return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") - if len(session.messages) > self.memory_window: - asyncio.create_task(self._consolidate_memory(session)) + if len(session.messages) > self.memory_window and session.key not in self._consolidating: + self._consolidating.add(session.key) + + async def _consolidate_and_unlock(): + try: + await self._consolidate_memory(session) + finally: + self._consolidating.discard(session.key) + + asyncio.create_task(_consolidate_and_unlock()) self._set_tool_context(msg.channel, msg.chat_id) initial_messages = self.context.build_messages( From 33d760d31213edf8af992026d3a7e3da33bca52f Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Wed, 18 Feb 2026 21:27:13 -0300 Subject: [PATCH 075/415] fix: handle /help command directly in Telegram, bypassing ACL check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /help command was routed through _forward_command → _handle_message → is_allowed(), which denied access to users not in the allowFrom list. Since /help is purely informational, it should be accessible to all users — similar to how /start already works with its own handler. Add a dedicated _on_help handler that replies directly without going through the message bus access control. Closes #687 --- nanobot/channels/telegram.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 39924b3..fa48fef 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -146,7 +146,7 @@ class TelegramChannel(BaseChannel): # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) - self._app.add_handler(CommandHandler("help", self._forward_command)) + self._app.add_handler(CommandHandler("help", self._on_help)) # Add message handler for text, photos, voice, documents self._app.add_handler( @@ -258,14 +258,28 @@ class TelegramChannel(BaseChannel): """Handle /start command.""" if not update.message or not update.effective_user: return - + user = update.effective_user await update.message.reply_text( f"👋 Hi {user.first_name}! I'm nanobot.\n\n" "Send me a message and I'll respond!\n" "Type /help to see available commands." ) - + + async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /help command directly, bypassing access control. + + /help is informational and should be accessible to all users, + even those not in the allowFrom list. + """ + if not update.message: + return + await update.message.reply_text( + "🐈 nanobot commands:\n" + "/new — Start a new conversation\n" + "/help — Show available commands" + ) + @staticmethod def _sender_id(user) -> str: """Build sender_id with username for allowlist matching.""" From 464352c664c0c059457b8cceafbaa3844011a1d9 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Wed, 18 Feb 2026 21:29:10 -0300 Subject: [PATCH 076/415] fix: allow one retry for models that send interim text before tool calls Some LLM providers (MiniMax, Gemini Flash, GPT-4.1, etc.) send an initial text-only response like "Let me investigate..." before actually making tool calls. The agent loop previously broke immediately on any text response without tool calls, preventing these models from ever using tools. Now, when the model responds with text but hasn't used any tools yet, the loop forwards the text as progress to the user and gives the model one additional iteration to make tool calls. This is limited to a single retry to prevent infinite loops. Closes #705 --- nanobot/agent/loop.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..6acbb38 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -183,6 +183,7 @@ class AgentLoop: iteration = 0 final_content = None tools_used: list[str] = [] + text_only_retried = False while iteration < self.max_iterations: iteration += 1 @@ -226,6 +227,21 @@ class AgentLoop: ) else: final_content = self._strip_think(response.content) + # Some models (MiniMax, Gemini Flash, GPT-4.1, etc.) send an + # interim text response (e.g. "Let me investigate...") before + # making tool calls. If no tools have been used yet and we + # haven't already retried, forward the text as progress and + # give the model one more chance to use tools. + if not tools_used and not text_only_retried and final_content: + text_only_retried = True + logger.debug(f"Interim text response (no tools used yet), retrying: {final_content[:80]}") + if on_progress: + await on_progress(final_content) + messages = self.context.add_assistant_message( + messages, response.content, + reasoning_content=response.reasoning_content, + ) + continue break return final_content, tools_used From c7b5dd93502a829da18e2a7ef2253ae3298f2f28 Mon Sep 17 00:00:00 2001 From: chtangwin Date: Tue, 10 Feb 2026 17:31:45 +0800 Subject: [PATCH 077/415] Fix: Ensure UTF-8 encoding for all file operations --- nanobot/agent/loop.py | 3 ++- nanobot/agent/subagent.py | 4 ++-- nanobot/config/loader.py | 2 +- nanobot/cron/service.py | 4 ++-- nanobot/session/manager.py | 11 ++++++----- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..1cd5730 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -206,7 +206,7 @@ class AgentLoop: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments) + "arguments": json.dumps(tc.arguments, ensure_ascii=False) } } for tc in response.tool_calls @@ -388,6 +388,7 @@ class AgentLoop: ) final_content, _ = await self._run_agent_loop(initial_messages) + if final_content is None: final_content = "Background task completed." diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 203836a..ffefc08 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -146,7 +146,7 @@ class SubagentManager: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments), + "arguments": json.dumps(tc.arguments, ensure_ascii=False), }, } for tc in response.tool_calls @@ -159,7 +159,7 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: - args_str = json.dumps(tool_call.arguments) + args_str = json.dumps(tool_call.arguments, ensure_ascii=False) logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 560c1f5..134e95f 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -31,7 +31,7 @@ def load_config(config_path: Path | None = None) -> Config: if path.exists(): try: - with open(path) as f: + with open(path, encoding="utf-8") as f: data = json.load(f) data = _migrate_config(data) return Config.model_validate(data) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 14666e8..3c77452 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -66,7 +66,7 @@ class CronService: if self.store_path.exists(): try: - data = json.loads(self.store_path.read_text()) + data = json.loads(self.store_path.read_text(encoding="utf-8")) jobs = [] for j in data.get("jobs", []): jobs.append(CronJob( @@ -148,7 +148,7 @@ class CronService: ] } - self.store_path.write_text(json.dumps(data, indent=2)) + self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") async def start(self) -> None: """Start the cron service.""" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 752fce4..42df1b1 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -121,7 +121,7 @@ class SessionManager: created_at = None last_consolidated = 0 - with open(path) as f: + with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: @@ -151,7 +151,7 @@ class SessionManager: """Save a session to disk.""" path = self._get_session_path(session.key) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: metadata_line = { "_type": "metadata", "created_at": session.created_at.isoformat(), @@ -159,9 +159,10 @@ class SessionManager: "metadata": session.metadata, "last_consolidated": session.last_consolidated } - f.write(json.dumps(metadata_line) + "\n") + f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") for msg in session.messages: - f.write(json.dumps(msg) + "\n") + f.write(json.dumps(msg, ensure_ascii=False) + "\n") + self._cache[session.key] = session @@ -181,7 +182,7 @@ class SessionManager: for path in self.sessions_dir.glob("*.jsonl"): try: # Read just the metadata line - with open(path) as f: + with open(path, encoding="utf-8") as f: first_line = f.readline().strip() if first_line: data = json.loads(first_line) From a2379a08ac5467e4b3e628a7264427be73759a9f Mon Sep 17 00:00:00 2001 From: chtangwin Date: Wed, 18 Feb 2026 18:37:17 -0800 Subject: [PATCH 078/415] Fix: Ensure UTF-8 encoding and ensure_ascii=False for remaining file/JSON operations --- nanobot/agent/tools/web.py | 8 ++++---- nanobot/channels/dingtalk.py | 2 +- nanobot/cli/commands.py | 6 +++--- nanobot/config/loader.py | 4 ++-- nanobot/heartbeat/service.py | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 9de1d3c..90cdda8 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -116,7 +116,7 @@ class WebFetchTool(Tool): # Validate URL before fetching is_valid, error_msg = _validate_url(url) if not is_valid: - return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}) + return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) try: async with httpx.AsyncClient( @@ -131,7 +131,7 @@ class WebFetchTool(Tool): # JSON if "application/json" in ctype: - text, extractor = json.dumps(r.json(), indent=2), "json" + text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" # HTML elif "text/html" in ctype or r.text[:256].lower().startswith((" str: """Convert HTML to markdown.""" diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 4a8cdd9..6b27af4 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -208,7 +208,7 @@ class DingTalkChannel(BaseChannel): "msgParam": json.dumps({ "text": msg.content, "title": "Nanobot Reply", - }), + }, ensure_ascii=False), } if not self._http: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2f4ba7b..d879d58 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -243,7 +243,7 @@ Information about the user goes here. for filename, content in templates.items(): file_path = workspace / filename if not file_path.exists(): - file_path.write_text(content) + file_path.write_text(content, encoding="utf-8") console.print(f" [dim]Created {filename}[/dim]") # Create memory directory and MEMORY.md @@ -266,12 +266,12 @@ This file stores important information that should persist across sessions. ## Important Notes (Things to remember) -""") +""", encoding="utf-8") console.print(" [dim]Created memory/MEMORY.md[/dim]") history_file = memory_dir / "HISTORY.md" if not history_file.exists(): - history_file.write_text("") + history_file.write_text("", encoding="utf-8") console.print(" [dim]Created memory/HISTORY.md[/dim]") # Create skills directory for custom user skills diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 134e95f..c789efd 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -55,8 +55,8 @@ def save_config(config: Config, config_path: Path | None = None) -> None: data = config.model_dump(by_alias=True) - with open(path, "w") as f: - json.dump(data, f, indent=2) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) def _migrate_config(data: dict) -> dict: diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 221ed27..a51e5a0 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -65,7 +65,7 @@ class HeartbeatService: """Read HEARTBEAT.md content.""" if self.heartbeat_file.exists(): try: - return self.heartbeat_file.read_text() + return self.heartbeat_file.read_text(encoding="utf-8") except Exception: return None return None From 124c611426eefe06aeb9a5b3d33338286228bb8a Mon Sep 17 00:00:00 2001 From: chtangwin Date: Wed, 18 Feb 2026 18:46:23 -0800 Subject: [PATCH 079/415] Fix: Add ensure_ascii=False to WhatsApp send payload The send() payload contains user message content (msg.content) which may include non-ASCII characters (e.g. CJK, German umlauts, emoji). The auth frame and Discord heartbeat/identify payloads are left unchanged as they only carry ASCII protocol fields. --- nanobot/channels/whatsapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 0cf2dd7..f799347 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -87,7 +87,7 @@ class WhatsAppChannel(BaseChannel): "to": msg.chat_id, "text": msg.content } - await self._ws.send(json.dumps(payload)) + await self._ws.send(json.dumps(payload, ensure_ascii=False)) except Exception as e: logger.error(f"Error sending WhatsApp message: {e}") From 523b2982f4fb2c0dcf74e3a4bb01ebf2a77fd529 Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Thu, 19 Feb 2026 05:22:00 +0100 Subject: [PATCH 080/415] fix: fixed not logging tool uses if a think fragment had them attached. if a think fragment had a tool attached, the tool use would not log. now it does --- nanobot/agent/loop.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..6b45b28 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -198,7 +198,9 @@ class AgentLoop: if response.has_tool_calls: if on_progress: clean = self._strip_think(response.content) - await on_progress(clean or self._tool_hint(response.tool_calls)) + if clean: + await on_progress(clean) + await on_progress(self._tool_hint(response.tool_calls)) tool_call_dicts = [ { From 9789307dd691d469881b45ad7e0a7f655bab110d Mon Sep 17 00:00:00 2001 From: PiEgg Date: Thu, 19 Feb 2026 13:30:02 +0800 Subject: [PATCH 081/415] Fix Codex provider routing for GitHub Copilot models --- nanobot/config/schema.py | 25 +++++++++++++- nanobot/providers/litellm_provider.py | 13 +++++++- nanobot/providers/openai_codex_provider.py | 2 +- nanobot/providers/registry.py | 16 ++++++++- tests/test_commands.py | 38 ++++++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ce9634c..9558072 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -287,11 +287,34 @@ class Config(BaseSettings): from nanobot.providers.registry import PROVIDERS model_lower = (model or self.agents.defaults.model).lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + def _matches_model_prefix(spec_name: str) -> bool: + if not model_prefix: + return False + return normalized_prefix == spec_name + + def _keyword_matches(keyword: str) -> bool: + keyword_lower = keyword.lower() + return ( + keyword_lower in model_lower + or keyword_lower.replace("-", "_") in model_normalized + ) + + # Explicit provider prefix in model name wins over generic keyword matches. + # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and _matches_model_prefix(spec.name): + if spec.is_oauth or p.api_key: + return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and any(kw in model_lower for kw in spec.keywords): + if p and any(_keyword_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..3fec618 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -88,10 +88,21 @@ class LiteLLMProvider(LLMProvider): # Standard mode: auto-prefix for known providers spec = find_by_model(model) if spec and spec.litellm_prefix: + model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix) if not any(model.startswith(s) for s in spec.skip_prefixes): model = f"{spec.litellm_prefix}/{model}" - + return model + + @staticmethod + def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str: + """Normalize explicit provider prefixes like `github-copilot/...`.""" + if "/" not in model: + return model + prefix, remainder = model.split("/", 1) + if prefix.lower().replace("-", "_") != spec_name: + return model + return f"{canonical_prefix}/{remainder}" def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None: """Apply model-specific parameter overrides from the registry.""" diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 5067438..2336e71 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -80,7 +80,7 @@ class OpenAICodexProvider(LLMProvider): def _strip_model_prefix(model: str) -> str: - if model.startswith("openai-codex/"): + if model.startswith("openai-codex/") or model.startswith("openai_codex/"): return model.split("/", 1)[1] return model diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 49b735c..d986fe6 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -384,10 +384,24 @@ def find_by_model(model: str) -> ProviderSpec | None: """Match a standard provider by model-name keyword (case-insensitive). Skips gateways/local — those are matched by api_key/api_base instead.""" model_lower = model.lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + # Prefer explicit provider prefix in model name. for spec in PROVIDERS: if spec.is_gateway or spec.is_local: continue - if any(kw in model_lower for kw in spec.keywords): + if model_prefix and normalized_prefix == spec.name: + return spec + + for spec in PROVIDERS: + if spec.is_gateway or spec.is_local: + continue + if any( + kw in model_lower or kw.replace("-", "_") in model_normalized + for kw in spec.keywords + ): return spec return None diff --git a/tests/test_commands.py b/tests/test_commands.py index f5495fd..044d113 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,6 +6,10 @@ import pytest from typer.testing import CliRunner from nanobot.cli.commands import app +from nanobot.config.schema import Config +from nanobot.providers.litellm_provider import LiteLLMProvider +from nanobot.providers.openai_codex_provider import _strip_model_prefix +from nanobot.providers.registry import find_by_model runner = CliRunner() @@ -90,3 +94,37 @@ def test_onboard_existing_workspace_safe_create(mock_paths): assert "Created workspace" not in result.stdout assert "Created AGENTS.md" in result.stdout assert (workspace_dir / "AGENTS.md").exists() + + +def test_config_matches_github_copilot_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "github-copilot/gpt-5.3-codex" + + assert config.get_provider_name() == "github_copilot" + + +def test_config_matches_openai_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "openai-codex/gpt-5.1-codex" + + assert config.get_provider_name() == "openai_codex" + + +def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword(): + spec = find_by_model("github-copilot/gpt-5.3-codex") + + assert spec is not None + assert spec.name == "github_copilot" + + +def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix(): + provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex") + + resolved = provider._resolve_model("github-copilot/gpt-5.3-codex") + + assert resolved == "github_copilot/gpt-5.3-codex" + + +def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): + assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" + assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" From d08c02225551b5410e126c91b7224c773b5c9187 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 19 Feb 2026 16:31:00 +0800 Subject: [PATCH 082/415] feat(feishu): support sending images, audio, and files - Add image upload via im.v1.image.create API - Add file upload via im.v1.file.create API - Support sending images (.png, .jpg, .gif, etc.) as image messages - Support sending audio (.opus) as voice messages - Support sending other files as file messages - Refactor send() to handle media attachments before text content --- nanobot/channels/feishu.py | 179 ++++++++++++++++++++++++++++++------- 1 file changed, 149 insertions(+), 30 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index bc4a2b8..6f2881b 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -17,6 +17,10 @@ from nanobot.config.schema import FeishuConfig try: import lark_oapi as lark from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, + CreateImageRequest, + CreateImageRequestBody, CreateMessageRequest, CreateMessageRequestBody, CreateMessageReactionRequest, @@ -284,48 +288,163 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] + # Image file extensions + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} + + # Audio file extensions (Feishu only supports opus for audio messages) + _AUDIO_EXTS = {".opus"} + + # File type mapping for Feishu file upload API + _FILE_TYPE_MAP = { + ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", + ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", + } + + def _upload_image_sync(self, file_path: str) -> str | None: + """Upload an image to Feishu and return the image_key.""" + import os + try: + with open(file_path, "rb") as f: + request = CreateImageRequest.builder() \ + .request_body( + CreateImageRequestBody.builder() + .image_type("message") + .image(f) + .build() + ).build() + response = self._client.im.v1.image.create(request) + if response.success(): + image_key = response.data.image_key + logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") + return image_key + else: + logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}") + return None + except Exception as e: + logger.error(f"Error uploading image {file_path}: {e}") + return None + + def _upload_file_sync(self, file_path: str) -> str | None: + """Upload a file to Feishu and return the file_key.""" + import os + ext = os.path.splitext(file_path)[1].lower() + file_type = self._FILE_TYPE_MAP.get(ext, "stream") + file_name = os.path.basename(file_path) + try: + with open(file_path, "rb") as f: + request = CreateFileRequest.builder() \ + .request_body( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(file_name) + .file(f) + .build() + ).build() + response = self._client.im.v1.file.create(request) + if response.success(): + file_key = response.data.file_key + logger.debug(f"Uploaded file {file_name}: {file_key}") + return file_key + else: + logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}") + return None + except Exception as e: + logger.error(f"Error uploading file {file_path}: {e}") + return None + + def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: + """Send a single message (text/image/file/interactive) synchronously.""" + try: + request = CreateMessageRequest.builder() \ + .receive_id_type(receive_id_type) \ + .request_body( + CreateMessageRequestBody.builder() + .receive_id(receive_id) + .msg_type(msg_type) + .content(content) + .build() + ).build() + response = self._client.im.v1.message.create(request) + if not response.success(): + logger.error( + f"Failed to send Feishu {msg_type} message: code={response.code}, " + f"msg={response.msg}, log_id={response.get_log_id()}" + ) + return False + logger.debug(f"Feishu {msg_type} message sent to {receive_id}") + return True + except Exception as e: + logger.error(f"Error sending Feishu {msg_type} message: {e}") + return False + async def send(self, msg: OutboundMessage) -> None: - """Send a message through Feishu.""" + """Send a message through Feishu, including media (images/files) if present.""" if not self._client: logger.warning("Feishu client not initialized") return - + try: + import os + # Determine receive_id_type based on chat_id format # open_id starts with "ou_", chat_id starts with "oc_" if msg.chat_id.startswith("oc_"): receive_id_type = "chat_id" else: receive_id_type = "open_id" - - # Build card with markdown + table support - elements = self._build_card_elements(msg.content) - card = { - "config": {"wide_screen_mode": True}, - "elements": elements, - } - content = json.dumps(card, ensure_ascii=False) - - request = CreateMessageRequest.builder() \ - .receive_id_type(receive_id_type) \ - .request_body( - CreateMessageRequestBody.builder() - .receive_id(msg.chat_id) - .msg_type("interactive") - .content(content) - .build() - ).build() - - response = self._client.im.v1.message.create(request) - - if not response.success(): - logger.error( - f"Failed to send Feishu message: code={response.code}, " - f"msg={response.msg}, log_id={response.get_log_id()}" + + loop = asyncio.get_running_loop() + + # --- Send media attachments first --- + if msg.media: + for file_path in msg.media: + if not os.path.isfile(file_path): + logger.warning(f"Media file not found: {file_path}") + continue + + ext = os.path.splitext(file_path)[1].lower() + if ext in self._IMAGE_EXTS: + # Upload and send as image + image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path) + if image_key: + content = json.dumps({"image_key": image_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "image", content, + ) + elif ext in self._AUDIO_EXTS: + # Upload and send as audio (voice message) + file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if file_key: + content = json.dumps({"file_key": file_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "audio", content, + ) + else: + # Upload and send as file + file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if file_key: + content = json.dumps({"file_key": file_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "file", content, + ) + + # --- Send text content (if any) --- + if msg.content and msg.content.strip(): + # Build card with markdown + table support + elements = self._build_card_elements(msg.content) + card = { + "config": {"wide_screen_mode": True}, + "elements": elements, + } + content = json.dumps(card, ensure_ascii=False) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", content, ) - else: - logger.debug(f"Feishu message sent to {msg.chat_id}") - + except Exception as e: logger.error(f"Error sending Feishu message: {e}") From 1b49bf96021eba1bfc95e3c0a1ab6cae36271973 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 19 Feb 2026 10:26:49 -0300 Subject: [PATCH 083/415] fix: avoid duplicate messages on retry and reset final_content Address review feedback: - Remove on_progress call for interim text to prevent duplicate messages when the model simply answers a direct question - Reset final_content to None before continue to avoid stale interim text leaking as the final response on empty retry Closes #705 --- nanobot/agent/loop.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6acbb38..532488f 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -230,17 +230,18 @@ class AgentLoop: # Some models (MiniMax, Gemini Flash, GPT-4.1, etc.) send an # interim text response (e.g. "Let me investigate...") before # making tool calls. If no tools have been used yet and we - # haven't already retried, forward the text as progress and - # give the model one more chance to use tools. + # haven't already retried, add the text to the conversation + # and give the model one more chance to use tools. + # We do NOT forward the interim text as progress to avoid + # duplicate messages when the model simply answers directly. if not tools_used and not text_only_retried and final_content: text_only_retried = True logger.debug(f"Interim text response (no tools used yet), retrying: {final_content[:80]}") - if on_progress: - await on_progress(final_content) messages = self.context.add_assistant_message( messages, response.content, reasoning_content=response.reasoning_content, ) + final_content = None continue break From c86dbc9f45e4467d54ec0a56ce5597219071d8ee Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 19 Feb 2026 10:27:11 -0300 Subject: [PATCH 084/415] fix: wait for killed process after shell timeout to prevent fd leaks When a shell command times out, process.kill() is called but the process object was never awaited after that. This leaves subprocess pipes undrained and file descriptors open. If many commands time out, fd leaks accumulate. Add a bounded wait (5s) after kill to let the process fully terminate and release its resources. --- nanobot/agent/tools/shell.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 18eff64..ceac6b7 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -81,6 +81,12 @@ class ExecTool(Tool): ) except asyncio.TimeoutError: process.kill() + # Wait for the process to fully terminate so pipes are + # drained and file descriptors are released. + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + pass return f"Error: Command timed out after {self.timeout} seconds" output_parts = [] From 3b4763b3f989c00da674933f459730717ea7385a Mon Sep 17 00:00:00 2001 From: tercerapersona Date: Thu, 19 Feb 2026 11:05:22 -0300 Subject: [PATCH 085/415] feat: add Anthropic prompt caching via cache_control Inject cache_control: {"type": "ephemeral"} on the system message and last tool definition for providers that support prompt caching. Adds supports_prompt_caching flag to ProviderSpec (enabled for Anthropic only) and skips caching when routing through a gateway. Co-Authored-By: Claude Sonnet 4.6 --- nanobot/providers/litellm_provider.py | 43 +++++++++++++++++++++++++-- nanobot/providers/registry.py | 4 +++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..950a138 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -93,6 +93,41 @@ class LiteLLMProvider(LLMProvider): return model + def _supports_cache_control(self, model: str) -> bool: + """Return True when the provider supports cache_control on content blocks.""" + if self._gateway is not None: + return False + spec = find_by_model(model) + return spec is not None and spec.supports_prompt_caching + + def _apply_cache_control( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]: + """Return copies of messages and tools with cache_control injected.""" + # Transform the system message + new_messages = [] + for msg in messages: + if msg.get("role") == "system": + content = msg["content"] + if isinstance(content, str): + new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}] + else: + new_content = list(content) + new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}} + new_messages.append({**msg, "content": new_content}) + else: + new_messages.append(msg) + + # Add cache_control to the last tool definition + new_tools = tools + if tools: + new_tools = list(tools) + new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}} + + return new_messages, new_tools + def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None: """Apply model-specific parameter overrides from the registry.""" model_lower = model.lower() @@ -124,8 +159,12 @@ class LiteLLMProvider(LLMProvider): Returns: LLMResponse with content and/or tool calls. """ - model = self._resolve_model(model or self.default_model) - + original_model = model or self.default_model + model = self._resolve_model(original_model) + + if self._supports_cache_control(original_model): + messages, tools = self._apply_cache_control(messages, tools) + # Clamp max_tokens to at least 1 — negative or zero values cause # LiteLLM to reject the request with "max_tokens must be at least 1". max_tokens = max(1, max_tokens) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 49b735c..2584e0e 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -57,6 +57,9 @@ class ProviderSpec: # Direct providers bypass LiteLLM entirely (e.g., CustomProvider) is_direct: bool = False + # Provider supports cache_control on content blocks (e.g. Anthropic prompt caching) + supports_prompt_caching: bool = False + @property def label(self) -> str: return self.display_name or self.name.title() @@ -155,6 +158,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="", strip_model_prefix=False, model_overrides=(), + supports_prompt_caching=True, ), # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed. From d748e6eca33baf4b419c624b2a5e702b0d5b6b35 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:28:13 +0000 Subject: [PATCH 086/415] fix: pin dependency version ranges --- pyproject.toml | 54 +++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbd6feb..64a884d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,37 +17,37 @@ classifiers = [ ] dependencies = [ - "typer>=0.9.0", - "litellm>=1.0.0", - "pydantic>=2.0.0", - "pydantic-settings>=2.0.0", - "websockets>=12.0", - "websocket-client>=1.6.0", - "httpx>=0.25.0", - "oauth-cli-kit>=0.1.1", - "loguru>=0.7.0", - "readability-lxml>=0.8.0", - "rich>=13.0.0", - "croniter>=2.0.0", - "dingtalk-stream>=0.4.0", - "python-telegram-bot[socks]>=21.0", - "lark-oapi>=1.0.0", - "socksio>=1.0.0", - "python-socketio>=5.11.0", - "msgpack>=1.0.8", - "slack-sdk>=3.26.0", - "slackify-markdown>=0.2.0", - "qq-botpy>=1.0.0", - "python-socks[asyncio]>=2.4.0", - "prompt-toolkit>=3.0.0", - "mcp>=1.0.0", - "json-repair>=0.30.0", + "typer>=0.20.0,<1.0.0", + "litellm>=1.81.5,<2.0.0", + "pydantic>=2.12.0,<3.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "websockets>=16.0,<17.0", + "websocket-client>=1.9.0,<2.0.0", + "httpx>=0.28.0,<1.0.0", + "oauth-cli-kit>=0.1.3,<1.0.0", + "loguru>=0.7.3,<1.0.0", + "readability-lxml>=0.8.4,<1.0.0", + "rich>=14.0.0,<15.0.0", + "croniter>=6.0.0,<7.0.0", + "dingtalk-stream>=0.24.0,<1.0.0", + "python-telegram-bot[socks]>=22.0,<23.0", + "lark-oapi>=1.5.0,<2.0.0", + "socksio>=1.0.0,<2.0.0", + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + "qq-botpy>=1.2.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0", + "prompt-toolkit>=3.0.50,<4.0.0", + "mcp>=1.26.0,<2.0.0", + "json-repair>=0.57.0,<1.0.0", ] [project.optional-dependencies] dev = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", + "pytest>=9.0.0,<10.0.0", + "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", ] From 3890f1a7dd040050df64b11755d392c8b2689806 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:33:08 +0000 Subject: [PATCH 087/415] refactor(feishu): clean up send() and remove dead code --- nanobot/channels/feishu.py | 85 +++++++++++--------------------------- 1 file changed, 24 insertions(+), 61 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6f2881b..651d655 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -2,6 +2,7 @@ import asyncio import json +import os import re import threading from collections import OrderedDict @@ -267,7 +268,6 @@ class FeishuChannel(BaseChannel): before = protected[last_end:m.start()].strip() if before: elements.append({"tag": "markdown", "content": before}) - level = len(m.group(1)) text = m.group(2).strip() elements.append({ "tag": "div", @@ -288,13 +288,8 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] - # Image file extensions _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} - - # Audio file extensions (Feishu only supports opus for audio messages) _AUDIO_EXTS = {".opus"} - - # File type mapping for Feishu file upload API _FILE_TYPE_MAP = { ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", @@ -302,7 +297,6 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" - import os try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -326,7 +320,6 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" - import os ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -384,65 +377,35 @@ class FeishuChannel(BaseChannel): return try: - import os - - # Determine receive_id_type based on chat_id format - # open_id starts with "ou_", chat_id starts with "oc_" - if msg.chat_id.startswith("oc_"): - receive_id_type = "chat_id" - else: - receive_id_type = "open_id" - + receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id" loop = asyncio.get_running_loop() - # --- Send media attachments first --- - if msg.media: - for file_path in msg.media: - if not os.path.isfile(file_path): - logger.warning(f"Media file not found: {file_path}") - continue + for file_path in msg.media: + if not os.path.isfile(file_path): + logger.warning(f"Media file not found: {file_path}") + continue + ext = os.path.splitext(file_path)[1].lower() + if ext in self._IMAGE_EXTS: + key = await loop.run_in_executor(None, self._upload_image_sync, file_path) + if key: + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}), + ) + else: + key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if key: + media_type = "audio" if ext in self._AUDIO_EXTS else "file" + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}), + ) - ext = os.path.splitext(file_path)[1].lower() - if ext in self._IMAGE_EXTS: - # Upload and send as image - image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path) - if image_key: - content = json.dumps({"image_key": image_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "image", content, - ) - elif ext in self._AUDIO_EXTS: - # Upload and send as audio (voice message) - file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) - if file_key: - content = json.dumps({"file_key": file_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "audio", content, - ) - else: - # Upload and send as file - file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) - if file_key: - content = json.dumps({"file_key": file_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "file", content, - ) - - # --- Send text content (if any) --- if msg.content and msg.content.strip(): - # Build card with markdown + table support - elements = self._build_card_elements(msg.content) - card = { - "config": {"wide_screen_mode": True}, - "elements": elements, - } - content = json.dumps(card, ensure_ascii=False) + card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)} await loop.run_in_executor( None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", content, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), ) except Exception as e: From b11f0ce6a9bc8159ed1a7a6937c7c93c80a54733 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:39:44 +0000 Subject: [PATCH 088/415] fix: prefer explicit provider prefix over keyword match to fix Codex routing --- nanobot/config/schema.py | 21 ++++++--------------- nanobot/providers/registry.py | 16 +++++----------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9558072..6a1257e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -291,30 +291,21 @@ class Config(BaseSettings): model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") - def _matches_model_prefix(spec_name: str) -> bool: - if not model_prefix: - return False - return normalized_prefix == spec_name + def _kw_matches(kw: str) -> bool: + kw = kw.lower() + return kw in model_lower or kw.replace("-", "_") in model_normalized - def _keyword_matches(keyword: str) -> bool: - keyword_lower = keyword.lower() - return ( - keyword_lower in model_lower - or keyword_lower.replace("-", "_") in model_normalized - ) - - # Explicit provider prefix in model name wins over generic keyword matches. - # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + # Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex. for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and _matches_model_prefix(spec.name): + if p and model_prefix and normalized_prefix == spec.name: if spec.is_oauth or p.api_key: return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and any(_keyword_matches(kw) for kw in spec.keywords): + if p and any(_kw_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index d986fe6..3071793 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -387,21 +387,15 @@ def find_by_model(model: str) -> ProviderSpec | None: model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") + std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local] - # Prefer explicit provider prefix in model name. - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue + # Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex. + for spec in std_specs: if model_prefix and normalized_prefix == spec.name: return spec - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue - if any( - kw in model_lower or kw.replace("-", "_") in model_normalized - for kw in spec.keywords - ): + for spec in std_specs: + if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords): return spec return None From fbfb030a6eeabf6b3abf601f7cbe6514da876943 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:48:09 +0000 Subject: [PATCH 089/415] chore: remove network-dependent test file for shell guard --- tests/test_exec_curl.py | 97 ----------------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 tests/test_exec_curl.py diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py deleted file mode 100644 index 2f53fcf..0000000 --- a/tests/test_exec_curl.py +++ /dev/null @@ -1,97 +0,0 @@ -r"""Tests for ExecTool safety guard — format pattern false positive. - -The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside -URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word -character, so `\b` fires between `?` and `f`. - -The fix splits the pattern: - - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command - - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) -""" - -import re - -import pytest - -from nanobot.agent.tools.shell import ExecTool - - -# --- Guard regression: "format" in URLs must not be blocked --- - - -@pytest.mark.asyncio -async def test_curl_with_format_in_url_not_blocked(): - """curl with ?format= in URL should NOT be blocked by the guard.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute( - command="curl -s 'https://wttr.in/Brooklyn?format=3'" - ) - assert "blocked by safety guard" not in result - - -@pytest.mark.asyncio -async def test_curl_with_format_in_post_body_not_blocked(): - """curl with 'format=json' in POST body should NOT be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute( - command="curl -s -d 'format=json' https://httpbin.org/post" - ) - assert "blocked by safety guard" not in result - - -@pytest.mark.asyncio -async def test_curl_without_format_not_blocked(): - """Plain curl commands should pass the guard.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="curl -s https://httpbin.org/get") - assert "blocked by safety guard" not in result - - -# --- The guard still blocks actual format commands --- - - -@pytest.mark.asyncio -async def test_guard_blocks_standalone_format_command(): - """'format c:' as a standalone command must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="format c:") - assert "blocked by safety guard" in result - - -@pytest.mark.asyncio -async def test_guard_blocks_format_after_semicolon(): - """'echo hi; format c:' must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="echo hi; format c:") - assert "blocked by safety guard" in result - - -@pytest.mark.asyncio -async def test_guard_blocks_format_after_pipe(): - """'echo hi | format' must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="echo hi | format") - assert "blocked by safety guard" in result - - -# --- Regex unit tests (no I/O) --- - - -def test_format_pattern_blocks_disk_commands(): - """The tightened pattern still catches actual format commands.""" - pattern = r"(?:^|[;&|]\s*)format\b" - - assert re.search(pattern, "format c:") - assert re.search(pattern, "echo hi; format c:") - assert re.search(pattern, "echo hi | format") - assert re.search(pattern, "cmd && format d:") - - -def test_format_pattern_allows_urls_and_flags(): - """The tightened pattern does NOT match format inside URLs or flags.""" - pattern = r"(?:^|[;&|]\s*)format\b" - - assert not re.search(pattern, "curl https://wttr.in?format=3") - assert not re.search(pattern, "echo --output-format=json") - assert not re.search(pattern, "curl -d 'format=json' https://api.example.com") - assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'") From 53b83a38e2dc44b91e7890594abb1bd2220a6b03 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 19 Feb 2026 17:19:36 -0300 Subject: [PATCH 090/415] fix: use loguru native formatting to prevent KeyError on messages containing curly braces Closes #857 --- nanobot/agent/loop.py | 12 ++++++------ nanobot/agent/subagent.py | 4 ++-- nanobot/agent/tools/mcp.py | 2 +- nanobot/bus/queue.py | 2 +- nanobot/channels/dingtalk.py | 18 +++++++++--------- nanobot/channels/discord.py | 10 +++++----- nanobot/channels/email.py | 4 ++-- nanobot/channels/feishu.py | 26 +++++++++++++------------- nanobot/channels/manager.py | 6 +++--- nanobot/channels/mochat.py | 24 ++++++++++++------------ nanobot/channels/qq.py | 6 +++--- nanobot/channels/slack.py | 8 ++++---- nanobot/channels/telegram.py | 16 ++++++++-------- nanobot/channels/whatsapp.py | 10 +++++----- nanobot/cron/service.py | 4 ++-- nanobot/heartbeat/service.py | 4 ++-- nanobot/providers/transcription.py | 2 +- nanobot/session/manager.py | 2 +- 18 files changed, 80 insertions(+), 80 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..cbab5aa 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -219,7 +219,7 @@ class AgentLoop: for tool_call in response.tool_calls: tools_used.append(tool_call.name) args_str = json.dumps(tool_call.arguments, ensure_ascii=False) - logger.info(f"Tool call: {tool_call.name}({args_str[:200]})") + logger.info("Tool call: {}({})", tool_call.name, args_str[:200]) result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result @@ -247,7 +247,7 @@ class AgentLoop: if response: await self.bus.publish_outbound(response) except Exception as e: - logger.error(f"Error processing message: {e}") + logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, @@ -292,7 +292,7 @@ class AgentLoop: return await self._process_system_message(msg) preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content - logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") + logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) key = session_key or msg.session_key session = self.sessions.get_or_create(key) @@ -344,7 +344,7 @@ class AgentLoop: final_content = "I've completed processing but have no response to give." preview = final_content[:120] + "..." if len(final_content) > 120 else final_content - logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") + logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) session.add_message("user", msg.content) session.add_message("assistant", final_content, @@ -469,7 +469,7 @@ Respond with ONLY valid JSON, no markdown fences.""" text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() result = json_repair.loads(text) if not isinstance(result, dict): - logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}") + logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200]) return if entry := result.get("history_entry"): @@ -484,7 +484,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = len(session.messages) - keep_count logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}") except Exception as e: - logger.error(f"Memory consolidation failed: {e}") + logger.error("Memory consolidation failed: {}", e) async def process_direct( self, diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 203836a..ae0e492 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -160,7 +160,7 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: args_str = json.dumps(tool_call.arguments) - logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") + logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str) result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ "role": "tool", @@ -180,7 +180,7 @@ class SubagentManager: except Exception as e: error_msg = f"Error: {str(e)}" - logger.error(f"Subagent [{task_id}] failed: {e}") + logger.error("Subagent [{}] failed: {}", task_id, e) await self._announce_result(task_id, label, task, error_msg, origin, "error") async def _announce_result( diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 1c8eac4..4e61923 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -77,4 +77,4 @@ async def connect_mcp_servers( logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") except Exception as e: - logger.error(f"MCP server '{name}': failed to connect: {e}") + logger.error("MCP server '{}': failed to connect: {}", name, e) diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py index 4123d06..554c0ec 100644 --- a/nanobot/bus/queue.py +++ b/nanobot/bus/queue.py @@ -62,7 +62,7 @@ class MessageBus: try: await callback(msg) except Exception as e: - logger.error(f"Error dispatching to {msg.channel}: {e}") + logger.error("Error dispatching to {}: {}", msg.channel, e) except asyncio.TimeoutError: continue diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 4a8cdd9..3ac233f 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -65,7 +65,7 @@ class NanobotDingTalkHandler(CallbackHandler): sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id sender_name = chatbot_msg.sender_nick or "Unknown" - logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}") + logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) # Forward to Nanobot via _on_message (non-blocking). # Store reference to prevent GC before task completes. @@ -78,7 +78,7 @@ class NanobotDingTalkHandler(CallbackHandler): return AckMessage.STATUS_OK, "OK" except Exception as e: - logger.error(f"Error processing DingTalk message: {e}") + logger.error("Error processing DingTalk message: {}", e) # Return OK to avoid retry loop from DingTalk server return AckMessage.STATUS_OK, "Error" @@ -142,13 +142,13 @@ class DingTalkChannel(BaseChannel): try: await self._client.start() except Exception as e: - logger.warning(f"DingTalk stream error: {e}") + logger.warning("DingTalk stream error: {}", e) if self._running: logger.info("Reconnecting DingTalk stream in 5 seconds...") await asyncio.sleep(5) except Exception as e: - logger.exception(f"Failed to start DingTalk channel: {e}") + logger.exception("Failed to start DingTalk channel: {}", e) async def stop(self) -> None: """Stop the DingTalk bot.""" @@ -186,7 +186,7 @@ class DingTalkChannel(BaseChannel): self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 return self._access_token except Exception as e: - logger.error(f"Failed to get DingTalk access token: {e}") + logger.error("Failed to get DingTalk access token: {}", e) return None async def send(self, msg: OutboundMessage) -> None: @@ -218,11 +218,11 @@ class DingTalkChannel(BaseChannel): try: resp = await self._http.post(url, json=data, headers=headers) if resp.status_code != 200: - logger.error(f"DingTalk send failed: {resp.text}") + logger.error("DingTalk send failed: {}", resp.text) else: logger.debug(f"DingTalk message sent to {msg.chat_id}") except Exception as e: - logger.error(f"Error sending DingTalk message: {e}") + logger.error("Error sending DingTalk message: {}", e) async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: """Handle incoming message (called by NanobotDingTalkHandler). @@ -231,7 +231,7 @@ class DingTalkChannel(BaseChannel): permission checks before publishing to the bus. """ try: - logger.info(f"DingTalk inbound: {content} from {sender_name}") + logger.info("DingTalk inbound: {} from {}", content, sender_name) await self._handle_message( sender_id=sender_id, chat_id=sender_id, # For private chat, chat_id == sender_id @@ -242,4 +242,4 @@ class DingTalkChannel(BaseChannel): }, ) except Exception as e: - logger.error(f"Error publishing DingTalk message: {e}") + logger.error("Error publishing DingTalk message: {}", e) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index a76d6ac..ee54eed 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -51,7 +51,7 @@ class DiscordChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Discord gateway error: {e}") + logger.warning("Discord gateway error: {}", e) if self._running: logger.info("Reconnecting to Discord gateway in 5 seconds...") await asyncio.sleep(5) @@ -101,7 +101,7 @@ class DiscordChannel(BaseChannel): return except Exception as e: if attempt == 2: - logger.error(f"Error sending Discord message: {e}") + logger.error("Error sending Discord message: {}", e) else: await asyncio.sleep(1) finally: @@ -116,7 +116,7 @@ class DiscordChannel(BaseChannel): try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}") + logger.warning("Invalid JSON from Discord gateway: {}", raw[:100]) continue op = data.get("op") @@ -175,7 +175,7 @@ class DiscordChannel(BaseChannel): try: await self._ws.send(json.dumps(payload)) except Exception as e: - logger.warning(f"Discord heartbeat failed: {e}") + logger.warning("Discord heartbeat failed: {}", e) break await asyncio.sleep(interval_s) @@ -219,7 +219,7 @@ class DiscordChannel(BaseChannel): media_paths.append(str(file_path)) content_parts.append(f"[attachment: {file_path}]") except Exception as e: - logger.warning(f"Failed to download Discord attachment: {e}") + logger.warning("Failed to download Discord attachment: {}", e) content_parts.append(f"[attachment: {filename} - download failed]") reply_to = (payload.get("referenced_message") or {}).get("id") diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 0e47067..8a1ee79 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -94,7 +94,7 @@ class EmailChannel(BaseChannel): metadata=item.get("metadata", {}), ) except Exception as e: - logger.error(f"Email polling error: {e}") + logger.error("Email polling error: {}", e) await asyncio.sleep(poll_seconds) @@ -143,7 +143,7 @@ class EmailChannel(BaseChannel): try: await asyncio.to_thread(self._smtp_send, email_msg) except Exception as e: - logger.error(f"Error sending email to {to_addr}: {e}") + logger.error("Error sending email to {}: {}", to_addr, e) raise def _validate_config(self) -> bool: diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 651d655..6f62202 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -156,7 +156,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.start() except Exception as e: - logger.warning(f"Feishu WebSocket error: {e}") + logger.warning("Feishu WebSocket error: {}", e) if self._running: import time; time.sleep(5) @@ -177,7 +177,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.stop() except Exception as e: - logger.warning(f"Error stopping WebSocket client: {e}") + logger.warning("Error stopping WebSocket client: {}", e) logger.info("Feishu bot stopped") def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: @@ -194,11 +194,11 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message_reaction.create(request) if not response.success(): - logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}") + logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: logger.debug(f"Added {emoji_type} reaction to message {message_id}") except Exception as e: - logger.warning(f"Error adding reaction: {e}") + logger.warning("Error adding reaction: {}", e) async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: """ @@ -312,10 +312,10 @@ class FeishuChannel(BaseChannel): logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") return image_key else: - logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}") + logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.error(f"Error uploading image {file_path}: {e}") + logger.error("Error uploading image {}: {}", file_path, e) return None def _upload_file_sync(self, file_path: str) -> str | None: @@ -339,10 +339,10 @@ class FeishuChannel(BaseChannel): logger.debug(f"Uploaded file {file_name}: {file_key}") return file_key else: - logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}") + logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.error(f"Error uploading file {file_path}: {e}") + logger.error("Error uploading file {}: {}", file_path, e) return None def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: @@ -360,14 +360,14 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message.create(request) if not response.success(): logger.error( - f"Failed to send Feishu {msg_type} message: code={response.code}, " - f"msg={response.msg}, log_id={response.get_log_id()}" + "Failed to send Feishu {} message: code={}, msg={}, log_id={}", + msg_type, response.code, response.msg, response.get_log_id() ) return False logger.debug(f"Feishu {msg_type} message sent to {receive_id}") return True except Exception as e: - logger.error(f"Error sending Feishu {msg_type} message: {e}") + logger.error("Error sending Feishu {} message: {}", msg_type, e) return False async def send(self, msg: OutboundMessage) -> None: @@ -409,7 +409,7 @@ class FeishuChannel(BaseChannel): ) except Exception as e: - logger.error(f"Error sending Feishu message: {e}") + logger.error("Error sending Feishu message: {}", e) def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: """ @@ -481,4 +481,4 @@ class FeishuChannel(BaseChannel): ) except Exception as e: - logger.error(f"Error processing Feishu message: {e}") + logger.error("Error processing Feishu message: {}", e) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index e860d26..3e714c3 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -142,7 +142,7 @@ class ChannelManager: try: await channel.start() except Exception as e: - logger.error(f"Failed to start channel {name}: {e}") + logger.error("Failed to start channel {}: {}", name, e) async def start_all(self) -> None: """Start all channels and the outbound dispatcher.""" @@ -180,7 +180,7 @@ class ChannelManager: await channel.stop() logger.info(f"Stopped {name} channel") except Exception as e: - logger.error(f"Error stopping {name}: {e}") + logger.error("Error stopping {}: {}", name, e) async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" @@ -198,7 +198,7 @@ class ChannelManager: try: await channel.send(msg) except Exception as e: - logger.error(f"Error sending to {msg.channel}: {e}") + logger.error("Error sending to {}: {}", msg.channel, e) else: logger.warning(f"Unknown channel: {msg.channel}") diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 30c3dbf..e762dfd 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -322,7 +322,7 @@ class MochatChannel(BaseChannel): await self._api_send("/api/claw/sessions/send", "sessionId", target.id, content, msg.reply_to) except Exception as e: - logger.error(f"Failed to send Mochat message: {e}") + logger.error("Failed to send Mochat message: {}", e) # ---- config / init helpers --------------------------------------------- @@ -380,7 +380,7 @@ class MochatChannel(BaseChannel): @client.event async def connect_error(data: Any) -> None: - logger.error(f"Mochat websocket connect error: {data}") + logger.error("Mochat websocket connect error: {}", data) @client.on("claw.session.events") async def on_session_events(payload: dict[str, Any]) -> None: @@ -407,7 +407,7 @@ class MochatChannel(BaseChannel): ) return True except Exception as e: - logger.error(f"Failed to connect Mochat websocket: {e}") + logger.error("Failed to connect Mochat websocket: {}", e) try: await client.disconnect() except Exception: @@ -444,7 +444,7 @@ class MochatChannel(BaseChannel): "limit": self.config.watch_limit, }) if not ack.get("result"): - logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}") + logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error')) return False data = ack.get("data") @@ -466,7 +466,7 @@ class MochatChannel(BaseChannel): return True ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) if not ack.get("result"): - logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}") + logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error')) return False return True @@ -488,7 +488,7 @@ class MochatChannel(BaseChannel): try: await self._refresh_targets(subscribe_new=self._ws_ready) except Exception as e: - logger.warning(f"Mochat refresh failed: {e}") + logger.warning("Mochat refresh failed: {}", e) if self._fallback_mode: await self._ensure_fallback_workers() @@ -502,7 +502,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/sessions/list", {}) except Exception as e: - logger.warning(f"Mochat listSessions failed: {e}") + logger.warning("Mochat listSessions failed: {}", e) return sessions = response.get("sessions") @@ -536,7 +536,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/groups/get", {}) except Exception as e: - logger.warning(f"Mochat getWorkspaceGroup failed: {e}") + logger.warning("Mochat getWorkspaceGroup failed: {}", e) return raw_panels = response.get("panels") @@ -598,7 +598,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Mochat watch fallback error ({session_id}): {e}") + logger.warning("Mochat watch fallback error ({}): {}", session_id, e) await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) async def _panel_poll_worker(self, panel_id: str) -> None: @@ -625,7 +625,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Mochat panel polling error ({panel_id}): {e}") + logger.warning("Mochat panel polling error ({}): {}", panel_id, e) await asyncio.sleep(sleep_s) # ---- inbound event processing ------------------------------------------ @@ -836,7 +836,7 @@ class MochatChannel(BaseChannel): try: data = json.loads(self._cursor_path.read_text("utf-8")) except Exception as e: - logger.warning(f"Failed to read Mochat cursor file: {e}") + logger.warning("Failed to read Mochat cursor file: {}", e) return cursors = data.get("cursors") if isinstance(data, dict) else None if isinstance(cursors, dict): @@ -852,7 +852,7 @@ class MochatChannel(BaseChannel): "cursors": self._session_cursor, }, ensure_ascii=False, indent=2) + "\n", "utf-8") except Exception as e: - logger.warning(f"Failed to save Mochat cursor file: {e}") + logger.warning("Failed to save Mochat cursor file: {}", e) # ---- HTTP helpers ------------------------------------------------------ diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 0e8fe66..1d00bc7 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -80,7 +80,7 @@ class QQChannel(BaseChannel): try: await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.warning(f"QQ bot error: {e}") + logger.warning("QQ bot error: {}", e) if self._running: logger.info("Reconnecting QQ bot in 5 seconds...") await asyncio.sleep(5) @@ -108,7 +108,7 @@ class QQChannel(BaseChannel): content=msg.content, ) except Exception as e: - logger.error(f"Error sending QQ message: {e}") + logger.error("Error sending QQ message: {}", e) async def _on_message(self, data: "C2CMessage") -> None: """Handle incoming message from QQ.""" @@ -131,4 +131,4 @@ class QQChannel(BaseChannel): metadata={"message_id": data.id}, ) except Exception as e: - logger.error(f"Error handling QQ message: {e}") + logger.error("Error handling QQ message: {}", e) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index dca5055..7dd2971 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -55,7 +55,7 @@ class SlackChannel(BaseChannel): self._bot_user_id = auth.get("user_id") logger.info(f"Slack bot connected as {self._bot_user_id}") except Exception as e: - logger.warning(f"Slack auth_test failed: {e}") + logger.warning("Slack auth_test failed: {}", e) logger.info("Starting Slack Socket Mode client...") await self._socket_client.connect() @@ -70,7 +70,7 @@ class SlackChannel(BaseChannel): try: await self._socket_client.close() except Exception as e: - logger.warning(f"Slack socket close failed: {e}") + logger.warning("Slack socket close failed: {}", e) self._socket_client = None async def send(self, msg: OutboundMessage) -> None: @@ -90,7 +90,7 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts if use_thread else None, ) except Exception as e: - logger.error(f"Error sending Slack message: {e}") + logger.error("Error sending Slack message: {}", e) async def _on_socket_request( self, @@ -164,7 +164,7 @@ class SlackChannel(BaseChannel): timestamp=event.get("ts"), ) except Exception as e: - logger.debug(f"Slack reactions_add failed: {e}") + logger.debug("Slack reactions_add failed: {}", e) await self._handle_message( sender_id=sender_id, diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 39924b3..42db489 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -171,7 +171,7 @@ class TelegramChannel(BaseChannel): await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: - logger.warning(f"Failed to register bot commands: {e}") + logger.warning("Failed to register bot commands: {}", e) # Start polling (this runs until stopped) await self._app.updater.start_polling( @@ -238,7 +238,7 @@ class TelegramChannel(BaseChannel): await sender(chat_id=chat_id, **{param: f}) except Exception as e: filename = media_path.rsplit("/", 1)[-1] - logger.error(f"Failed to send media {media_path}: {e}") + logger.error("Failed to send media {}: {}", media_path, e) await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]") # Send text content @@ -248,11 +248,11 @@ class TelegramChannel(BaseChannel): html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") except Exception as e: - logger.warning(f"HTML parse failed, falling back to plain text: {e}") + logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message(chat_id=chat_id, text=chunk) except Exception as e2: - logger.error(f"Error sending Telegram message: {e2}") + logger.error("Error sending Telegram message: {}", e2) async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" @@ -353,12 +353,12 @@ class TelegramChannel(BaseChannel): logger.debug(f"Downloaded {media_type} to {file_path}") except Exception as e: - logger.error(f"Failed to download media: {e}") + logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") content = "\n".join(content_parts) if content_parts else "[empty message]" - logger.debug(f"Telegram message from {sender_id}: {content[:50]}...") + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) str_chat_id = str(chat_id) @@ -401,11 +401,11 @@ class TelegramChannel(BaseChannel): except asyncio.CancelledError: pass except Exception as e: - logger.debug(f"Typing indicator stopped for {chat_id}: {e}") + logger.debug("Typing indicator stopped for {}: {}", chat_id, e) async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" - logger.error(f"Telegram error: {context.error}") + logger.error("Telegram error: {}", context.error) def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 0cf2dd7..4d12360 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -53,14 +53,14 @@ class WhatsAppChannel(BaseChannel): try: await self._handle_bridge_message(message) except Exception as e: - logger.error(f"Error handling bridge message: {e}") + logger.error("Error handling bridge message: {}", e) except asyncio.CancelledError: break except Exception as e: self._connected = False self._ws = None - logger.warning(f"WhatsApp bridge connection error: {e}") + logger.warning("WhatsApp bridge connection error: {}", e) if self._running: logger.info("Reconnecting in 5 seconds...") @@ -89,14 +89,14 @@ class WhatsAppChannel(BaseChannel): } await self._ws.send(json.dumps(payload)) except Exception as e: - logger.error(f"Error sending WhatsApp message: {e}") + logger.error("Error sending WhatsApp message: {}", e) async def _handle_bridge_message(self, raw: str) -> None: """Handle a message from the bridge.""" try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning(f"Invalid JSON from bridge: {raw[:100]}") + logger.warning("Invalid JSON from bridge: {}", raw[:100]) return msg_type = data.get("type") @@ -145,4 +145,4 @@ class WhatsAppChannel(BaseChannel): logger.info("Scan QR code in the bridge terminal to connect WhatsApp") elif msg_type == "error": - logger.error(f"WhatsApp bridge error: {data.get('error')}") + logger.error("WhatsApp bridge error: {}", data.get('error')) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 14666e8..d2b9ef7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -99,7 +99,7 @@ class CronService: )) self._store = CronStore(jobs=jobs) except Exception as e: - logger.warning(f"Failed to load cron store: {e}") + logger.warning("Failed to load cron store: {}", e) self._store = CronStore() else: self._store = CronStore() @@ -236,7 +236,7 @@ class CronService: except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) - logger.error(f"Cron: job '{job.name}' failed: {e}") + logger.error("Cron: job '{}' failed: {}", job.name, e) job.state.last_run_at_ms = start_ms job.updated_at_ms = _now_ms() diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 221ed27..8bdc78f 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -97,7 +97,7 @@ class HeartbeatService: except asyncio.CancelledError: break except Exception as e: - logger.error(f"Heartbeat error: {e}") + logger.error("Heartbeat error: {}", e) async def _tick(self) -> None: """Execute a single heartbeat tick.""" @@ -121,7 +121,7 @@ class HeartbeatService: logger.info(f"Heartbeat: completed task") except Exception as e: - logger.error(f"Heartbeat execution failed: {e}") + logger.error("Heartbeat execution failed: {}", e) async def trigger_now(self) -> str | None: """Manually trigger a heartbeat.""" diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 8ce909b..eb5969d 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -61,5 +61,5 @@ class GroqTranscriptionProvider: return data.get("text", "") except Exception as e: - logger.error(f"Groq transcription error: {e}") + logger.error("Groq transcription error: {}", e) return "" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 752fce4..44dcecb 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -144,7 +144,7 @@ class SessionManager: last_consolidated=last_consolidated ) except Exception as e: - logger.warning(f"Failed to load session {key}: {e}") + logger.warning("Failed to load session {}: {}", key, e) return None def save(self, session: Session) -> None: From afca0278ad9e99be7f2778fdbdcb1bb3aac2ecb0 Mon Sep 17 00:00:00 2001 From: Rudolfs Tilgass Date: Thu, 19 Feb 2026 21:02:52 +0100 Subject: [PATCH 091/415] fix(memory): Enforce memory consolidation schema with a tool call --- nanobot/agent/loop.py | 111 +++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 49 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..e9e225c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -3,7 +3,6 @@ import asyncio from contextlib import AsyncExitStack import json -import json_repair from pathlib import Path import re from typing import Any, Awaitable, Callable @@ -84,13 +83,13 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (restrict to workspace if configured) @@ -99,30 +98,30 @@ class AgentLoop: self.tools.register(WriteFileTool(allowed_dir=allowed_dir)) self.tools.register(EditFileTool(allowed_dir=allowed_dir)) self.tools.register(ListDirTool(allowed_dir=allowed_dir)) - + # Shell tool self.tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, )) - + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: @@ -255,7 +254,7 @@ class AgentLoop: )) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -269,7 +268,7 @@ class AgentLoop: """Stop the agent loop.""" self._running = False logger.info("Agent loop stopping") - + async def _process_message( self, msg: InboundMessage, @@ -278,25 +277,25 @@ class AgentLoop: ) -> OutboundMessage | None: """ Process a single inbound message. - + Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). on_progress: Optional callback for intermediate output (defaults to bus publish). - + Returns: The response message, or None if no response needed. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": @@ -317,7 +316,7 @@ class AgentLoop: if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") - + if len(session.messages) > self.memory_window: asyncio.create_task(self._consolidate_memory(session)) @@ -342,31 +341,31 @@ class AgentLoop: if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") - + session.add_message("user", msg.content) session.add_message("assistant", final_content, tools_used=tools_used if tools_used else None) self.sessions.save(session) - + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info(f"Processing system message from {msg.sender_id}") - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -376,7 +375,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id) @@ -390,17 +389,17 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( channel=origin_channel, chat_id=origin_chat_id, content=final_content ) - + async def _consolidate_memory(self, session, archive_all: bool = False) -> None: """Consolidate old messages into MEMORY.md + HISTORY.md. @@ -439,42 +438,56 @@ class AgentLoop: conversation = "\n".join(lines) current_memory = memory.read_long_term() - prompt = f"""You are a memory consolidation agent. Process this conversation and return a JSON object with exactly two keys: - -1. "history_entry": A paragraph (2-5 sentences) summarizing the key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later. - -2. "memory_update": The updated long-term memory content. Add any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged. + prompt = f"""Process this conversation and call the save_memory tool with your consolidation. ## Current Long-term Memory {current_memory or "(empty)"} ## Conversation to Process -{conversation} +{conversation}""" -Respond with ONLY valid JSON, no markdown fences.""" + save_memory_tool = [ + { + "type": "function", + "function": { + "name": "save_memory", + "description": "Save the memory consolidation result to persistent storage.", + "parameters": { + "type": "object", + "properties": { + "history_entry": { + "type": "string", + "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.", + }, + "memory_update": { + "type": "string", + "description": "The full updated long-term memory content as a markdown string. Include all existing facts plus any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.", + }, + }, + "required": ["history_entry", "memory_update"], + }, + }, + } + ] try: response = await self.provider.chat( messages=[ - {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."}, + {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, {"role": "user", "content": prompt}, ], + tools=save_memory_tool, model=self.model, ) - text = (response.content or "").strip() - if not text: - logger.warning("Memory consolidation: LLM returned empty response, skipping") - return - if text.startswith("```"): - text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - result = json_repair.loads(text) - if not isinstance(result, dict): - logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}") + + if not response.has_tool_calls: + logger.warning("Memory consolidation: LLM did not call save_memory tool, skipping") return - if entry := result.get("history_entry"): + args = response.tool_calls[0].arguments + if entry := args.get("history_entry"): memory.append_history(entry) - if update := result.get("memory_update"): + if update := args.get("memory_update"): if update != current_memory: memory.write_long_term(update) @@ -496,14 +509,14 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). on_progress: Optional callback for intermediate output. - + Returns: The agent's response. """ @@ -514,6 +527,6 @@ Respond with ONLY valid JSON, no markdown fences.""" chat_id=chat_id, content=content ) - + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" From f3c7337356de507b6a1e0b10725bc2521f48ec95 Mon Sep 17 00:00:00 2001 From: dxtime Date: Fri, 20 Feb 2026 08:31:52 +0800 Subject: [PATCH 092/415] feat: Added custom headers for MCP Auth use, update README.md --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7fad9ce..e8aac1e 100644 --- a/README.md +++ b/README.md @@ -752,7 +752,14 @@ Add MCP servers to your `config.json`: "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"] } - } + }, + "urlMcpServers": { + "url": "https://xx.xx.xx.xx:xxxx/mcp/", + "headers": { + "Authorization": "Bearer xxxxx", + "X-API-Key": "xxxxxxx" + } + }, } } ``` @@ -762,7 +769,7 @@ Two transport modes are supported: | Mode | Config | Example | |------|--------|---------| | **Stdio** | `command` + `args` | Local process via `npx` / `uvx` | -| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) | +| **HTTP** | `url` + `option(headers)`| Remote endpoint (`https://mcp.example.com/sse`) | MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed. From 0001f286b578b6ecf9634b47c6605978038e5a61 Mon Sep 17 00:00:00 2001 From: AlexanderMerkel Date: Thu, 19 Feb 2026 19:00:25 -0700 Subject: [PATCH 093/415] fix: remove dead pub/sub code from MessageBus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subscribe_outbound()`, `dispatch_outbound()`, and `stop()` have zero callers — `ChannelManager._dispatch_outbound()` handles all outbound routing via `consume_outbound()` directly. Remove the dead methods and their unused imports (`Callable`, `Awaitable`, `logger`). Co-Authored-By: Claude Opus 4.6 --- nanobot/bus/queue.py | 53 +++++++------------------------------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py index 4123d06..7c0616f 100644 --- a/nanobot/bus/queue.py +++ b/nanobot/bus/queue.py @@ -1,9 +1,6 @@ """Async message queue for decoupled channel-agent communication.""" import asyncio -from typing import Callable, Awaitable - -from loguru import logger from nanobot.bus.events import InboundMessage, OutboundMessage @@ -11,70 +8,36 @@ from nanobot.bus.events import InboundMessage, OutboundMessage class MessageBus: """ Async message bus that decouples chat channels from the agent core. - + Channels push messages to the inbound queue, and the agent processes them and pushes responses to the outbound queue. """ - + def __init__(self): self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() - self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {} - self._running = False - + async def publish_inbound(self, msg: InboundMessage) -> None: """Publish a message from a channel to the agent.""" await self.inbound.put(msg) - + async def consume_inbound(self) -> InboundMessage: """Consume the next inbound message (blocks until available).""" return await self.inbound.get() - + async def publish_outbound(self, msg: OutboundMessage) -> None: """Publish a response from the agent to channels.""" await self.outbound.put(msg) - + async def consume_outbound(self) -> OutboundMessage: """Consume the next outbound message (blocks until available).""" return await self.outbound.get() - - def subscribe_outbound( - self, - channel: str, - callback: Callable[[OutboundMessage], Awaitable[None]] - ) -> None: - """Subscribe to outbound messages for a specific channel.""" - if channel not in self._outbound_subscribers: - self._outbound_subscribers[channel] = [] - self._outbound_subscribers[channel].append(callback) - - async def dispatch_outbound(self) -> None: - """ - Dispatch outbound messages to subscribed channels. - Run this as a background task. - """ - self._running = True - while self._running: - try: - msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0) - subscribers = self._outbound_subscribers.get(msg.channel, []) - for callback in subscribers: - try: - await callback(msg) - except Exception as e: - logger.error(f"Error dispatching to {msg.channel}: {e}") - except asyncio.TimeoutError: - continue - - def stop(self) -> None: - """Stop the dispatcher loop.""" - self._running = False - + @property def inbound_size(self) -> int: """Number of pending inbound messages.""" return self.inbound.qsize() - + @property def outbound_size(self) -> int: """Number of pending outbound messages.""" From 0d3dc57a65e54bd8223110da4e7d5ac06b5aa924 Mon Sep 17 00:00:00 2001 From: Tanish Rajput Date: Mon, 9 Feb 2026 22:57:38 +0530 Subject: [PATCH 094/415] feat: add matrix (Element) chat channel support --- nanobot/channels/matrix.py | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 nanobot/channels/matrix.py diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py new file mode 100644 index 0000000..0ea4ae1 --- /dev/null +++ b/nanobot/channels/matrix.py @@ -0,0 +1,79 @@ +import asyncio +from typing import Any + +from nio import AsyncClient, MatrixRoom, RoomMessageText + +from nanobot.channels.base import BaseChannel +from nanobot.bus.events import OutboundMessage + + +class MatrixChannel(BaseChannel): + """ + Matrix (Element) channel using long-polling sync. + """ + + name = "matrix" + + def __init__(self, config: Any, bus): + super().__init__(config, bus) + self.client: AsyncClient | None = None + self._sync_task: asyncio.Task | None = None + + async def start(self) -> None: + self._running = True + + self.client = AsyncClient( + homeserver=self.config.homeserver, + user=self.config.user_id, + ) + + self.client.access_token = self.config.access_token + + self.client.add_event_callback( + self._on_message, + RoomMessageText + ) + + self._sync_task = asyncio.create_task(self._sync_loop()) + + async def stop(self) -> None: + self._running = False + if self._sync_task: + self._sync_task.cancel() + if self.client: + await self.client.close() + + async def send(self, msg: OutboundMessage) -> None: + if not self.client: + return + + await self.client.room_send( + room_id=msg.chat_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": msg.content}, + ) + + async def _sync_loop(self) -> None: + while self._running: + try: + await self.client.sync(timeout=30000) + except asyncio.CancelledError: + break + except Exception: + await asyncio.sleep(2) + + async def _on_message( + self, + room: MatrixRoom, + event: RoomMessageText + ) -> None: + # Ignore self messages + if event.sender == self.config.user_id: + return + + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content=event.body, + metadata={"room": room.display_name}, + ) \ No newline at end of file From 37252a4226214367abea1cef0fae4591b2dc00c4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 07:55:34 +0000 Subject: [PATCH 095/415] fix: complete loguru native formatting migration across all files --- nanobot/agent/loop.py | 12 ++++++------ nanobot/agent/subagent.py | 8 ++++---- nanobot/agent/tools/mcp.py | 6 +++--- nanobot/channels/dingtalk.py | 2 +- nanobot/channels/discord.py | 2 +- nanobot/channels/email.py | 2 +- nanobot/channels/feishu.py | 10 +++++----- nanobot/channels/manager.py | 24 ++++++++++++------------ nanobot/channels/qq.py | 2 +- nanobot/channels/slack.py | 4 ++-- nanobot/channels/telegram.py | 8 ++++---- nanobot/channels/whatsapp.py | 8 ++++---- nanobot/cron/service.py | 10 +++++----- nanobot/heartbeat/service.py | 4 ++-- nanobot/providers/transcription.py | 2 +- nanobot/session/manager.py | 2 +- 16 files changed, 53 insertions(+), 53 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index cbab5aa..1620cb0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -365,7 +365,7 @@ class AgentLoop: The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ - logger.info(f"Processing system message from {msg.sender_id}") + logger.info("Processing system message from {}", msg.sender_id) # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: @@ -413,22 +413,22 @@ class AgentLoop: if archive_all: old_messages = session.messages keep_count = 0 - logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived") + logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) else: keep_count = self.memory_window // 2 if len(session.messages) <= keep_count: - logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})") + logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) return messages_to_process = len(session.messages) - session.last_consolidated if messages_to_process <= 0: - logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})") + logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) return old_messages = session.messages[session.last_consolidated:-keep_count] if not old_messages: return - logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep") + logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) lines = [] for m in old_messages: @@ -482,7 +482,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = 0 else: session.last_consolidated = len(session.messages) - keep_count - logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}") + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) except Exception as e: logger.error("Memory consolidation failed: {}", e) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index ae0e492..7d48cc4 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -86,7 +86,7 @@ class SubagentManager: # Cleanup when done bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None)) - logger.info(f"Spawned subagent [{task_id}]: {display_label}") + logger.info("Spawned subagent [{}]: {}", task_id, display_label) return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." async def _run_subagent( @@ -97,7 +97,7 @@ class SubagentManager: origin: dict[str, str], ) -> None: """Execute the subagent task and announce the result.""" - logger.info(f"Subagent [{task_id}] starting task: {label}") + logger.info("Subagent [{}] starting task: {}", task_id, label) try: # Build subagent tools (no message tool, no spawn tool) @@ -175,7 +175,7 @@ class SubagentManager: if final_result is None: final_result = "Task completed but no final response was generated." - logger.info(f"Subagent [{task_id}] completed successfully") + logger.info("Subagent [{}] completed successfully", task_id) await self._announce_result(task_id, label, task, final_result, origin, "ok") except Exception as e: @@ -213,7 +213,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men ) await self.bus.publish_inbound(msg) - logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}") + logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 4e61923..7d9033d 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -63,7 +63,7 @@ async def connect_mcp_servers( streamable_http_client(cfg.url) ) else: - logger.warning(f"MCP server '{name}': no command or url configured, skipping") + logger.warning("MCP server '{}': no command or url configured, skipping", name) continue session = await stack.enter_async_context(ClientSession(read, write)) @@ -73,8 +73,8 @@ async def connect_mcp_servers( for tool_def in tools.tools: wrapper = MCPToolWrapper(session, name, tool_def) registry.register(wrapper) - logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'") + logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) - logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") + logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools)) except Exception as e: logger.error("MCP server '{}': failed to connect: {}", name, e) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 3ac233f..f6dca30 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -220,7 +220,7 @@ class DingTalkChannel(BaseChannel): if resp.status_code != 200: logger.error("DingTalk send failed: {}", resp.text) else: - logger.debug(f"DingTalk message sent to {msg.chat_id}") + logger.debug("DingTalk message sent to {}", msg.chat_id) except Exception as e: logger.error("Error sending DingTalk message: {}", e) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index ee54eed..8baecbf 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -94,7 +94,7 @@ class DiscordChannel(BaseChannel): if response.status_code == 429: data = response.json() retry_after = float(data.get("retry_after", 1.0)) - logger.warning(f"Discord rate limited, retrying in {retry_after}s") + logger.warning("Discord rate limited, retrying in {}s", retry_after) await asyncio.sleep(retry_after) continue response.raise_for_status() diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 8a1ee79..1b6f46b 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -162,7 +162,7 @@ class EmailChannel(BaseChannel): missing.append("smtp_password") if missing: - logger.error(f"Email channel not configured, missing: {', '.join(missing)}") + logger.error("Email channel not configured, missing: {}", ', '.join(missing)) return False return True diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6f62202..c17bf1a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -196,7 +196,7 @@ class FeishuChannel(BaseChannel): if not response.success(): logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: - logger.debug(f"Added {emoji_type} reaction to message {message_id}") + logger.debug("Added {} reaction to message {}", emoji_type, message_id) except Exception as e: logger.warning("Error adding reaction: {}", e) @@ -309,7 +309,7 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.image.create(request) if response.success(): image_key = response.data.image_key - logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") + logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) return image_key else: logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) @@ -336,7 +336,7 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.file.create(request) if response.success(): file_key = response.data.file_key - logger.debug(f"Uploaded file {file_name}: {file_key}") + logger.debug("Uploaded file {}: {}", file_name, file_key) return file_key else: logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) @@ -364,7 +364,7 @@ class FeishuChannel(BaseChannel): msg_type, response.code, response.msg, response.get_log_id() ) return False - logger.debug(f"Feishu {msg_type} message sent to {receive_id}") + logger.debug("Feishu {} message sent to {}", msg_type, receive_id) return True except Exception as e: logger.error("Error sending Feishu {} message: {}", msg_type, e) @@ -382,7 +382,7 @@ class FeishuChannel(BaseChannel): for file_path in msg.media: if not os.path.isfile(file_path): - logger.warning(f"Media file not found: {file_path}") + logger.warning("Media file not found: {}", file_path) continue ext = os.path.splitext(file_path)[1].lower() if ext in self._IMAGE_EXTS: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 3e714c3..6fbab04 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -45,7 +45,7 @@ class ChannelManager: ) logger.info("Telegram channel enabled") except ImportError as e: - logger.warning(f"Telegram channel not available: {e}") + logger.warning("Telegram channel not available: {}", e) # WhatsApp channel if self.config.channels.whatsapp.enabled: @@ -56,7 +56,7 @@ class ChannelManager: ) logger.info("WhatsApp channel enabled") except ImportError as e: - logger.warning(f"WhatsApp channel not available: {e}") + logger.warning("WhatsApp channel not available: {}", e) # Discord channel if self.config.channels.discord.enabled: @@ -67,7 +67,7 @@ class ChannelManager: ) logger.info("Discord channel enabled") except ImportError as e: - logger.warning(f"Discord channel not available: {e}") + logger.warning("Discord channel not available: {}", e) # Feishu channel if self.config.channels.feishu.enabled: @@ -78,7 +78,7 @@ class ChannelManager: ) logger.info("Feishu channel enabled") except ImportError as e: - logger.warning(f"Feishu channel not available: {e}") + logger.warning("Feishu channel not available: {}", e) # Mochat channel if self.config.channels.mochat.enabled: @@ -90,7 +90,7 @@ class ChannelManager: ) logger.info("Mochat channel enabled") except ImportError as e: - logger.warning(f"Mochat channel not available: {e}") + logger.warning("Mochat channel not available: {}", e) # DingTalk channel if self.config.channels.dingtalk.enabled: @@ -101,7 +101,7 @@ class ChannelManager: ) logger.info("DingTalk channel enabled") except ImportError as e: - logger.warning(f"DingTalk channel not available: {e}") + logger.warning("DingTalk channel not available: {}", e) # Email channel if self.config.channels.email.enabled: @@ -112,7 +112,7 @@ class ChannelManager: ) logger.info("Email channel enabled") except ImportError as e: - logger.warning(f"Email channel not available: {e}") + logger.warning("Email channel not available: {}", e) # Slack channel if self.config.channels.slack.enabled: @@ -123,7 +123,7 @@ class ChannelManager: ) logger.info("Slack channel enabled") except ImportError as e: - logger.warning(f"Slack channel not available: {e}") + logger.warning("Slack channel not available: {}", e) # QQ channel if self.config.channels.qq.enabled: @@ -135,7 +135,7 @@ class ChannelManager: ) logger.info("QQ channel enabled") except ImportError as e: - logger.warning(f"QQ channel not available: {e}") + logger.warning("QQ channel not available: {}", e) async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" @@ -156,7 +156,7 @@ class ChannelManager: # Start channels tasks = [] for name, channel in self.channels.items(): - logger.info(f"Starting {name} channel...") + logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) # Wait for all to complete (they should run forever) @@ -178,7 +178,7 @@ class ChannelManager: for name, channel in self.channels.items(): try: await channel.stop() - logger.info(f"Stopped {name} channel") + logger.info("Stopped {} channel", name) except Exception as e: logger.error("Error stopping {}: {}", name, e) @@ -200,7 +200,7 @@ class ChannelManager: except Exception as e: logger.error("Error sending to {}: {}", msg.channel, e) else: - logger.warning(f"Unknown channel: {msg.channel}") + logger.warning("Unknown channel: {}", msg.channel) except asyncio.TimeoutError: continue diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 1d00bc7..16cbfb8 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -34,7 +34,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": super().__init__(intents=intents) async def on_ready(self): - logger.info(f"QQ bot ready: {self.robot.name}") + logger.info("QQ bot ready: {}", self.robot.name) async def on_c2c_message_create(self, message: "C2CMessage"): await channel._on_message(message) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 7dd2971..79cbe76 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -36,7 +36,7 @@ class SlackChannel(BaseChannel): logger.error("Slack bot/app token not configured") return if self.config.mode != "socket": - logger.error(f"Unsupported Slack mode: {self.config.mode}") + logger.error("Unsupported Slack mode: {}", self.config.mode) return self._running = True @@ -53,7 +53,7 @@ class SlackChannel(BaseChannel): try: auth = await self._web_client.auth_test() self._bot_user_id = auth.get("user_id") - logger.info(f"Slack bot connected as {self._bot_user_id}") + logger.info("Slack bot connected as {}", self._bot_user_id) except Exception as e: logger.warning("Slack auth_test failed: {}", e) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 42db489..fa36c98 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -165,7 +165,7 @@ class TelegramChannel(BaseChannel): # Get bot info and register command menu bot_info = await self._app.bot.get_me() - logger.info(f"Telegram bot @{bot_info.username} connected") + logger.info("Telegram bot @{} connected", bot_info.username) try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) @@ -221,7 +221,7 @@ class TelegramChannel(BaseChannel): try: chat_id = int(msg.chat_id) except ValueError: - logger.error(f"Invalid chat_id: {msg.chat_id}") + logger.error("Invalid chat_id: {}", msg.chat_id) return # Send media files @@ -344,14 +344,14 @@ class TelegramChannel(BaseChannel): transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) transcription = await transcriber.transcribe(file_path) if transcription: - logger.info(f"Transcribed {media_type}: {transcription[:50]}...") + logger.info("Transcribed {}: {}...", media_type, transcription[:50]) content_parts.append(f"[transcription: {transcription}]") else: content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {file_path}]") - logger.debug(f"Downloaded {media_type} to {file_path}") + logger.debug("Downloaded {} to {}", media_type, file_path) except Exception as e: logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 4d12360..f3e14d9 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -34,7 +34,7 @@ class WhatsAppChannel(BaseChannel): bridge_url = self.config.bridge_url - logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...") + logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) self._running = True @@ -112,11 +112,11 @@ class WhatsAppChannel(BaseChannel): # Extract just the phone number or lid as chat_id user_id = pn if pn else sender sender_id = user_id.split("@")[0] if "@" in user_id else user_id - logger.info(f"Sender {sender}") + logger.info("Sender {}", sender) # Handle voice transcription if it's a voice message if content == "[Voice Message]": - logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.") + logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id) content = "[Voice Message: Transcription not available for WhatsApp yet]" await self._handle_message( @@ -133,7 +133,7 @@ class WhatsAppChannel(BaseChannel): elif msg_type == "status": # Connection status update status = data.get("status") - logger.info(f"WhatsApp status: {status}") + logger.info("WhatsApp status: {}", status) if status == "connected": self._connected = True diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index d2b9ef7..4c14ef7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -157,7 +157,7 @@ class CronService: self._recompute_next_runs() self._save_store() self._arm_timer() - logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs") + logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else [])) def stop(self) -> None: """Stop the cron service.""" @@ -222,7 +222,7 @@ class CronService: async def _execute_job(self, job: CronJob) -> None: """Execute a single job.""" start_ms = _now_ms() - logger.info(f"Cron: executing job '{job.name}' ({job.id})") + logger.info("Cron: executing job '{}' ({})", job.name, job.id) try: response = None @@ -231,7 +231,7 @@ class CronService: job.state.last_status = "ok" job.state.last_error = None - logger.info(f"Cron: job '{job.name}' completed") + logger.info("Cron: job '{}' completed", job.name) except Exception as e: job.state.last_status = "error" @@ -296,7 +296,7 @@ class CronService: self._save_store() self._arm_timer() - logger.info(f"Cron: added job '{name}' ({job.id})") + logger.info("Cron: added job '{}' ({})", name, job.id) return job def remove_job(self, job_id: str) -> bool: @@ -309,7 +309,7 @@ class CronService: if removed: self._save_store() self._arm_timer() - logger.info(f"Cron: removed job {job_id}") + logger.info("Cron: removed job {}", job_id) return removed diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 8bdc78f..8b33e3a 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -78,7 +78,7 @@ class HeartbeatService: self._running = True self._task = asyncio.create_task(self._run_loop()) - logger.info(f"Heartbeat started (every {self.interval_s}s)") + logger.info("Heartbeat started (every {}s)", self.interval_s) def stop(self) -> None: """Stop the heartbeat service.""" @@ -118,7 +118,7 @@ class HeartbeatService: if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): logger.info("Heartbeat: OK (no action needed)") else: - logger.info(f"Heartbeat: completed task") + logger.info("Heartbeat: completed task") except Exception as e: logger.error("Heartbeat execution failed: {}", e) diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index eb5969d..7a3c628 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -35,7 +35,7 @@ class GroqTranscriptionProvider: path = Path(file_path) if not path.exists(): - logger.error(f"Audio file not found: {file_path}") + logger.error("Audio file not found: {}", file_path) return "" try: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 44dcecb..9c0c7de 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -110,7 +110,7 @@ class SessionManager: if legacy_path.exists(): import shutil shutil.move(str(legacy_path), str(path)) - logger.info(f"Migrated session {key} from legacy path") + logger.info("Migrated session {} from legacy path", key) if not path.exists(): return None From e17342ddfc812a629d54e4be28f7cb39a84be424 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:03:24 +0000 Subject: [PATCH 096/415] fix: pass workspace to file tools in subagent --- nanobot/agent/subagent.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 767bc68..d87c61a 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -103,10 +103,10 @@ class SubagentManager: # Build subagent tools (no message tool, no spawn tool) tools = ToolRegistry() allowed_dir = self.workspace if self.restrict_to_workspace else None - tools.register(ReadFileTool(allowed_dir=allowed_dir)) - tools.register(WriteFileTool(allowed_dir=allowed_dir)) - tools.register(EditFileTool(allowed_dir=allowed_dir)) - tools.register(ListDirTool(allowed_dir=allowed_dir)) + tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, From 002de466d769ffa81a0fa89ff8056f0eb9cdc5fd Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:12:23 +0000 Subject: [PATCH 097/415] chore: remove test file for memory consolidation fix --- README.md | 2 +- tests/test_memory_consolidation_types.py | 133 ----------------------- 2 files changed, 1 insertion(+), 134 deletions(-) delete mode 100644 tests/test_memory_consolidation_types.py diff --git a/README.md b/README.md index a474367..289ff28 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,761 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,781 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py deleted file mode 100644 index 3b76596..0000000 --- a/tests/test_memory_consolidation_types.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Test memory consolidation handles non-string values from LLM. - -This test verifies the fix for the bug where memory consolidation fails -when LLM returns JSON objects instead of strings for history_entry or -memory_update fields. - -Related issue: Memory consolidation fails with TypeError when LLM returns dict -""" - -import json -import tempfile -from pathlib import Path - -import pytest - -from nanobot.agent.memory import MemoryStore - - -class TestMemoryConsolidationTypeHandling: - """Test that MemoryStore methods handle type conversion correctly.""" - - def test_append_history_accepts_string(self): - """MemoryStore.append_history should accept string values.""" - with tempfile.TemporaryDirectory() as tmpdir: - memory = MemoryStore(Path(tmpdir)) - - # Should not raise TypeError - memory.append_history("[2026-02-14] Test entry") - - # Verify content was written - history_content = memory.history_file.read_text() - assert "Test entry" in history_content - - def test_write_long_term_accepts_string(self): - """MemoryStore.write_long_term should accept string values.""" - with tempfile.TemporaryDirectory() as tmpdir: - memory = MemoryStore(Path(tmpdir)) - - # Should not raise TypeError - memory.write_long_term("- Fact 1\n- Fact 2") - - # Verify content was written - memory_content = memory.read_long_term() - assert "Fact 1" in memory_content - - def test_type_conversion_dict_to_str(self): - """Dict values should be converted to JSON strings.""" - input_val = {"timestamp": "2026-02-14", "summary": "test"} - expected = '{"timestamp": "2026-02-14", "summary": "test"}' - - # Simulate the fix logic - if not isinstance(input_val, str): - result = json.dumps(input_val, ensure_ascii=False) - else: - result = input_val - - assert result == expected - assert isinstance(result, str) - - def test_type_conversion_list_to_str(self): - """List values should be converted to JSON strings.""" - input_val = ["item1", "item2"] - expected = '["item1", "item2"]' - - # Simulate the fix logic - if not isinstance(input_val, str): - result = json.dumps(input_val, ensure_ascii=False) - else: - result = input_val - - assert result == expected - assert isinstance(result, str) - - def test_type_conversion_str_unchanged(self): - """String values should remain unchanged.""" - input_val = "already a string" - - # Simulate the fix logic - if not isinstance(input_val, str): - result = json.dumps(input_val, ensure_ascii=False) - else: - result = input_val - - assert result == input_val - assert isinstance(result, str) - - def test_memory_consolidation_simulation(self): - """Simulate full consolidation with dict values from LLM.""" - with tempfile.TemporaryDirectory() as tmpdir: - memory = MemoryStore(Path(tmpdir)) - - # Simulate LLM returning dict values (the bug scenario) - history_entry = {"timestamp": "2026-02-14", "summary": "User asked about..."} - memory_update = {"facts": ["Location: Beijing", "Skill: Python"]} - - # Apply the fix: convert to str - if not isinstance(history_entry, str): - history_entry = json.dumps(history_entry, ensure_ascii=False) - if not isinstance(memory_update, str): - memory_update = json.dumps(memory_update, ensure_ascii=False) - - # Should not raise TypeError after conversion - memory.append_history(history_entry) - memory.write_long_term(memory_update) - - # Verify content - assert memory.history_file.exists() - assert memory.memory_file.exists() - - history_content = memory.history_file.read_text() - memory_content = memory.read_long_term() - - assert "timestamp" in history_content - assert "facts" in memory_content - - -class TestPromptOptimization: - """Test that prompt optimization helps prevent the issue.""" - - def test_prompt_includes_string_requirement(self): - """The prompt should explicitly require string values.""" - # This is a documentation test - verify the fix is in place - # by checking the expected prompt content - expected_keywords = [ - "MUST be strings", - "not objects or arrays", - "Example:", - ] - - # The actual prompt content is in nanobot/agent/loop.py - # This test serves as documentation of the expected behavior - for keyword in expected_keywords: - assert keyword, f"Prompt should include: {keyword}" From 9ffae47c13862c0942b16016e67725af199a5ace Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:21:02 +0000 Subject: [PATCH 098/415] refactor(litellm): remove redundant comments in cache_control methods --- nanobot/providers/litellm_provider.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 08c2f53..66751ed 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -117,7 +117,6 @@ class LiteLLMProvider(LLMProvider): tools: list[dict[str, Any]] | None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]: """Return copies of messages and tools with cache_control injected.""" - # Transform the system message new_messages = [] for msg in messages: if msg.get("role") == "system": @@ -131,7 +130,6 @@ class LiteLLMProvider(LLMProvider): else: new_messages.append(msg) - # Add cache_control to the last tool definition new_tools = tools if tools: new_tools = list(tools) From 2383dcb3a82868162a970b91528945a84467af93 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:31:48 +0000 Subject: [PATCH 099/415] style: use loguru native format and trim comments in interim retry --- README.md | 2 +- nanobot/agent/loop.py | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 289ff28..21c5491 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,781 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,793 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3829626..a90dccb 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -227,16 +227,11 @@ class AgentLoop: ) else: final_content = self._strip_think(response.content) - # Some models (MiniMax, Gemini Flash, GPT-4.1, etc.) send an - # interim text response (e.g. "Let me investigate...") before - # making tool calls. If no tools have been used yet and we - # haven't already retried, add the text to the conversation - # and give the model one more chance to use tools. - # We do NOT forward the interim text as progress to avoid - # duplicate messages when the model simply answers directly. + # Some models send an interim text response before tool calls. + # Give them one retry; don't forward the text to avoid duplicates. if not tools_used and not text_only_retried and final_content: text_only_retried = True - logger.debug(f"Interim text response (no tools used yet), retrying: {final_content[:80]}") + logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) messages = self.context.add_assistant_message( messages, response.content, reasoning_content=response.reasoning_content, From 2f315ec567ab2432ab32332e3fa671a1bdc44dc6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:39:26 +0000 Subject: [PATCH 100/415] style: trim _on_help docstring --- nanobot/channels/telegram.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index af161d4..768e565 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -267,11 +267,7 @@ class TelegramChannel(BaseChannel): ) async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /help command directly, bypassing access control. - - /help is informational and should be accessible to all users, - even those not in the allowFrom list. - """ + """Handle /help command, bypassing ACL so all users can access it.""" if not update.message: return await update.message.reply_text( From 25efd1bc543d2de31bb9f347b8fa168512b7dfde Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:45:42 +0000 Subject: [PATCH 101/415] docs: update docs for providers --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dc85b54..c0bd4f4 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,7 @@ Config file: `~/.nanobot/config.json` | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | -| `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) | +| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | | `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | From f5fe74f5789be04028a03ed8c95c9f35feb2fd81 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 08:49:49 +0000 Subject: [PATCH 102/415] style: move httpx import to top-level and fix README example for MCP headers --- README.md | 17 ++++++++--------- nanobot/agent/tools/mcp.py | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ece47e0..b37f8bd 100644 --- a/README.md +++ b/README.md @@ -753,15 +753,14 @@ Add MCP servers to your `config.json`: "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"] - } - }, - "urlMcpServers": { - "url": "https://xx.xx.xx.xx:xxxx/mcp/", - "headers": { - "Authorization": "Bearer xxxxx", - "X-API-Key": "xxxxxxx" - } }, + "my-remote-mcp": { + "url": "https://example.com/mcp/", + "headers": { + "Authorization": "Bearer xxxxx" + } + } + } } } ``` @@ -771,7 +770,7 @@ Two transport modes are supported: | Mode | Config | Example | |------|--------|---------| | **Stdio** | `command` + `args` | Local process via `npx` / `uvx` | -| **HTTP** | `url` + `option(headers)`| Remote endpoint (`https://mcp.example.com/sse`) | +| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) | MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed. diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index a02f42b..ad352bf 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -3,6 +3,7 @@ from contextlib import AsyncExitStack from typing import Any +import httpx from loguru import logger from nanobot.agent.tools.base import Tool @@ -59,7 +60,6 @@ async def connect_mcp_servers( read, write = await stack.enter_async_context(stdio_client(params)) elif cfg.url: from mcp.client.streamable_http import streamable_http_client - import httpx if cfg.headers: http_client = await stack.enter_async_context( httpx.AsyncClient( From b97b1a5e91bd8778e5625b2debc48c49998f0ada Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 09:04:33 +0000 Subject: [PATCH 103/415] fix: pass full agent config including mcp_servers to cron run command --- nanobot/cli/commands.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 9389d0b..a135349 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -864,11 +864,12 @@ def cron_run( force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), ): """Manually run a job.""" + from loguru import logger from nanobot.config.loader import load_config, get_data_dir from nanobot.cron.service import CronService + from nanobot.cron.types import CronJob from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop - from loguru import logger logger.disable("nanobot") config = load_config() @@ -879,10 +880,14 @@ def cron_run( provider=provider, workspace=config.workspace_path, model=config.agents.defaults.model, + temperature=config.agents.defaults.temperature, + max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, + brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, + mcp_servers=config.tools.mcp_servers, ) store_path = get_data_dir() / "cron" / "jobs.json" @@ -890,7 +895,7 @@ def cron_run( result_holder = [] - async def on_job(job): + async def on_job(job: CronJob) -> str | None: response = await agent_loop.process_direct( job.payload.message, session_key=f"cron:{job.id}", From e39bbaa9be85e57020be9735051e5f8044f53ed1 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 20 Feb 2026 09:54:21 +0000 Subject: [PATCH 104/415] feat(slack): add media file upload support Use files_upload_v2 API to upload media attachments in Slack messages. This enables the message tool's media parameter to work correctly when sending images or other files through the Slack channel. Requires files:write OAuth scope. --- nanobot/channels/slack.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index dca5055..d29f1e1 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -84,11 +84,26 @@ class SlackChannel(BaseChannel): channel_type = slack_meta.get("channel_type") # Only reply in thread for channel/group messages; DMs don't use threads use_thread = thread_ts and channel_type != "im" - await self._web_client.chat_postMessage( - channel=msg.chat_id, - text=self._to_mrkdwn(msg.content), - thread_ts=thread_ts if use_thread else None, - ) + thread_ts_param = thread_ts if use_thread else None + + # Send text message if content is present + if msg.content: + await self._web_client.chat_postMessage( + channel=msg.chat_id, + text=self._to_mrkdwn(msg.content), + thread_ts=thread_ts_param, + ) + + # Upload media files if present + for media_path in msg.media or []: + try: + await self._web_client.files_upload_v2( + channel=msg.chat_id, + file=media_path, + thread_ts=thread_ts_param, + ) + except Exception as e: + logger.error(f"Failed to upload file {media_path}: {e}") except Exception as e: logger.error(f"Error sending Slack message: {e}") From e1854c4373cd7b944c18452bb0c852ee214c032d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 11:13:10 +0000 Subject: [PATCH 105/415] feat: make Telegram reply-to-message behavior configurable, default false --- nanobot/channels/telegram.py | 14 +++++++------- nanobot/config/schema.py | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index d29fdfd..6cd98e7 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -224,14 +224,14 @@ class TelegramChannel(BaseChannel): logger.error("Invalid chat_id: {}", msg.chat_id) return - # Build reply parameters (Will reply to the message if it exists) - reply_to_message_id = msg.metadata.get("message_id") reply_params = None - if reply_to_message_id: - reply_params = ReplyParameters( - message_id=reply_to_message_id, - allow_sending_without_reply=True - ) + if self.config.reply_to_message: + reply_to_message_id = msg.metadata.get("message_id") + if reply_to_message_id: + reply_params = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=True + ) # Send media files for media_path in (msg.media or []): diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 570f322..966d11d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -28,6 +28,7 @@ class TelegramConfig(Base): token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + reply_to_message: bool = False # If true, bot replies quote the original message class FeishuConfig(Base): From 8db91f59e26cd0ca83fc5eb01dd346a2410d98f9 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 11:18:57 +0000 Subject: [PATCH 106/415] style: remove trailing space --- README.md | 2 +- nanobot/agent/loop.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b37f8bd..68ad5a9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,793 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,827 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 14a8be6..3016d92 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -200,7 +200,7 @@ class AgentLoop: if response.has_tool_calls: if on_progress: clean = self._strip_think(response.content) - if clean: + if clean: await on_progress(clean) await on_progress(self._tool_hint(response.tool_calls)) From 5cc019bf1a53df1fd2ff1e50cd40b4e358804a4f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 11:27:21 +0000 Subject: [PATCH 107/415] style: trim verbose comments in _sanitize_messages --- nanobot/providers/litellm_provider.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 4fe44f7..edeb5c6 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -12,9 +12,7 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway -# Keys that are part of the OpenAI chat-completion message schema. -# Anything else (e.g. reasoning_content, timestamp) is stripped before sending -# to avoid "Unrecognized chat message" errors from strict providers like StepFun. +# Standard OpenAI chat-completion message keys; extras (e.g. reasoning_content) are stripped for strict providers. _ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) @@ -155,13 +153,7 @@ class LiteLLMProvider(LLMProvider): @staticmethod def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Strip non-standard keys from messages for strict providers. - - Some providers (e.g. StepFun via OpenRouter) reject messages that - contain extra keys like ``reasoning_content``. This method keeps - only the keys defined in the OpenAI chat-completion schema and - ensures every assistant message has a ``content`` key. - """ + """Strip non-standard keys and ensure assistant messages have a content key.""" sanitized = [] for msg in messages: clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS} From 755e42412717c0c4372b79c4d53f0dcb050351f7 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 12:38:43 +0100 Subject: [PATCH 108/415] fix(loop): serialize /new consolidation and track task refs --- nanobot/agent/loop.py | 251 ++++++++++++++++++++----------- tests/test_consolidate_offset.py | 246 ++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+), 90 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3016d92..7806fb8 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -21,7 +21,6 @@ from nanobot.agent.tools.web import WebSearchTool, WebFetchTool from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.cron import CronTool -from nanobot.agent.memory import MemoryStore from nanobot.agent.subagent import SubagentManager from nanobot.session.manager import Session, SessionManager @@ -57,6 +56,7 @@ class AgentLoop: ): from nanobot.config.schema import ExecToolConfig from nanobot.cron.service import CronService + self.bus = bus self.provider = provider self.workspace = workspace @@ -84,14 +84,16 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False self._consolidating: set[str] = set() # Session keys with consolidation in progress + self._consolidation_tasks: set[asyncio.Task] = set() # Keep strong refs for in-flight tasks + self._consolidation_locks: dict[str, asyncio.Lock] = {} self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (workspace for relative paths, restrict if configured) @@ -100,36 +102,39 @@ class AgentLoop: self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - + # Shell tool - self.tools.register(ExecTool( - working_dir=str(self.workspace), - timeout=self.exec_config.timeout, - restrict_to_workspace=self.restrict_to_workspace, - )) - + self.tools.register( + ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + ) + ) + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: return self._mcp_connected = True from nanobot.agent.tools.mcp import connect_mcp_servers + self._mcp_stack = AsyncExitStack() await self._mcp_stack.__aenter__() await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) @@ -158,11 +163,13 @@ class AgentLoop: @staticmethod def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hint, e.g. 'web_search("query")'.""" + def _fmt(tc): val = next(iter(tc.arguments.values()), None) if tc.arguments else None if not isinstance(val, str): return tc.name return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' + return ", ".join(_fmt(tc) for tc in tool_calls) async def _run_agent_loop( @@ -210,13 +217,15 @@ class AgentLoop: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments, ensure_ascii=False) - } + "arguments": json.dumps(tc.arguments, ensure_ascii=False), + }, } for tc in response.tool_calls ] messages = self.context.add_assistant_message( - messages, response.content, tool_call_dicts, + messages, + response.content, + tool_call_dicts, reasoning_content=response.reasoning_content, ) @@ -234,9 +243,13 @@ class AgentLoop: # Give them one retry; don't forward the text to avoid duplicates. if not tools_used and not text_only_retried and final_content: text_only_retried = True - logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) + logger.debug( + "Interim text response (no tools used yet), retrying: {}", + final_content[:80], + ) messages = self.context.add_assistant_message( - messages, response.content, + messages, + response.content, reasoning_content=response.reasoning_content, ) final_content = None @@ -253,24 +266,23 @@ class AgentLoop: while self._running: try: - msg = await asyncio.wait_for( - self.bus.consume_inbound(), - timeout=1.0 - ) + msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) try: response = await self._process_message(msg) if response: await self.bus.publish_outbound(response) except Exception as e: logger.error("Error processing message: {}", e) - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Sorry, I encountered an error: {str(e)}" - )) + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}", + ) + ) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -284,7 +296,15 @@ class AgentLoop: """Stop the agent loop.""" self._running = False logger.info("Agent loop stopping") - + + def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock: + """Return a per-session lock for memory consolidation writers.""" + lock = self._consolidation_locks.get(session_key) + if lock is None: + lock = asyncio.Lock() + self._consolidation_locks[session_key] = lock + return lock + async def _process_message( self, msg: InboundMessage, @@ -293,56 +313,75 @@ class AgentLoop: ) -> OutboundMessage | None: """ Process a single inbound message. - + Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). on_progress: Optional callback for intermediate output (defaults to bus publish). - + Returns: The response message, or None if no response needed. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": - # Capture messages before clearing (avoid race condition with background task) messages_to_archive = session.messages.copy() + lock = self._get_consolidation_lock(session.key) + + try: + async with lock: + temp_session = Session(key=session.key) + temp_session.messages = messages_to_archive + await self._consolidate_memory(temp_session, archive_all=True) + except Exception as e: + logger.error("/new archival failed for {}: {}", session.key, e) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Could not start a new session because memory archival failed. Please try again.", + ) + session.clear() self.sessions.save(session) self.sessions.invalidate(session.key) - - async def _consolidate_and_cleanup(): - temp_session = Session(key=session.key) - temp_session.messages = messages_to_archive - await self._consolidate_memory(temp_session, archive_all=True) - - asyncio.create_task(_consolidate_and_cleanup()) - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="New session started. Memory consolidation in progress.") + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="New session started. Memory consolidation in progress.", + ) if cmd == "/help": - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") - + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands", + ) + if len(session.messages) > self.memory_window and session.key not in self._consolidating: self._consolidating.add(session.key) + lock = self._get_consolidation_lock(session.key) async def _consolidate_and_unlock(): try: - await self._consolidate_memory(session) + async with lock: + await self._consolidate_memory(session) finally: self._consolidating.discard(session.key) + task = asyncio.current_task() + if task is not None: + self._consolidation_tasks.discard(task) - asyncio.create_task(_consolidate_and_unlock()) + task = asyncio.create_task(_consolidate_and_unlock()) + self._consolidation_tasks.add(task) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) initial_messages = self.context.build_messages( @@ -354,42 +393,49 @@ class AgentLoop: ) async def _bus_progress(content: str) -> None: - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content=content, - metadata=msg.metadata or {}, - )) + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=content, + metadata=msg.metadata or {}, + ) + ) final_content, tools_used = await self._run_agent_loop( - initial_messages, on_progress=on_progress or _bus_progress, + initial_messages, + on_progress=on_progress or _bus_progress, ) if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - + session.add_message("user", msg.content) - session.add_message("assistant", final_content, - tools_used=tools_used if tools_used else None) + session.add_message( + "assistant", final_content, tools_used=tools_used if tools_used else None + ) self.sessions.save(session) - + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, - metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) + metadata=msg.metadata + or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info("Processing system message from {}", msg.sender_id) - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -399,7 +445,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) @@ -413,17 +459,15 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( - channel=origin_channel, - chat_id=origin_chat_id, - content=final_content + channel=origin_channel, chat_id=origin_chat_id, content=final_content ) - + async def _consolidate_memory(self, session, archive_all: bool = False) -> None: """Consolidate old messages into MEMORY.md + HISTORY.md. @@ -431,34 +475,54 @@ class AgentLoop: archive_all: If True, clear all messages and reset session (for /new command). If False, only write to files without modifying session. """ - memory = MemoryStore(self.workspace) + memory = self.context.memory if archive_all: old_messages = session.messages keep_count = 0 - logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) + logger.info( + "Memory consolidation (archive_all): {} total messages archived", + len(session.messages), + ) else: keep_count = self.memory_window // 2 if len(session.messages) <= keep_count: - logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) + logger.debug( + "Session {}: No consolidation needed (messages={}, keep={})", + session.key, + len(session.messages), + keep_count, + ) return messages_to_process = len(session.messages) - session.last_consolidated if messages_to_process <= 0: - logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) + logger.debug( + "Session {}: No new messages to consolidate (last_consolidated={}, total={})", + session.key, + session.last_consolidated, + len(session.messages), + ) return - old_messages = session.messages[session.last_consolidated:-keep_count] + old_messages = session.messages[session.last_consolidated : -keep_count] if not old_messages: return - logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) + logger.info( + "Memory consolidation started: {} total, {} new to consolidate, {} keep", + len(session.messages), + len(old_messages), + keep_count, + ) lines = [] for m in old_messages: if not m.get("content"): continue tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" - lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") + lines.append( + f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}" + ) conversation = "\n".join(lines) current_memory = memory.read_long_term() @@ -487,7 +551,10 @@ Respond with ONLY valid JSON, no markdown fences.""" try: response = await self.provider.chat( messages=[ - {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."}, + { + "role": "system", + "content": "You are a memory consolidation agent. Respond only with valid JSON.", + }, {"role": "user", "content": prompt}, ], model=self.model, @@ -500,7 +567,10 @@ Respond with ONLY valid JSON, no markdown fences.""" text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() result = json_repair.loads(text) if not isinstance(result, dict): - logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200]) + logger.warning( + "Memory consolidation: unexpected response type, skipping. Response: {}", + text[:200], + ) return if entry := result.get("history_entry"): @@ -519,7 +589,11 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = 0 else: session.last_consolidated = len(session.messages) - keep_count - logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) + logger.info( + "Memory consolidation done: {} messages, last_consolidated={}", + len(session.messages), + session.last_consolidated, + ) except Exception as e: logger.error("Memory consolidation failed: {}", e) @@ -533,24 +607,21 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). on_progress: Optional callback for intermediate output. - + Returns: The agent's response. """ await self._connect_mcp() - msg = InboundMessage( - channel=channel, - sender_id="user", - chat_id=chat_id, - content=content + msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) + + response = await self._process_message( + msg, session_key=session_key, on_progress=on_progress ) - - response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index e204733..6162fa0 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -1,5 +1,8 @@ """Test session management with cache-friendly message handling.""" +import asyncio +from unittest.mock import AsyncMock, MagicMock + import pytest from pathlib import Path from nanobot.session.manager import Session, SessionManager @@ -475,3 +478,246 @@ class TestEmptyAndBoundarySessions: expected_count = 60 - KEEP_COUNT - 10 assert len(old_messages) == expected_count assert_messages_content(old_messages, 10, 34) + + +class TestConsolidationDeduplicationGuard: + """Test that consolidation tasks are deduplicated and serialized.""" + + @pytest.mark.asyncio + async def test_consolidation_guard_prevents_duplicate_tasks(self, tmp_path: Path) -> None: + """Concurrent messages above memory_window spawn only one consolidation task.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) + loop.tools.get_definitions = MagicMock(return_value=[]) + + session = loop.sessions.get_or_create("cli:test") + for i in range(15): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + + consolidation_calls = 0 + + async def _fake_consolidate(_session, archive_all: bool = False) -> None: + nonlocal consolidation_calls + consolidation_calls += 1 + await asyncio.sleep(0.05) + + loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign] + + msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello") + await loop._process_message(msg) + await loop._process_message(msg) + await asyncio.sleep(0.1) + + assert consolidation_calls == 1, ( + f"Expected exactly 1 consolidation, got {consolidation_calls}" + ) + + @pytest.mark.asyncio + async def test_new_command_guard_prevents_concurrent_consolidation( + self, tmp_path: Path + ) -> None: + """/new command does not run consolidation concurrently with in-flight consolidation.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) + loop.tools.get_definitions = MagicMock(return_value=[]) + + session = loop.sessions.get_or_create("cli:test") + for i in range(15): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + + consolidation_calls = 0 + active = 0 + max_active = 0 + + async def _fake_consolidate(_session, archive_all: bool = False) -> None: + nonlocal consolidation_calls, active, max_active + consolidation_calls += 1 + active += 1 + max_active = max(max_active, active) + await asyncio.sleep(0.05) + active -= 1 + + loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign] + + msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello") + await loop._process_message(msg) + + new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") + await loop._process_message(new_msg) + await asyncio.sleep(0.1) + + assert consolidation_calls == 2, ( + f"Expected normal + /new consolidations, got {consolidation_calls}" + ) + assert max_active == 1, ( + f"Expected serialized consolidation, observed concurrency={max_active}" + ) + + @pytest.mark.asyncio + async def test_consolidation_tasks_are_referenced(self, tmp_path: Path) -> None: + """create_task results are tracked in _consolidation_tasks while in flight.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) + loop.tools.get_definitions = MagicMock(return_value=[]) + + session = loop.sessions.get_or_create("cli:test") + for i in range(15): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + + started = asyncio.Event() + + async def _slow_consolidate(_session, archive_all: bool = False) -> None: + started.set() + await asyncio.sleep(0.1) + + loop._consolidate_memory = _slow_consolidate # type: ignore[method-assign] + + msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello") + await loop._process_message(msg) + + await started.wait() + assert len(loop._consolidation_tasks) == 1, "Task must be referenced while in-flight" + + await asyncio.sleep(0.15) + assert len(loop._consolidation_tasks) == 0, ( + "Task reference must be removed after completion" + ) + + @pytest.mark.asyncio + async def test_new_waits_for_inflight_consolidation_and_preserves_messages( + self, tmp_path: Path + ) -> None: + """/new waits for in-flight consolidation and archives before clear.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) + loop.tools.get_definitions = MagicMock(return_value=[]) + + session = loop.sessions.get_or_create("cli:test") + for i in range(15): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + + started = asyncio.Event() + release = asyncio.Event() + archived_count = 0 + + async def _fake_consolidate(sess, archive_all: bool = False) -> None: + nonlocal archived_count + if archive_all: + archived_count = len(sess.messages) + return + started.set() + await release.wait() + + loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign] + + msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello") + await loop._process_message(msg) + await started.wait() + + new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") + pending_new = asyncio.create_task(loop._process_message(new_msg)) + + await asyncio.sleep(0.02) + assert not pending_new.done(), "/new should wait while consolidation is in-flight" + + release.set() + response = await pending_new + assert response is not None + assert "new session started" in response.content.lower() + assert archived_count > 0, "Expected /new archival to process a non-empty snapshot" + + session_after = loop.sessions.get_or_create("cli:test") + assert session_after.messages == [], "Session should be cleared after successful archival" + + @pytest.mark.asyncio + async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None: + """/new keeps session data if archive step fails.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) + loop.tools.get_definitions = MagicMock(return_value=[]) + + session = loop.sessions.get_or_create("cli:test") + for i in range(5): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + before_count = len(session.messages) + + async def _failing_consolidate(_session, archive_all: bool = False) -> None: + if archive_all: + raise RuntimeError("forced archive failure") + + loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign] + + new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") + response = await loop._process_message(new_msg) + + assert response is not None + assert "failed" in response.content.lower() + session_after = loop.sessions.get_or_create("cli:test") + assert len(session_after.messages) == before_count, ( + "Session must remain intact when /new archival fails" + ) From 5f9eca466484e52ce535d4f20f4d0b87581da5db Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 12:46:11 +0100 Subject: [PATCH 109/415] style(loop): remove formatting-only changes from upstream PR 881 --- nanobot/agent/loop.py | 220 ++++++++++++++++-------------------------- 1 file changed, 85 insertions(+), 135 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 7806fb8..481b72e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -56,7 +56,6 @@ class AgentLoop: ): from nanobot.config.schema import ExecToolConfig from nanobot.cron.service import CronService - self.bus = bus self.provider = provider self.workspace = workspace @@ -84,16 +83,16 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False self._consolidating: set[str] = set() # Session keys with consolidation in progress - self._consolidation_tasks: set[asyncio.Task] = set() # Keep strong refs for in-flight tasks + self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks self._consolidation_locks: dict[str, asyncio.Lock] = {} self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (workspace for relative paths, restrict if configured) @@ -102,39 +101,36 @@ class AgentLoop: self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - + # Shell tool - self.tools.register( - ExecTool( - working_dir=str(self.workspace), - timeout=self.exec_config.timeout, - restrict_to_workspace=self.restrict_to_workspace, - ) - ) - + self.tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + )) + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: return self._mcp_connected = True from nanobot.agent.tools.mcp import connect_mcp_servers - self._mcp_stack = AsyncExitStack() await self._mcp_stack.__aenter__() await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) @@ -163,13 +159,11 @@ class AgentLoop: @staticmethod def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hint, e.g. 'web_search("query")'.""" - def _fmt(tc): val = next(iter(tc.arguments.values()), None) if tc.arguments else None if not isinstance(val, str): return tc.name return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' - return ", ".join(_fmt(tc) for tc in tool_calls) async def _run_agent_loop( @@ -217,15 +211,13 @@ class AgentLoop: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments, ensure_ascii=False), - }, + "arguments": json.dumps(tc.arguments, ensure_ascii=False) + } } for tc in response.tool_calls ] messages = self.context.add_assistant_message( - messages, - response.content, - tool_call_dicts, + messages, response.content, tool_call_dicts, reasoning_content=response.reasoning_content, ) @@ -243,13 +235,9 @@ class AgentLoop: # Give them one retry; don't forward the text to avoid duplicates. if not tools_used and not text_only_retried and final_content: text_only_retried = True - logger.debug( - "Interim text response (no tools used yet), retrying: {}", - final_content[:80], - ) + logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) messages = self.context.add_assistant_message( - messages, - response.content, + messages, response.content, reasoning_content=response.reasoning_content, ) final_content = None @@ -266,23 +254,24 @@ class AgentLoop: while self._running: try: - msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) + msg = await asyncio.wait_for( + self.bus.consume_inbound(), + timeout=1.0 + ) try: response = await self._process_message(msg) if response: await self.bus.publish_outbound(response) except Exception as e: logger.error("Error processing message: {}", e) - await self.bus.publish_outbound( - OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Sorry, I encountered an error: {str(e)}", - ) - ) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}" + )) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -298,13 +287,12 @@ class AgentLoop: logger.info("Agent loop stopping") def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock: - """Return a per-session lock for memory consolidation writers.""" lock = self._consolidation_locks.get(session_key) if lock is None: lock = asyncio.Lock() self._consolidation_locks[session_key] = lock return lock - + async def _process_message( self, msg: InboundMessage, @@ -313,25 +301,25 @@ class AgentLoop: ) -> OutboundMessage | None: """ Process a single inbound message. - + Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). on_progress: Optional callback for intermediate output (defaults to bus publish). - + Returns: The response message, or None if no response needed. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": @@ -348,24 +336,18 @@ class AgentLoop: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content="Could not start a new session because memory archival failed. Please try again.", + content="Could not start a new session because memory archival failed. Please try again." ) session.clear() self.sessions.save(session) self.sessions.invalidate(session.key) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="New session started. Memory consolidation in progress.", - ) + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="New session started. Memory consolidation in progress.") if cmd == "/help": - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands", - ) - + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") + if len(session.messages) > self.memory_window and session.key not in self._consolidating: self._consolidating.add(session.key) lock = self._get_consolidation_lock(session.key) @@ -376,12 +358,12 @@ class AgentLoop: await self._consolidate_memory(session) finally: self._consolidating.discard(session.key) - task = asyncio.current_task() - if task is not None: - self._consolidation_tasks.discard(task) + _task = asyncio.current_task() + if _task is not None: + self._consolidation_tasks.discard(_task) - task = asyncio.create_task(_consolidate_and_unlock()) - self._consolidation_tasks.add(task) + _task = asyncio.create_task(_consolidate_and_unlock()) + self._consolidation_tasks.add(_task) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) initial_messages = self.context.build_messages( @@ -393,49 +375,42 @@ class AgentLoop: ) async def _bus_progress(content: str) -> None: - await self.bus.publish_outbound( - OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=content, - metadata=msg.metadata or {}, - ) - ) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + metadata=msg.metadata or {}, + )) final_content, tools_used = await self._run_agent_loop( - initial_messages, - on_progress=on_progress or _bus_progress, + initial_messages, on_progress=on_progress or _bus_progress, ) if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - + session.add_message("user", msg.content) - session.add_message( - "assistant", final_content, tools_used=tools_used if tools_used else None - ) + session.add_message("assistant", final_content, + tools_used=tools_used if tools_used else None) self.sessions.save(session) - + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, - metadata=msg.metadata - or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) + metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info("Processing system message from {}", msg.sender_id) - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -445,7 +420,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) @@ -459,15 +434,17 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( - channel=origin_channel, chat_id=origin_chat_id, content=final_content + channel=origin_channel, + chat_id=origin_chat_id, + content=final_content ) - + async def _consolidate_memory(self, session, archive_all: bool = False) -> None: """Consolidate old messages into MEMORY.md + HISTORY.md. @@ -480,49 +457,29 @@ class AgentLoop: if archive_all: old_messages = session.messages keep_count = 0 - logger.info( - "Memory consolidation (archive_all): {} total messages archived", - len(session.messages), - ) + logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) else: keep_count = self.memory_window // 2 if len(session.messages) <= keep_count: - logger.debug( - "Session {}: No consolidation needed (messages={}, keep={})", - session.key, - len(session.messages), - keep_count, - ) + logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) return messages_to_process = len(session.messages) - session.last_consolidated if messages_to_process <= 0: - logger.debug( - "Session {}: No new messages to consolidate (last_consolidated={}, total={})", - session.key, - session.last_consolidated, - len(session.messages), - ) + logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) return - old_messages = session.messages[session.last_consolidated : -keep_count] + old_messages = session.messages[session.last_consolidated:-keep_count] if not old_messages: return - logger.info( - "Memory consolidation started: {} total, {} new to consolidate, {} keep", - len(session.messages), - len(old_messages), - keep_count, - ) + logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) lines = [] for m in old_messages: if not m.get("content"): continue tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" - lines.append( - f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}" - ) + lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") conversation = "\n".join(lines) current_memory = memory.read_long_term() @@ -551,10 +508,7 @@ Respond with ONLY valid JSON, no markdown fences.""" try: response = await self.provider.chat( messages=[ - { - "role": "system", - "content": "You are a memory consolidation agent. Respond only with valid JSON.", - }, + {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."}, {"role": "user", "content": prompt}, ], model=self.model, @@ -567,10 +521,7 @@ Respond with ONLY valid JSON, no markdown fences.""" text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() result = json_repair.loads(text) if not isinstance(result, dict): - logger.warning( - "Memory consolidation: unexpected response type, skipping. Response: {}", - text[:200], - ) + logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200]) return if entry := result.get("history_entry"): @@ -589,11 +540,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = 0 else: session.last_consolidated = len(session.messages) - keep_count - logger.info( - "Memory consolidation done: {} messages, last_consolidated={}", - len(session.messages), - session.last_consolidated, - ) + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) except Exception as e: logger.error("Memory consolidation failed: {}", e) @@ -607,21 +554,24 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). on_progress: Optional callback for intermediate output. - + Returns: The agent's response. """ await self._connect_mcp() - msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) - - response = await self._process_message( - msg, session_key=session_key, on_progress=on_progress + msg = InboundMessage( + channel=channel, + sender_id="user", + chat_id=chat_id, + content=content ) + + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" From 9ada8e68547bae6daeb8e6e43b7c1babdb519724 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 12:48:54 +0100 Subject: [PATCH 110/415] fix(loop): require successful archival before /new clear --- nanobot/agent/loop.py | 230 +++++++++++++++++++------------ tests/test_consolidate_offset.py | 12 +- 2 files changed, 151 insertions(+), 91 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 481b72e..4ff01ea 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -56,6 +56,7 @@ class AgentLoop: ): from nanobot.config.schema import ExecToolConfig from nanobot.cron.service import CronService + self.bus = bus self.provider = provider self.workspace = workspace @@ -83,7 +84,7 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None @@ -92,7 +93,7 @@ class AgentLoop: self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks self._consolidation_locks: dict[str, asyncio.Lock] = {} self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (workspace for relative paths, restrict if configured) @@ -101,36 +102,39 @@ class AgentLoop: self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - + # Shell tool - self.tools.register(ExecTool( - working_dir=str(self.workspace), - timeout=self.exec_config.timeout, - restrict_to_workspace=self.restrict_to_workspace, - )) - + self.tools.register( + ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + ) + ) + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: return self._mcp_connected = True from nanobot.agent.tools.mcp import connect_mcp_servers + self._mcp_stack = AsyncExitStack() await self._mcp_stack.__aenter__() await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) @@ -159,11 +163,13 @@ class AgentLoop: @staticmethod def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hint, e.g. 'web_search("query")'.""" + def _fmt(tc): val = next(iter(tc.arguments.values()), None) if tc.arguments else None if not isinstance(val, str): return tc.name return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' + return ", ".join(_fmt(tc) for tc in tool_calls) async def _run_agent_loop( @@ -211,13 +217,15 @@ class AgentLoop: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments, ensure_ascii=False) - } + "arguments": json.dumps(tc.arguments, ensure_ascii=False), + }, } for tc in response.tool_calls ] messages = self.context.add_assistant_message( - messages, response.content, tool_call_dicts, + messages, + response.content, + tool_call_dicts, reasoning_content=response.reasoning_content, ) @@ -235,9 +243,13 @@ class AgentLoop: # Give them one retry; don't forward the text to avoid duplicates. if not tools_used and not text_only_retried and final_content: text_only_retried = True - logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) + logger.debug( + "Interim text response (no tools used yet), retrying: {}", + final_content[:80], + ) messages = self.context.add_assistant_message( - messages, response.content, + messages, + response.content, reasoning_content=response.reasoning_content, ) final_content = None @@ -254,24 +266,23 @@ class AgentLoop: while self._running: try: - msg = await asyncio.wait_for( - self.bus.consume_inbound(), - timeout=1.0 - ) + msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) try: response = await self._process_message(msg) if response: await self.bus.publish_outbound(response) except Exception as e: logger.error("Error processing message: {}", e) - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Sorry, I encountered an error: {str(e)}" - )) + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}", + ) + ) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -292,7 +303,7 @@ class AgentLoop: lock = asyncio.Lock() self._consolidation_locks[session_key] = lock return lock - + async def _process_message( self, msg: InboundMessage, @@ -301,25 +312,25 @@ class AgentLoop: ) -> OutboundMessage | None: """ Process a single inbound message. - + Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). on_progress: Optional callback for intermediate output (defaults to bus publish). - + Returns: The response message, or None if no response needed. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": @@ -330,24 +341,37 @@ class AgentLoop: async with lock: temp_session = Session(key=session.key) temp_session.messages = messages_to_archive - await self._consolidate_memory(temp_session, archive_all=True) + archived = await self._consolidate_memory(temp_session, archive_all=True) except Exception as e: logger.error("/new archival failed for {}: {}", session.key, e) return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content="Could not start a new session because memory archival failed. Please try again." + content="Could not start a new session because memory archival failed. Please try again.", + ) + + if messages_to_archive and not archived: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Could not start a new session because memory archival failed. Please try again.", ) session.clear() self.sessions.save(session) self.sessions.invalidate(session.key) - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="New session started. Memory consolidation in progress.") + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="New session started. Memory consolidation in progress.", + ) if cmd == "/help": - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") - + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands", + ) + if len(session.messages) > self.memory_window and session.key not in self._consolidating: self._consolidating.add(session.key) lock = self._get_consolidation_lock(session.key) @@ -375,42 +399,49 @@ class AgentLoop: ) async def _bus_progress(content: str) -> None: - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content=content, - metadata=msg.metadata or {}, - )) + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=content, + metadata=msg.metadata or {}, + ) + ) final_content, tools_used = await self._run_agent_loop( - initial_messages, on_progress=on_progress or _bus_progress, + initial_messages, + on_progress=on_progress or _bus_progress, ) if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - + session.add_message("user", msg.content) - session.add_message("assistant", final_content, - tools_used=tools_used if tools_used else None) + session.add_message( + "assistant", final_content, tools_used=tools_used if tools_used else None + ) self.sessions.save(session) - + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, - metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) + metadata=msg.metadata + or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info("Processing system message from {}", msg.sender_id) - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -420,7 +451,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) @@ -434,18 +465,16 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( - channel=origin_channel, - chat_id=origin_chat_id, - content=final_content + channel=origin_channel, chat_id=origin_chat_id, content=final_content ) - - async def _consolidate_memory(self, session, archive_all: bool = False) -> None: + + async def _consolidate_memory(self, session, archive_all: bool = False) -> bool: """Consolidate old messages into MEMORY.md + HISTORY.md. Args: @@ -457,29 +486,49 @@ class AgentLoop: if archive_all: old_messages = session.messages keep_count = 0 - logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) + logger.info( + "Memory consolidation (archive_all): {} total messages archived", + len(session.messages), + ) else: keep_count = self.memory_window // 2 if len(session.messages) <= keep_count: - logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) - return + logger.debug( + "Session {}: No consolidation needed (messages={}, keep={})", + session.key, + len(session.messages), + keep_count, + ) + return True messages_to_process = len(session.messages) - session.last_consolidated if messages_to_process <= 0: - logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) - return + logger.debug( + "Session {}: No new messages to consolidate (last_consolidated={}, total={})", + session.key, + session.last_consolidated, + len(session.messages), + ) + return True - old_messages = session.messages[session.last_consolidated:-keep_count] + old_messages = session.messages[session.last_consolidated : -keep_count] if not old_messages: - return - logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) + return True + logger.info( + "Memory consolidation started: {} total, {} new to consolidate, {} keep", + len(session.messages), + len(old_messages), + keep_count, + ) lines = [] for m in old_messages: if not m.get("content"): continue tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" - lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") + lines.append( + f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}" + ) conversation = "\n".join(lines) current_memory = memory.read_long_term() @@ -508,7 +557,10 @@ Respond with ONLY valid JSON, no markdown fences.""" try: response = await self.provider.chat( messages=[ - {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."}, + { + "role": "system", + "content": "You are a memory consolidation agent. Respond only with valid JSON.", + }, {"role": "user", "content": prompt}, ], model=self.model, @@ -516,13 +568,16 @@ Respond with ONLY valid JSON, no markdown fences.""" text = (response.content or "").strip() if not text: logger.warning("Memory consolidation: LLM returned empty response, skipping") - return + return False if text.startswith("```"): text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() result = json_repair.loads(text) if not isinstance(result, dict): - logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200]) - return + logger.warning( + "Memory consolidation: unexpected response type, skipping. Response: {}", + text[:200], + ) + return False if entry := result.get("history_entry"): # Defensive: ensure entry is a string (LLM may return dict) @@ -540,9 +595,15 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = 0 else: session.last_consolidated = len(session.messages) - keep_count - logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) + logger.info( + "Memory consolidation done: {} messages, last_consolidated={}", + len(session.messages), + session.last_consolidated, + ) + return True except Exception as e: logger.error("Memory consolidation failed: {}", e) + return False async def process_direct( self, @@ -554,24 +615,21 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). on_progress: Optional callback for intermediate output. - + Returns: The agent's response. """ await self._connect_mcp() - msg = InboundMessage( - channel=channel, - sender_id="user", - chat_id=chat_id, - content=content + msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) + + response = await self._process_message( + msg, session_key=session_key, on_progress=on_progress ) - - response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 6162fa0..6be808d 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -652,13 +652,14 @@ class TestConsolidationDeduplicationGuard: release = asyncio.Event() archived_count = 0 - async def _fake_consolidate(sess, archive_all: bool = False) -> None: + async def _fake_consolidate(sess, archive_all: bool = False) -> bool: nonlocal archived_count if archive_all: archived_count = len(sess.messages) - return + return True started.set() await release.wait() + return True loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign] @@ -683,7 +684,7 @@ class TestConsolidationDeduplicationGuard: @pytest.mark.asyncio async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None: - """/new keeps session data if archive step fails.""" + """/new must keep session data if archive step reports failure.""" from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus @@ -706,9 +707,10 @@ class TestConsolidationDeduplicationGuard: loop.sessions.save(session) before_count = len(session.messages) - async def _failing_consolidate(_session, archive_all: bool = False) -> None: + async def _failing_consolidate(sess, archive_all: bool = False) -> bool: if archive_all: - raise RuntimeError("forced archive failure") + return False + return True loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign] From ddae3e9d5f73ab9d95aa866d9894190d0f2200de Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:25:47 +0800 Subject: [PATCH 111/415] fix(agent): avoid duplicate final send when message tool already replied --- nanobot/agent/loop.py | 120 +++++++++++++++++++-------------- nanobot/agent/tools/message.py | 43 +++++++----- 2 files changed, 97 insertions(+), 66 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3016d92..926fad9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1,30 +1,36 @@ """Agent loop: the core processing engine.""" -import asyncio -from contextlib import AsyncExitStack -import json -import json_repair -from pathlib import Path -import re -from typing import Any, Awaitable, Callable +from __future__ import annotations +import asyncio +import json +import re +from contextlib import AsyncExitStack +from pathlib import Path +from typing import TYPE_CHECKING, Awaitable, Callable + +import json_repair from loguru import logger +from nanobot.agent.context import ContextBuilder +from nanobot.agent.memory import MemoryStore +from nanobot.agent.subagent import SubagentManager +from nanobot.agent.tools.cron import CronTool +from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool +from nanobot.agent.tools.message import MessageTool +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.spawn import SpawnTool +from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMProvider -from nanobot.agent.context import ContextBuilder -from nanobot.agent.tools.registry import ToolRegistry -from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool -from nanobot.agent.tools.shell import ExecTool -from nanobot.agent.tools.web import WebSearchTool, WebFetchTool -from nanobot.agent.tools.message import MessageTool -from nanobot.agent.tools.spawn import SpawnTool -from nanobot.agent.tools.cron import CronTool -from nanobot.agent.memory import MemoryStore -from nanobot.agent.subagent import SubagentManager from nanobot.session.manager import Session, SessionManager +if TYPE_CHECKING: + from nanobot.config.schema import ExecToolConfig + from nanobot.cron.service import CronService + class AgentLoop: """ @@ -49,14 +55,13 @@ class AgentLoop: max_tokens: int = 4096, memory_window: int = 50, brave_api_key: str | None = None, - exec_config: "ExecToolConfig | None" = None, - cron_service: "CronService | None" = None, + exec_config: ExecToolConfig | None = None, + cron_service: CronService | None = None, restrict_to_workspace: bool = False, session_manager: SessionManager | None = None, mcp_servers: dict | None = None, ): from nanobot.config.schema import ExecToolConfig - from nanobot.cron.service import CronService self.bus = bus self.provider = provider self.workspace = workspace @@ -84,14 +89,14 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False self._consolidating: set[str] = set() # Session keys with consolidation in progress self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (workspace for relative paths, restrict if configured) @@ -100,30 +105,30 @@ class AgentLoop: self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - + # Shell tool self.tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, )) - + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: @@ -270,7 +275,7 @@ class AgentLoop: )) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -284,7 +289,7 @@ class AgentLoop: """Stop the agent loop.""" self._running = False logger.info("Agent loop stopping") - + async def _process_message( self, msg: InboundMessage, @@ -293,25 +298,25 @@ class AgentLoop: ) -> OutboundMessage | None: """ Process a single inbound message. - + Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). on_progress: Optional callback for intermediate output (defaults to bus publish). - + Returns: The response message, or None if no response needed. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": @@ -332,7 +337,7 @@ class AgentLoop: if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") - + if len(session.messages) > self.memory_window and session.key not in self._consolidating: self._consolidating.add(session.key) @@ -345,6 +350,10 @@ class AgentLoop: asyncio.create_task(_consolidate_and_unlock()) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) + if message_tool := self.tools.get("message"): + if isinstance(message_tool, MessageTool): + message_tool.start_turn() + initial_messages = self.context.build_messages( history=session.get_history(max_messages=self.memory_window), current_message=msg.content, @@ -365,31 +374,44 @@ class AgentLoop: if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - + session.add_message("user", msg.content) session.add_message("assistant", final_content, tools_used=tools_used if tools_used else None) self.sessions.save(session) - + + suppress_final_reply = False + if message_tool := self.tools.get("message"): + if isinstance(message_tool, MessageTool): + sent_targets = set(message_tool.get_turn_sends()) + suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets + + if suppress_final_reply: + logger.info( + "Skipping final auto-reply because message tool already sent to " + f"{msg.channel}:{msg.chat_id} in this turn" + ) + return None + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info("Processing system message from {}", msg.sender_id) - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -399,7 +421,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) @@ -413,17 +435,17 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( channel=origin_channel, chat_id=origin_chat_id, content=final_content ) - + async def _consolidate_memory(self, session, archive_all: bool = False) -> None: """Consolidate old messages into MEMORY.md + HISTORY.md. @@ -533,14 +555,14 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). on_progress: Optional callback for intermediate output. - + Returns: The agent's response. """ @@ -551,6 +573,6 @@ Respond with ONLY valid JSON, no markdown fences.""" chat_id=chat_id, content=content ) - + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 10947c4..593bc44 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,6 +1,6 @@ """Message tool for sending messages to users.""" -from typing import Any, Callable, Awaitable +from typing import Any, Awaitable, Callable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage @@ -8,37 +8,45 @@ from nanobot.bus.events import OutboundMessage class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( - self, + self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", default_chat_id: str = "", - default_message_id: str | None = None + default_message_id: str | None = None, ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - + self._turn_sends: list[tuple[str, str]] = [] + def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id self._default_message_id = message_id - def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - + + def start_turn(self) -> None: + """Reset per-turn send tracking.""" + self._turn_sends.clear() + + def get_turn_sends(self) -> list[tuple[str, str]]: + """Get (channel, chat_id) targets sent in the current turn.""" + return list(self._turn_sends) + @property def name(self) -> str: return "message" - + @property def description(self) -> str: return "Send a message to the user. Use this when you want to communicate something." - + @property def parameters(self) -> dict[str, Any]: return { @@ -64,11 +72,11 @@ class MessageTool(Tool): }, "required": ["content"] } - + async def execute( - self, - content: str, - channel: str | None = None, + self, + content: str, + channel: str | None = None, chat_id: str | None = None, message_id: str | None = None, media: list[str] | None = None, @@ -77,13 +85,13 @@ class MessageTool(Tool): channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id message_id = message_id or self._default_message_id - + if not channel or not chat_id: return "Error: No target channel/chat specified" - + if not self._send_callback: return "Error: Message sending not configured" - + msg = OutboundMessage( channel=channel, chat_id=chat_id, @@ -93,9 +101,10 @@ class MessageTool(Tool): "message_id": message_id, } ) - + try: await self._send_callback(msg) + self._turn_sends.append((channel, chat_id)) media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: From 4eb07c44b982076f29331e2d41e1f6963cfc48db Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:21:27 -0300 Subject: [PATCH 112/415] fix: preserve interim content as fallback when retry produces empty response Fixes regression from #825 where models that respond with final text directly (no tools) had their answer discarded by the retry mechanism. Closes #878 --- nanobot/agent/loop.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3016d92..d817815 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -185,6 +185,7 @@ class AgentLoop: final_content = None tools_used: list[str] = [] text_only_retried = False + interim_content: str | None = None # Fallback if retry produces nothing while iteration < self.max_iterations: iteration += 1 @@ -231,9 +232,11 @@ class AgentLoop: else: final_content = self._strip_think(response.content) # Some models send an interim text response before tool calls. - # Give them one retry; don't forward the text to avoid duplicates. + # Give them one retry; save the content as fallback in case + # the retry produces nothing useful (e.g. model already answered). if not tools_used and not text_only_retried and final_content: text_only_retried = True + interim_content = final_content logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) messages = self.context.add_assistant_message( messages, response.content, @@ -241,6 +244,9 @@ class AgentLoop: ) final_content = None continue + # Fall back to interim content if retry produced nothing + if not final_content and interim_content: + final_content = interim_content break return final_content, tools_used From 44f44b305a60ba27adf4ed8bdfb577412b6d9176 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:24:48 -0300 Subject: [PATCH 113/415] fix: move MCP connected flag after successful connection to allow retry The flag was set before the connection attempt, so if any MCP server was temporarily unavailable, the flag stayed True and MCP tools were permanently lost for the session. Closes #889 --- nanobot/agent/loop.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 3016d92..9078318 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -128,11 +128,20 @@ class AgentLoop: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: return - self._mcp_connected = True from nanobot.agent.tools.mcp import connect_mcp_servers - self._mcp_stack = AsyncExitStack() - await self._mcp_stack.__aenter__() - await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) + try: + self._mcp_stack = AsyncExitStack() + await self._mcp_stack.__aenter__() + await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) + self._mcp_connected = True + except Exception as e: + logger.error("Failed to connect MCP servers (will retry next message): {}", e) + if self._mcp_stack: + try: + await self._mcp_stack.aclose() + except Exception: + pass + self._mcp_stack = None def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" From 8cc54b188d81504f39ea15cfd656b4b40af0eb8a Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:25:46 +0800 Subject: [PATCH 114/415] style(logging): use loguru parameterized formatting in suppression log --- nanobot/agent/loop.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 926fad9..646b658 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -391,8 +391,9 @@ class AgentLoop: if suppress_final_reply: logger.info( - "Skipping final auto-reply because message tool already sent to " - f"{msg.channel}:{msg.chat_id} in this turn" + "Skipping final auto-reply because message tool already sent to {}:{} in this turn", + msg.channel, + msg.chat_id, ) return None From c1b5e8c8d29a2225a1f82a36a93da5a0b61d702f Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 13:29:18 +0100 Subject: [PATCH 115/415] fix(loop): lock /new snapshot and prune stale consolidation locks --- nanobot/agent/loop.py | 14 ++++- tests/test_consolidate_offset.py | 103 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 4ff01ea..b0bace5 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -304,6 +304,14 @@ class AgentLoop: self._consolidation_locks[session_key] = lock return lock + def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None: + """Drop unused per-session lock entries to avoid unbounded growth.""" + waiters = getattr(lock, "_waiters", None) + has_waiters = bool(waiters) + if lock.locked() or has_waiters: + return + self._consolidation_locks.pop(session_key, None) + async def _process_message( self, msg: InboundMessage, @@ -334,11 +342,11 @@ class AgentLoop: # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": - messages_to_archive = session.messages.copy() lock = self._get_consolidation_lock(session.key) - + messages_to_archive: list[dict[str, Any]] = [] try: async with lock: + messages_to_archive = session.messages[session.last_consolidated :].copy() temp_session = Session(key=session.key) temp_session.messages = messages_to_archive archived = await self._consolidate_memory(temp_session, archive_all=True) @@ -360,6 +368,7 @@ class AgentLoop: session.clear() self.sessions.save(session) self.sessions.invalidate(session.key) + self._prune_consolidation_lock(session.key, lock) return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, @@ -382,6 +391,7 @@ class AgentLoop: await self._consolidate_memory(session) finally: self._consolidating.discard(session.key) + self._prune_consolidation_lock(session.key, lock) _task = asyncio.current_task() if _task is not None: self._consolidation_tasks.discard(_task) diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 6be808d..323519e 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -723,3 +723,106 @@ class TestConsolidationDeduplicationGuard: assert len(session_after.messages) == before_count, ( "Session must remain intact when /new archival fails" ) + + @pytest.mark.asyncio + async def test_new_archives_only_unconsolidated_messages_after_inflight_task( + self, tmp_path: Path + ) -> None: + """/new should archive only messages not yet consolidated by prior task.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) + loop.tools.get_definitions = MagicMock(return_value=[]) + + session = loop.sessions.get_or_create("cli:test") + for i in range(15): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + + started = asyncio.Event() + release = asyncio.Event() + archived_count = -1 + + async def _fake_consolidate(sess, archive_all: bool = False) -> bool: + nonlocal archived_count + if archive_all: + archived_count = len(sess.messages) + return True + + started.set() + await release.wait() + sess.last_consolidated = len(sess.messages) - 3 + return True + + loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign] + + msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello") + await loop._process_message(msg) + await started.wait() + + new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") + pending_new = asyncio.create_task(loop._process_message(new_msg)) + await asyncio.sleep(0.02) + assert not pending_new.done() + + release.set() + response = await pending_new + + assert response is not None + assert "new session started" in response.content.lower() + assert archived_count == 3, ( + f"Expected only unconsolidated tail to archive, got {archived_count}" + ) + + @pytest.mark.asyncio + async def test_new_cleans_up_consolidation_lock_for_invalidated_session( + self, tmp_path: Path + ) -> None: + """/new should remove lock entry for fully invalidated session key.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + from nanobot.providers.base import LLMResponse + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[])) + loop.tools.get_definitions = MagicMock(return_value=[]) + + session = loop.sessions.get_or_create("cli:test") + for i in range(3): + session.add_message("user", f"msg{i}") + session.add_message("assistant", f"resp{i}") + loop.sessions.save(session) + + # Ensure lock exists before /new. + _ = loop._get_consolidation_lock(session.key) + assert session.key in loop._consolidation_locks + + async def _ok_consolidate(sess, archive_all: bool = False) -> bool: + return True + + loop._consolidate_memory = _ok_consolidate # type: ignore[method-assign] + + new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") + response = await loop._process_message(new_msg) + + assert response is not None + assert "new session started" in response.content.lower() + assert session.key not in loop._consolidation_locks From df022febaf4782270588431c0ba7c6b84f4b29c9 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 13:31:15 +0100 Subject: [PATCH 116/415] refactor(loop): drop redundant Any typing in /new snapshot --- nanobot/agent/loop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b0bace5..42ab351 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -6,7 +6,7 @@ import json import json_repair from pathlib import Path import re -from typing import Any, Awaitable, Callable +from typing import Awaitable, Callable from loguru import logger @@ -343,7 +343,7 @@ class AgentLoop: cmd = msg.content.strip().lower() if cmd == "/new": lock = self._get_consolidation_lock(session.key) - messages_to_archive: list[dict[str, Any]] = [] + messages_to_archive = [] try: async with lock: messages_to_archive = session.messages[session.last_consolidated :].copy() From 45f33853cf952b8642c104a5ecaa263a473f4963 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:37:42 -0300 Subject: [PATCH 117/415] fix: only apply interim fallback when no tools were used Addresses Codex review: if the model sent interim text then used tools, the interim text should not be used as fallback for the final response. --- nanobot/agent/loop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d817815..177302e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -245,7 +245,8 @@ class AgentLoop: final_content = None continue # Fall back to interim content if retry produced nothing - if not final_content and interim_content: + # and no tools were used (if tools ran, interim was truly interim) + if not final_content and interim_content and not tools_used: final_content = interim_content break From 37222f9c0a118c449f0ef291c380916e3ad7ae62 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:38:22 -0300 Subject: [PATCH 118/415] fix: add connecting guard to prevent concurrent MCP connection attempts Addresses Codex review: concurrent callers could both pass the _mcp_connected guard and race through _connect_mcp(). Added _mcp_connecting flag set immediately to serialize attempts. --- nanobot/agent/loop.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9078318..d14b6ea 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -89,6 +89,7 @@ class AgentLoop: self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False + self._mcp_connecting = False self._consolidating: set[str] = set() # Session keys with consolidation in progress self._register_default_tools() @@ -126,8 +127,9 @@ class AgentLoop: async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" - if self._mcp_connected or not self._mcp_servers: + if self._mcp_connected or self._mcp_connecting or not self._mcp_servers: return + self._mcp_connecting = True from nanobot.agent.tools.mcp import connect_mcp_servers try: self._mcp_stack = AsyncExitStack() @@ -142,6 +144,8 @@ class AgentLoop: except Exception: pass self._mcp_stack = None + finally: + self._mcp_connecting = False def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" From 4c75e1673fb546a9da3e3730a91fb6fe0165e70b Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:55:22 -0300 Subject: [PATCH 119/415] fix: split Discord messages exceeding 2000-character limit Discord's API rejects messages longer than 2000 characters with HTTP 400. Previously, long agent responses were silently lost after retries exhausted. Adds _split_message() (matching Telegram's approach) to chunk content at line boundaries before sending. Only the first chunk carries the reply reference. Retry logic extracted to _send_payload() for reuse across chunks. Closes #898 --- nanobot/channels/discord.py | 74 ++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 8baecbf..c073a05 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -17,6 +17,27 @@ from nanobot.config.schema import DiscordConfig DISCORD_API_BASE = "https://discord.com/api/v10" MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB +MAX_MESSAGE_LEN = 2000 # Discord message character limit + + +def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: + """Split content into chunks within max_len, preferring line breaks.""" + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + pos = cut.rfind('\n') + if pos == -1: + pos = cut.rfind(' ') + if pos == -1: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks class DiscordChannel(BaseChannel): @@ -79,34 +100,43 @@ class DiscordChannel(BaseChannel): return url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" - payload: dict[str, Any] = {"content": msg.content} - - if msg.reply_to: - payload["message_reference"] = {"message_id": msg.reply_to} - payload["allowed_mentions"] = {"replied_user": False} - headers = {"Authorization": f"Bot {self.config.token}"} try: - for attempt in range(3): - try: - response = await self._http.post(url, headers=headers, json=payload) - if response.status_code == 429: - data = response.json() - retry_after = float(data.get("retry_after", 1.0)) - logger.warning("Discord rate limited, retrying in {}s", retry_after) - await asyncio.sleep(retry_after) - continue - response.raise_for_status() - return - except Exception as e: - if attempt == 2: - logger.error("Error sending Discord message: {}", e) - else: - await asyncio.sleep(1) + chunks = _split_message(msg.content or "") + for i, chunk in enumerate(chunks): + payload: dict[str, Any] = {"content": chunk} + + # Only set reply reference on the first chunk + if i == 0 and msg.reply_to: + payload["message_reference"] = {"message_id": msg.reply_to} + payload["allowed_mentions"] = {"replied_user": False} + + await self._send_payload(url, headers, payload) finally: await self._stop_typing(msg.chat_id) + async def _send_payload( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> None: + """Send a single Discord API payload with retry on rate-limit.""" + for attempt in range(3): + try: + response = await self._http.post(url, headers=headers, json=payload) + if response.status_code == 429: + data = response.json() + retry_after = float(data.get("retry_after", 1.0)) + logger.warning("Discord rate limited, retrying in {}s", retry_after) + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + return + except Exception as e: + if attempt == 2: + logger.error("Error sending Discord message: {}", e) + else: + await asyncio.sleep(1) + async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" if not self._ws: From 73530d51acc6229ac3c7020a4129f06813478b83 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 09:57:11 -0300 Subject: [PATCH 120/415] fix: store session key in JSONL metadata to avoid lossy filename reconstruction list_sessions() previously reconstructed the session key by replacing all underscores in the filename with colons. This is lossy: a key like 'cli:user_name' became 'cli:user:name' after round-tripping. Now the actual key is persisted in the metadata line during save() and read back in list_sessions(). Legacy files without the key field fall back to replacing only the first underscore, which handles the common channel:chat_id pattern correctly. Closes #899 --- nanobot/session/manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 9c1e427..35f8d13 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -154,6 +154,7 @@ class SessionManager: with open(path, "w", encoding="utf-8") as f: metadata_line = { "_type": "metadata", + "key": session.key, "created_at": session.created_at.isoformat(), "updated_at": session.updated_at.isoformat(), "metadata": session.metadata, @@ -186,8 +187,11 @@ class SessionManager: if first_line: data = json.loads(first_line) if data.get("_type") == "metadata": + # Prefer the key stored in metadata; fall back to + # filename-based heuristic for legacy files. + key = data.get("key") or path.stem.replace("_", ":", 1) sessions.append({ - "key": path.stem.replace("_", ":"), + "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), "path": str(path) From 426ef71ce7a87d0c104bbf34e63e2399166bf83e Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 13:57:39 +0100 Subject: [PATCH 121/415] style(loop): drop formatting-only churn against upstream main --- nanobot/agent/loop.py | 209 ++++++++++++++++-------------------------- 1 file changed, 80 insertions(+), 129 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 42ab351..06ab1f7 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -6,7 +6,7 @@ import json import json_repair from pathlib import Path import re -from typing import Awaitable, Callable +from typing import Any, Awaitable, Callable from loguru import logger @@ -56,7 +56,6 @@ class AgentLoop: ): from nanobot.config.schema import ExecToolConfig from nanobot.cron.service import CronService - self.bus = bus self.provider = provider self.workspace = workspace @@ -84,7 +83,7 @@ class AgentLoop: exec_config=self.exec_config, restrict_to_workspace=restrict_to_workspace, ) - + self._running = False self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None @@ -93,7 +92,7 @@ class AgentLoop: self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks self._consolidation_locks: dict[str, asyncio.Lock] = {} self._register_default_tools() - + def _register_default_tools(self) -> None: """Register the default set of tools.""" # File tools (workspace for relative paths, restrict if configured) @@ -102,39 +101,36 @@ class AgentLoop: self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - + # Shell tool - self.tools.register( - ExecTool( - working_dir=str(self.workspace), - timeout=self.exec_config.timeout, - restrict_to_workspace=self.restrict_to_workspace, - ) - ) - + self.tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + )) + # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - + # Message tool message_tool = MessageTool(send_callback=self.bus.publish_outbound) self.tools.register(message_tool) - + # Spawn tool (for subagents) spawn_tool = SpawnTool(manager=self.subagents) self.tools.register(spawn_tool) - + # Cron tool (for scheduling) if self.cron_service: self.tools.register(CronTool(self.cron_service)) - + async def _connect_mcp(self) -> None: """Connect to configured MCP servers (one-time, lazy).""" if self._mcp_connected or not self._mcp_servers: return self._mcp_connected = True from nanobot.agent.tools.mcp import connect_mcp_servers - self._mcp_stack = AsyncExitStack() await self._mcp_stack.__aenter__() await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) @@ -163,13 +159,11 @@ class AgentLoop: @staticmethod def _tool_hint(tool_calls: list) -> str: """Format tool calls as concise hint, e.g. 'web_search("query")'.""" - def _fmt(tc): val = next(iter(tc.arguments.values()), None) if tc.arguments else None if not isinstance(val, str): return tc.name return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' - return ", ".join(_fmt(tc) for tc in tool_calls) async def _run_agent_loop( @@ -217,15 +211,13 @@ class AgentLoop: "type": "function", "function": { "name": tc.name, - "arguments": json.dumps(tc.arguments, ensure_ascii=False), - }, + "arguments": json.dumps(tc.arguments, ensure_ascii=False) + } } for tc in response.tool_calls ] messages = self.context.add_assistant_message( - messages, - response.content, - tool_call_dicts, + messages, response.content, tool_call_dicts, reasoning_content=response.reasoning_content, ) @@ -243,13 +235,9 @@ class AgentLoop: # Give them one retry; don't forward the text to avoid duplicates. if not tools_used and not text_only_retried and final_content: text_only_retried = True - logger.debug( - "Interim text response (no tools used yet), retrying: {}", - final_content[:80], - ) + logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) messages = self.context.add_assistant_message( - messages, - response.content, + messages, response.content, reasoning_content=response.reasoning_content, ) final_content = None @@ -266,23 +254,24 @@ class AgentLoop: while self._running: try: - msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) + msg = await asyncio.wait_for( + self.bus.consume_inbound(), + timeout=1.0 + ) try: response = await self._process_message(msg) if response: await self.bus.publish_outbound(response) except Exception as e: logger.error("Error processing message: {}", e) - await self.bus.publish_outbound( - OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Sorry, I encountered an error: {str(e)}", - ) - ) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}" + )) except asyncio.TimeoutError: continue - + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: @@ -311,7 +300,7 @@ class AgentLoop: if lock.locked() or has_waiters: return self._consolidation_locks.pop(session_key, None) - + async def _process_message( self, msg: InboundMessage, @@ -320,30 +309,30 @@ class AgentLoop: ) -> OutboundMessage | None: """ Process a single inbound message. - + Args: msg: The inbound message to process. session_key: Override session key (used by process_direct). on_progress: Optional callback for intermediate output (defaults to bus publish). - + Returns: The response message, or None if no response needed. """ # System messages route back via chat_id ("channel:chat_id") if msg.channel == "system": return await self._process_system_message(msg) - + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) - + key = session_key or msg.session_key session = self.sessions.get_or_create(key) - + # Handle slash commands cmd = msg.content.strip().lower() if cmd == "/new": lock = self._get_consolidation_lock(session.key) - messages_to_archive = [] + messages_to_archive: list[dict[str, Any]] = [] try: async with lock: messages_to_archive = session.messages[session.last_consolidated :].copy() @@ -369,18 +358,12 @@ class AgentLoop: self.sessions.save(session) self.sessions.invalidate(session.key) self._prune_consolidation_lock(session.key, lock) - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="New session started. Memory consolidation in progress.", - ) + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="New session started. Memory consolidation in progress.") if cmd == "/help": - return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands", - ) - + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") + if len(session.messages) > self.memory_window and session.key not in self._consolidating: self._consolidating.add(session.key) lock = self._get_consolidation_lock(session.key) @@ -409,49 +392,42 @@ class AgentLoop: ) async def _bus_progress(content: str) -> None: - await self.bus.publish_outbound( - OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=content, - metadata=msg.metadata or {}, - ) - ) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, + metadata=msg.metadata or {}, + )) final_content, tools_used = await self._run_agent_loop( - initial_messages, - on_progress=on_progress or _bus_progress, + initial_messages, on_progress=on_progress or _bus_progress, ) if final_content is None: final_content = "I've completed processing but have no response to give." - + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - + session.add_message("user", msg.content) - session.add_message( - "assistant", final_content, tools_used=tools_used if tools_used else None - ) + session.add_message("assistant", final_content, + tools_used=tools_used if tools_used else None) self.sessions.save(session) - + return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, - metadata=msg.metadata - or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) + metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) - + async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: """ Process a system message (e.g., subagent announce). - + The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ logger.info("Processing system message from {}", msg.sender_id) - + # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: parts = msg.chat_id.split(":", 1) @@ -461,7 +437,7 @@ class AgentLoop: # Fallback origin_channel = "cli" origin_chat_id = msg.chat_id - + session_key = f"{origin_channel}:{origin_chat_id}" session = self.sessions.get_or_create(session_key) self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) @@ -475,15 +451,17 @@ class AgentLoop: if final_content is None: final_content = "Background task completed." - + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") session.add_message("assistant", final_content) self.sessions.save(session) - + return OutboundMessage( - channel=origin_channel, chat_id=origin_chat_id, content=final_content + channel=origin_channel, + chat_id=origin_chat_id, + content=final_content ) - + async def _consolidate_memory(self, session, archive_all: bool = False) -> bool: """Consolidate old messages into MEMORY.md + HISTORY.md. @@ -496,49 +474,29 @@ class AgentLoop: if archive_all: old_messages = session.messages keep_count = 0 - logger.info( - "Memory consolidation (archive_all): {} total messages archived", - len(session.messages), - ) + logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) else: keep_count = self.memory_window // 2 if len(session.messages) <= keep_count: - logger.debug( - "Session {}: No consolidation needed (messages={}, keep={})", - session.key, - len(session.messages), - keep_count, - ) + logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) return True messages_to_process = len(session.messages) - session.last_consolidated if messages_to_process <= 0: - logger.debug( - "Session {}: No new messages to consolidate (last_consolidated={}, total={})", - session.key, - session.last_consolidated, - len(session.messages), - ) + logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) return True - old_messages = session.messages[session.last_consolidated : -keep_count] + old_messages = session.messages[session.last_consolidated:-keep_count] if not old_messages: return True - logger.info( - "Memory consolidation started: {} total, {} new to consolidate, {} keep", - len(session.messages), - len(old_messages), - keep_count, - ) + logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) lines = [] for m in old_messages: if not m.get("content"): continue tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" - lines.append( - f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}" - ) + lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") conversation = "\n".join(lines) current_memory = memory.read_long_term() @@ -567,10 +525,7 @@ Respond with ONLY valid JSON, no markdown fences.""" try: response = await self.provider.chat( messages=[ - { - "role": "system", - "content": "You are a memory consolidation agent. Respond only with valid JSON.", - }, + {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."}, {"role": "user", "content": prompt}, ], model=self.model, @@ -583,10 +538,7 @@ Respond with ONLY valid JSON, no markdown fences.""" text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() result = json_repair.loads(text) if not isinstance(result, dict): - logger.warning( - "Memory consolidation: unexpected response type, skipping. Response: {}", - text[:200], - ) + logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200]) return False if entry := result.get("history_entry"): @@ -605,11 +557,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = 0 else: session.last_consolidated = len(session.messages) - keep_count - logger.info( - "Memory consolidation done: {} messages, last_consolidated={}", - len(session.messages), - session.last_consolidated, - ) + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) return True except Exception as e: logger.error("Memory consolidation failed: {}", e) @@ -625,21 +573,24 @@ Respond with ONLY valid JSON, no markdown fences.""" ) -> str: """ Process a message directly (for CLI or cron usage). - + Args: content: The message content. session_key: Session identifier (overrides channel:chat_id for session lookup). channel: Source channel (for tool context routing). chat_id: Source chat ID (for tool context routing). on_progress: Optional callback for intermediate output. - + Returns: The agent's response. """ await self._connect_mcp() - msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) - - response = await self._process_message( - msg, session_key=session_key, on_progress=on_progress + msg = InboundMessage( + channel=channel, + sender_id="user", + chat_id=chat_id, + content=content ) + + response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" From f19baa8fc40f5a2a66c07d4e02847280dfbd55da Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 10:01:38 -0300 Subject: [PATCH 122/415] fix: convert remaining f-string logger calls to loguru native format Follow-up to #864. Three f-string logger calls in base.py and dingtalk.py were missed in the original sweep. These can cause KeyError if interpolated values contain curly braces, since loguru interprets them as format placeholders. --- nanobot/channels/base.py | 5 +++-- nanobot/channels/dingtalk.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 30fcd1a..3a5a785 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -105,8 +105,9 @@ class BaseChannel(ABC): """ if not self.is_allowed(sender_id): logger.warning( - f"Access denied for sender {sender_id} on channel {self.name}. " - f"Add them to allowFrom list in config to grant access." + "Access denied for sender {} on channel {}. " + "Add them to allowFrom list in config to grant access.", + sender_id, self.name, ) return diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index b7263b3..09c7714 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -58,7 +58,8 @@ class NanobotDingTalkHandler(CallbackHandler): if not content: logger.warning( - f"Received empty or unsupported message type: {chatbot_msg.message_type}" + "Received empty or unsupported message type: {}", + chatbot_msg.message_type, ) return AckMessage.STATUS_OK, "OK" @@ -126,7 +127,8 @@ class DingTalkChannel(BaseChannel): self._http = httpx.AsyncClient() logger.info( - f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..." + "Initializing DingTalk Stream Client with Client ID: {}...", + self.config.client_id, ) credential = Credential(self.config.client_id, self.config.client_secret) self._client = DingTalkStreamClient(credential) From 4cbd8572504320f9ef54948db9d5fbbca7130e68 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Fri, 20 Feb 2026 10:09:04 -0300 Subject: [PATCH 123/415] fix: handle edge cases in message splitting and send failure - _split_message: return empty list for empty/None content instead of a list with one empty string (Discord rejects empty content) - _split_message: use pos <= 0 fallback to prevent empty chunks when content starts with a newline or space - _send_payload: return bool to indicate success/failure - send: abort remaining chunks when a chunk fails to send, preventing partial/corrupted message delivery --- nanobot/channels/discord.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index c073a05..5a1cf5c 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -22,6 +22,8 @@ MAX_MESSAGE_LEN = 2000 # Discord message character limit def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: """Split content into chunks within max_len, preferring line breaks.""" + if not content: + return [] if len(content) <= max_len: return [content] chunks: list[str] = [] @@ -31,9 +33,9 @@ def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: break cut = content[:max_len] pos = cut.rfind('\n') - if pos == -1: + if pos <= 0: pos = cut.rfind(' ') - if pos == -1: + if pos <= 0: pos = max_len chunks.append(content[:pos]) content = content[pos:].lstrip() @@ -104,6 +106,9 @@ class DiscordChannel(BaseChannel): try: chunks = _split_message(msg.content or "") + if not chunks: + return + for i, chunk in enumerate(chunks): payload: dict[str, Any] = {"content": chunk} @@ -112,14 +117,18 @@ class DiscordChannel(BaseChannel): payload["message_reference"] = {"message_id": msg.reply_to} payload["allowed_mentions"] = {"replied_user": False} - await self._send_payload(url, headers, payload) + if not await self._send_payload(url, headers, payload): + break # Abort remaining chunks on failure finally: await self._stop_typing(msg.chat_id) async def _send_payload( self, url: str, headers: dict[str, str], payload: dict[str, Any] - ) -> None: - """Send a single Discord API payload with retry on rate-limit.""" + ) -> bool: + """Send a single Discord API payload with retry on rate-limit. + + Returns True on success, False if all attempts failed. + """ for attempt in range(3): try: response = await self._http.post(url, headers=headers, json=payload) @@ -130,12 +139,13 @@ class DiscordChannel(BaseChannel): await asyncio.sleep(retry_after) continue response.raise_for_status() - return + return True except Exception as e: if attempt == 2: logger.error("Error sending Discord message: {}", e) else: await asyncio.sleep(1) + return False async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" From b286457c854fd3d60228b680ec0dbc8d29286303 Mon Sep 17 00:00:00 2001 From: tercerapersona Date: Fri, 20 Feb 2026 11:34:50 -0300 Subject: [PATCH 124/415] add Openrouter prompt caching via cache_control --- nanobot/providers/registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 445d977..ecf092f 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -100,6 +100,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://openrouter.ai/api/v1", strip_model_prefix=False, model_overrides=(), + supports_prompt_caching=True, ), # AiHubMix: global gateway, OpenAI-compatible interface. From cc04bc4dd1806f30e2f622a2628ddb40afc84fe7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:14:45 +0000 Subject: [PATCH 125/415] fix: check gateway's supports_prompt_caching instead of always returning False --- nanobot/providers/litellm_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index edeb5c6..58c9ac2 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -111,7 +111,7 @@ class LiteLLMProvider(LLMProvider): def _supports_cache_control(self, model: str) -> bool: """Return True when the provider supports cache_control on content blocks.""" if self._gateway is not None: - return False + return self._gateway.supports_prompt_caching spec = find_by_model(model) return spec is not None and spec.supports_prompt_caching From 6bcfbd9610687875c53ce65e64538e7200913591 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:19:18 +0000 Subject: [PATCH 126/415] style: remove redundant comments and use loguru native format --- nanobot/channels/slack.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 26a3966..4fc1f41 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -86,7 +86,6 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" thread_ts_param = thread_ts if use_thread else None - # Send text message if content is present if msg.content: await self._web_client.chat_postMessage( channel=msg.chat_id, @@ -94,7 +93,6 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts_param, ) - # Upload media files if present for media_path in msg.media or []: try: await self._web_client.files_upload_v2( @@ -103,7 +101,7 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts_param, ) except Exception as e: - logger.error(f"Failed to upload file {media_path}: {e}") + logger.error("Failed to upload file {}: {}", media_path, e) except Exception as e: logger.error("Error sending Slack message: {}", e) From b853222c8755bfb3405735b13bc2c1deae20d437 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:26:12 +0000 Subject: [PATCH 127/415] style: trim _send_payload docstring --- nanobot/channels/discord.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 5a1cf5c..1d2d7a6 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -125,10 +125,7 @@ class DiscordChannel(BaseChannel): async def _send_payload( self, url: str, headers: dict[str, str], payload: dict[str, Any] ) -> bool: - """Send a single Discord API payload with retry on rate-limit. - - Returns True on success, False if all attempts failed. - """ + """Send a single Discord API payload with retry on rate-limit. Returns True on success.""" for attempt in range(3): try: response = await self._http.post(url, headers=headers, json=payload) From d9cc144575a5186b9ae6be8ff520d981f1f12fe5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:42:24 +0000 Subject: [PATCH 128/415] style: remove redundant comment in list_sessions --- nanobot/session/manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 35f8d13..18e23b2 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -187,8 +187,6 @@ class SessionManager: if first_line: data = json.loads(first_line) if data.get("_type") == "metadata": - # Prefer the key stored in metadata; fall back to - # filename-based heuristic for legacy files. key = data.get("key") or path.stem.replace("_", ":", 1) sessions.append({ "key": key, From 132807a3fbbd851cfef2c21f469e32216aa18cb7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 15:55:30 +0000 Subject: [PATCH 129/415] refactor: simplify message tool turn tracking to a single boolean flag --- nanobot/agent/loop.py | 14 ++------------ nanobot/agent/tools/message.py | 11 ++++------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 646b658..51d0bc0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -383,19 +383,9 @@ class AgentLoop: tools_used=tools_used if tools_used else None) self.sessions.save(session) - suppress_final_reply = False if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool): - sent_targets = set(message_tool.get_turn_sends()) - suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets - - if suppress_final_reply: - logger.info( - "Skipping final auto-reply because message tool already sent to {}:{} in this turn", - msg.channel, - msg.chat_id, - ) - return None + if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: + return None return OutboundMessage( channel=msg.channel, diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 593bc44..40e76e3 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -20,24 +20,21 @@ class MessageTool(Tool): self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - self._turn_sends: list[tuple[str, str]] = [] + self._sent_in_turn: bool = False def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id self._default_message_id = message_id + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback def start_turn(self) -> None: """Reset per-turn send tracking.""" - self._turn_sends.clear() - - def get_turn_sends(self) -> list[tuple[str, str]]: - """Get (channel, chat_id) targets sent in the current turn.""" - return list(self._turn_sends) + self._sent_in_turn = False @property def name(self) -> str: @@ -104,7 +101,7 @@ class MessageTool(Tool): try: await self._send_callback(msg) - self._turn_sends.append((channel, chat_id)) + self._sent_in_turn = True media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: From 7279ff0167fbbdcd06f380b0c52e69371f95b73b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 16:45:21 +0000 Subject: [PATCH 130/415] refactor: route CLI interactive mode through message bus for subagent support --- nanobot/agent/loop.py | 9 ++++-- nanobot/cli/commands.py | 62 +++++++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5ed6e4..9f1e265 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -277,8 +277,9 @@ class AgentLoop: ) try: response = await self._process_message(msg) - if response: - await self.bus.publish_outbound(response) + await self.bus.publish_outbound(response or OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="", + )) except Exception as e: logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( @@ -376,9 +377,11 @@ class AgentLoop: ) async def _bus_progress(content: str) -> None: + meta = dict(msg.metadata or {}) + meta["_progress"] = True await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, - metadata=msg.metadata or {}, + metadata=meta, )) final_content, tools_used = await self._run_agent_loop( diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a135349..6155463 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -498,27 +498,58 @@ def agent( console.print(f" [dim]↳ {content}[/dim]") if message: - # Single message mode + # Single message mode — direct call, no bus needed async def run_once(): with _thinking_ctx(): response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() - + asyncio.run(run_once()) else: - # Interactive mode + # Interactive mode — route through bus like other channels + from nanobot.bus.events import InboundMessage _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") + if ":" in session_id: + cli_channel, cli_chat_id = session_id.split(":", 1) + else: + cli_channel, cli_chat_id = "cli", session_id + def _exit_on_sigint(signum, frame): _restore_terminal() console.print("\nGoodbye!") os._exit(0) signal.signal(signal.SIGINT, _exit_on_sigint) - + async def run_interactive(): + bus_task = asyncio.create_task(agent_loop.run()) + turn_done = asyncio.Event() + turn_done.set() + turn_response: list[str] = [] + + async def _consume_outbound(): + while True: + try: + msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + if msg.metadata.get("_progress"): + console.print(f" [dim]↳ {msg.content}[/dim]") + elif not turn_done.is_set(): + if msg.content: + turn_response.append(msg.content) + turn_done.set() + elif msg.content: + console.print() + _print_agent_response(msg.content, render_markdown=markdown) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + + outbound_task = asyncio.create_task(_consume_outbound()) + try: while True: try: @@ -532,10 +563,22 @@ def agent( _restore_terminal() console.print("\nGoodbye!") break - + + turn_done.clear() + turn_response.clear() + + await bus.publish_inbound(InboundMessage( + channel=cli_channel, + sender_id="user", + chat_id=cli_chat_id, + content=user_input, + )) + with _thinking_ctx(): - response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress) - _print_agent_response(response, render_markdown=markdown) + await turn_done.wait() + + if turn_response: + _print_agent_response(turn_response[0], render_markdown=markdown) except KeyboardInterrupt: _restore_terminal() console.print("\nGoodbye!") @@ -545,8 +588,11 @@ def agent( console.print("\nGoodbye!") break finally: + agent_loop.stop() + outbound_task.cancel() + await asyncio.gather(bus_task, outbound_task, return_exceptions=True) await agent_loop.close_mcp() - + asyncio.run(run_interactive()) From d3ddeb30671e7a5b33e5758bf6e33191662bc1e3 Mon Sep 17 00:00:00 2001 From: djmaze <7229+djmaze@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:01:15 +0100 Subject: [PATCH 131/415] fix: activate E2E and accept room invites in Matrix channels --- nanobot/channels/matrix.py | 48 ++++++++++++++++++++++++-------------- nanobot/config/schema.py | 12 ++++++++++ pyproject.toml | 1 + 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 0ea4ae1..f3b8468 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,10 +1,11 @@ import asyncio from typing import Any -from nio import AsyncClient, MatrixRoom, RoomMessageText +from nio import AsyncClient, AsyncClientConfig, InviteEvent, MatrixRoom, RoomMessageText -from nanobot.channels.base import BaseChannel from nanobot.bus.events import OutboundMessage +from nanobot.channels.base import BaseChannel +from nanobot.config.loader import get_data_dir class MatrixChannel(BaseChannel): @@ -22,18 +23,28 @@ class MatrixChannel(BaseChannel): async def start(self) -> None: self._running = True - self.client = AsyncClient( - homeserver=self.config.homeserver, - user=self.config.user_id, - ) - - self.client.access_token = self.config.access_token + store_path = get_data_dir() / "matrix-store" + store_path.mkdir(parents=True, exist_ok=True) - self.client.add_event_callback( - self._on_message, - RoomMessageText + self.client = AsyncClient( + homeserver=self.config.homeserver, + user=self.config.user_id, + store_path=store_path, # Where tokens are saved + config=AsyncClientConfig( + store_sync_tokens=True, # Auto-persists next_batch tokens + encryption_enabled=True, + ), ) + self.client.user_id = self.config.user_id + self.client.access_token = self.config.access_token + self.client.device_id = self.config.device_id + + self.client.add_event_callback(self._on_message, RoomMessageText) + self.client.add_event_callback(self._on_room_invite, InviteEvent) + + self.client.load_store() + self._sync_task = asyncio.create_task(self._sync_loop()) async def stop(self) -> None: @@ -51,22 +62,23 @@ class MatrixChannel(BaseChannel): room_id=msg.chat_id, message_type="m.room.message", content={"msgtype": "m.text", "body": msg.content}, + ignore_unverified_devices=True, ) async def _sync_loop(self) -> None: while self._running: try: - await self.client.sync(timeout=30000) + await self.client.sync_forever(timeout=30000, full_state=True) except asyncio.CancelledError: break except Exception: await asyncio.sleep(2) - async def _on_message( - self, - room: MatrixRoom, - event: RoomMessageText - ) -> None: + async def _on_room_invite(self, room: MatrixRoom, event: RoomMessageText) -> None: + if event.sender in self.config.allow_from: + await self.client.join(room.room_id) + + async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages if event.sender == self.config.user_id: return @@ -76,4 +88,4 @@ class MatrixChannel(BaseChannel): chat_id=room.room_id, content=event.body, metadata={"room": room.display_name}, - ) \ No newline at end of file + ) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6a1257e..8413f0a 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -60,6 +60,17 @@ class DiscordConfig(Base): intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT +class MatrixConfig(Base): + """Matrix (Element) channel configuration.""" + + enabled: bool = False + homeserver: str = "https://matrix.org" + access_token: str = "" + user_id: str = "" # @bot:matrix.org + device_id: str = "" + allow_from: list[str] = Field(default_factory=list) + + class EmailConfig(Base): """Email channel configuration (IMAP inbound + SMTP outbound).""" @@ -176,6 +187,7 @@ class ChannelsConfig(Base): email: EmailConfig = Field(default_factory=EmailConfig) slack: SlackConfig = Field(default_factory=SlackConfig) qq: QQConfig = Field(default_factory=QQConfig) + matrix: MatrixConfig = Field(default_factory=MatrixConfig) class AgentDefaults(Base): diff --git a/pyproject.toml b/pyproject.toml index 64a884d..18bbe70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "matrix-nio[e2e]>=0.25.2", ] [project.optional-dependencies] From c926569033bce8f52d49d54bf23c0d77439c1936 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 09:05:20 +0100 Subject: [PATCH 132/415] fix(matrix): guard store load without device id and allow invites by default --- nanobot/channels/matrix.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index f3b8468..340ec2a 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -43,7 +43,8 @@ class MatrixChannel(BaseChannel): self.client.add_event_callback(self._on_message, RoomMessageText) self.client.add_event_callback(self._on_room_invite, InviteEvent) - self.client.load_store() + if self.config.device_id: + self.client.load_store() self._sync_task = asyncio.create_task(self._sync_loop()) @@ -74,9 +75,12 @@ class MatrixChannel(BaseChannel): except Exception: await asyncio.sleep(2) - async def _on_room_invite(self, room: MatrixRoom, event: RoomMessageText) -> None: - if event.sender in self.config.allow_from: - await self.client.join(room.room_id) + async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: + allow_from = self.config.allow_from or [] + if allow_from and event.sender not in allow_from: + return + + await self.client.join(room.room_id) async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages From 988b75624c872c5351d92c59ec9be35284007c75 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 09:22:32 +0100 Subject: [PATCH 133/415] test(matrix): add matrix channel behavior test --- tests/test_matrix_channel.py | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/test_matrix_channel.py diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py new file mode 100644 index 0000000..bc39097 --- /dev/null +++ b/tests/test_matrix_channel.py @@ -0,0 +1,113 @@ +from types import SimpleNamespace + +import pytest + +from nanobot.bus.queue import MessageBus +from nanobot.channels.matrix import MatrixChannel +from nanobot.config.schema import MatrixConfig + + +class _DummyTask: + def __init__(self) -> None: + self.cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + +class _FakeAsyncClient: + def __init__(self, homeserver, user, store_path, config) -> None: + self.homeserver = homeserver + self.user = user + self.store_path = store_path + self.config = config + self.user_id: str | None = None + self.access_token: str | None = None + self.device_id: str | None = None + self.load_store_called = False + self.join_calls: list[str] = [] + self.callbacks: list[tuple[object, object]] = [] + + def add_event_callback(self, callback, event_type) -> None: + self.callbacks.append((callback, event_type)) + + def load_store(self) -> None: + self.load_store_called = True + + async def join(self, room_id: str) -> None: + self.join_calls.append(room_id) + + async def close(self) -> None: + return None + + +def _make_config(**kwargs) -> MatrixConfig: + return MatrixConfig( + enabled=True, + homeserver="https://matrix.org", + access_token="token", + user_id="@bot:matrix.org", + **kwargs, + ) + + +@pytest.mark.asyncio +async def test_start_skips_load_store_when_device_id_missing( + monkeypatch, tmp_path +) -> None: + clients: list[_FakeAsyncClient] = [] + + def _fake_client(*args, **kwargs) -> _FakeAsyncClient: + client = _FakeAsyncClient(*args, **kwargs) + clients.append(client) + return client + + def _fake_create_task(coro): + coro.close() + return _DummyTask() + + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr( + "nanobot.channels.matrix.AsyncClientConfig", + lambda **kwargs: SimpleNamespace(**kwargs), + ) + monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client) + monkeypatch.setattr( + "nanobot.channels.matrix.asyncio.create_task", _fake_create_task + ) + + channel = MatrixChannel(_make_config(device_id=""), MessageBus()) + await channel.start() + + assert len(clients) == 1 + assert clients[0].load_store_called is False + + await channel.stop() + + +@pytest.mark.asyncio +async def test_room_invite_joins_when_allow_list_is_empty() -> None: + channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org") + event = SimpleNamespace(sender="@alice:matrix.org") + + await channel._on_room_invite(room, event) + + assert client.join_calls == ["!room:matrix.org"] + + +@pytest.mark.asyncio +async def test_room_invite_respects_allow_list_when_configured() -> None: + channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org") + event = SimpleNamespace(sender="@alice:matrix.org") + + await channel._on_room_invite(room, event) + + assert client.join_calls == [] From 9a31571b6dd9e5aec13df46aebdd13d65ccb99ad Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 16:49:40 +0000 Subject: [PATCH 134/415] fix: don't append interim assistant message before retry to avoid prefill errors --- nanobot/agent/loop.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9f1e265..4850f9c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -253,10 +253,6 @@ class AgentLoop: if not tools_used and not text_only_retried and final_content: text_only_retried = True logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) - messages = self.context.add_assistant_message( - messages, response.content, - reasoning_content=response.reasoning_content, - ) final_content = None continue break From 7c33d3cbe241012eff5f5337912a2e994f0ceba1 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 12:20:55 +0100 Subject: [PATCH 135/415] feat(matrix): add configurable graceful sync shutdown --- nanobot/channels/matrix.py | 21 ++++++++++++++++++++- nanobot/config/schema.py | 22 +++++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 340ec2a..7e84626 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -49,9 +49,28 @@ class MatrixChannel(BaseChannel): self._sync_task = asyncio.create_task(self._sync_loop()) async def stop(self) -> None: + """Stop the Matrix channel with graceful sync shutdown.""" self._running = False + + if self.client: + # Request sync_forever loop to exit cleanly. + self.client.stop_sync_forever() + if self._sync_task: - self._sync_task.cancel() + try: + await asyncio.wait_for( + asyncio.shield(self._sync_task), + timeout=self.config.sync_stop_grace_seconds, + ) + except asyncio.TimeoutError: + self._sync_task.cancel() + try: + await self._sync_task + except asyncio.CancelledError: + pass + except asyncio.CancelledError: + pass + if self.client: await self.client.close() diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 8413f0a..f8d251b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -27,7 +27,9 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) class FeishuConfig(Base): @@ -68,6 +70,8 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" + # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + sync_stop_grace_seconds: int = 2 allow_from: list[str] = Field(default_factory=list) @@ -95,7 +99,9 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + auto_reply_enabled: bool = ( + True # If false, inbound email is read but no automatic reply is sent + ) poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -172,7 +178,9 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + allow_from: list[str] = Field( + default_factory=list + ) # Allowed user openids (empty = public access) class ChannelsConfig(Base): @@ -231,7 +239,9 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway + siliconflow: ProviderConfig = Field( + default_factory=ProviderConfig + ) # SiliconFlow (硅基流动) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -294,7 +304,9 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + def _match_provider( + self, model: str | None = None + ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS From 9d8539322657f35f22c84036536f088a995d4b4c Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 12:24:36 +0100 Subject: [PATCH 136/415] feat(matrix): add startup warnings and response error logging --- nanobot/channels/matrix.py | 71 +++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 7e84626..89e7616 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,7 +1,17 @@ import asyncio from typing import Any -from nio import AsyncClient, AsyncClientConfig, InviteEvent, MatrixRoom, RoomMessageText +from loguru import logger +from nio import ( + AsyncClient, + AsyncClientConfig, + InviteEvent, + JoinError, + MatrixRoom, + RoomMessageText, + RoomSendError, + SyncError, +) from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel @@ -21,6 +31,7 @@ class MatrixChannel(BaseChannel): self._sync_task: asyncio.Task | None = None async def start(self) -> None: + """Start Matrix client and begin sync loop.""" self._running = True store_path = get_data_dir() / "matrix-store" @@ -40,11 +51,24 @@ class MatrixChannel(BaseChannel): self.client.access_token = self.config.access_token self.client.device_id = self.config.device_id - self.client.add_event_callback(self._on_message, RoomMessageText) - self.client.add_event_callback(self._on_room_invite, InviteEvent) + self._register_event_callbacks() + self._register_response_callbacks() if self.config.device_id: - self.client.load_store() + try: + self.client.load_store() + except Exception as e: + logger.warning( + "Matrix store load failed ({}: {}); sync token restore is disabled and " + "restart may replay recent messages.", + type(e).__name__, + str(e), + ) + else: + logger.warning( + "Matrix device_id is empty; sync token restore is disabled and restart may " + "replay recent messages." + ) self._sync_task = asyncio.create_task(self._sync_loop()) @@ -85,9 +109,48 @@ class MatrixChannel(BaseChannel): ignore_unverified_devices=True, ) + def _register_event_callbacks(self) -> None: + """Register Matrix event callbacks used by this channel.""" + self.client.add_event_callback(self._on_message, RoomMessageText) + self.client.add_event_callback(self._on_room_invite, InviteEvent) + + def _register_response_callbacks(self) -> None: + """Register response callbacks for operational error observability.""" + self.client.add_response_callback(self._on_sync_error, SyncError) + self.client.add_response_callback(self._on_join_error, JoinError) + self.client.add_response_callback(self._on_send_error, RoomSendError) + + @staticmethod + def _is_auth_error(errcode: str | None) -> bool: + """Return True if the Matrix errcode indicates auth/token problems.""" + return errcode in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"} + + async def _on_sync_error(self, response: SyncError) -> None: + """Log sync errors with clear severity.""" + if self._is_auth_error(response.status_code) or response.soft_logout: + logger.error("Matrix sync failed: {}", response) + return + logger.warning("Matrix sync warning: {}", response) + + async def _on_join_error(self, response: JoinError) -> None: + """Log room-join errors from invite handling.""" + if self._is_auth_error(response.status_code): + logger.error("Matrix join failed: {}", response) + return + logger.warning("Matrix join warning: {}", response) + + async def _on_send_error(self, response: RoomSendError) -> None: + """Log message send failures.""" + if self._is_auth_error(response.status_code): + logger.error("Matrix send failed: {}", response) + return + logger.warning("Matrix send warning: {}", response) + async def _sync_loop(self) -> None: while self._running: try: + # full_state applies only to the first sync inside sync_forever and helps + # rebuild room state when restoring from stored sync tokens. await self.client.sync_forever(timeout=30000, full_state=True) except asyncio.CancelledError: break From b721f9f37dc295d30d45e81746335ae34b54f5e1 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 12:24:54 +0100 Subject: [PATCH 137/415] test(matrix): cover response callbacks and graceful shutdown --- tests/test_matrix_channel.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index bc39097..f86543b 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -14,6 +14,12 @@ class _DummyTask: def cancel(self) -> None: self.cancelled = True + def __await__(self): + async def _done(): + return None + + return _done().__await__() + class _FakeAsyncClient: def __init__(self, homeserver, user, store_path, config) -> None: @@ -25,15 +31,23 @@ class _FakeAsyncClient: self.access_token: str | None = None self.device_id: str | None = None self.load_store_called = False + self.stop_sync_forever_called = False self.join_calls: list[str] = [] self.callbacks: list[tuple[object, object]] = [] + self.response_callbacks: list[tuple[object, object]] = [] def add_event_callback(self, callback, event_type) -> None: self.callbacks.append((callback, event_type)) + def add_response_callback(self, callback, response_type) -> None: + self.response_callbacks.append((callback, response_type)) + def load_store(self) -> None: self.load_store_called = True + def stop_sync_forever(self) -> None: + self.stop_sync_forever_called = True + async def join(self, room_id: str) -> None: self.join_calls.append(room_id) @@ -81,10 +95,28 @@ async def test_start_skips_load_store_when_device_id_missing( assert len(clients) == 1 assert clients[0].load_store_called is False + assert len(clients[0].response_callbacks) == 3 await channel.stop() +@pytest.mark.asyncio +async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: + channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + task = _DummyTask() + + channel.client = client + channel._sync_task = task + channel._running = True + + await channel.stop() + + assert channel._running is False + assert client.stop_sync_forever_called is True + assert task.cancelled is False + + @pytest.mark.asyncio async def test_room_invite_joins_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) From b294a682a86631452e6f15e809e7933d7007bb03 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:03:13 +0100 Subject: [PATCH 138/415] chore(matrix): route matrix-nio logs through loguru --- nanobot/channels/matrix.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 89e7616..d73a849 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,4 +1,5 @@ import asyncio +import logging from typing import Any from loguru import logger @@ -18,6 +19,34 @@ from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir +class _NioLoguruHandler(logging.Handler): + """Route stdlib logging records from matrix-nio into Loguru output.""" + + def emit(self, record: logging.LogRecord) -> None: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame = logging.currentframe() + depth = 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def _configure_nio_logging_bridge() -> None: + """Ensure matrix-nio logs are emitted through the project's Loguru format.""" + nio_logger = logging.getLogger("nio") + if any(isinstance(handler, _NioLoguruHandler) for handler in nio_logger.handlers): + return + + nio_logger.handlers = [_NioLoguruHandler()] + nio_logger.propagate = False + + class MatrixChannel(BaseChannel): """ Matrix (Element) channel using long-polling sync. @@ -33,6 +62,7 @@ class MatrixChannel(BaseChannel): async def start(self) -> None: """Start Matrix client and begin sync loop.""" self._running = True + _configure_nio_logging_bridge() store_path = get_data_dir() / "matrix-store" store_path.mkdir(parents=True, exist_ok=True) From ffac42f9e5d1d3ebac690f29f0354b06acb961c8 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:12:45 +0100 Subject: [PATCH 139/415] refactor(matrix): replace logging depth magic number --- nanobot/channels/matrix.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index d73a849..63a07e0 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -18,6 +18,8 @@ from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir +LOGGING_STACK_BASE_DEPTH = 2 + class _NioLoguruHandler(logging.Handler): """Route stdlib logging records from matrix-nio into Loguru output.""" @@ -29,7 +31,8 @@ class _NioLoguruHandler(logging.Handler): level = record.levelno frame = logging.currentframe() - depth = 2 + # Skip logging internals plus this handler frame when forwarding to Loguru. + depth = LOGGING_STACK_BASE_DEPTH while frame and frame.f_code.co_filename == logging.__file__: frame = frame.f_back depth += 1 From 45267b0730b9a2b07b4b0ad1676fa9ba85f88898 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:25:12 +0100 Subject: [PATCH 140/415] feat(matrix): show typing while processing messages --- nanobot/channels/matrix.py | 65 +++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 63a07e0..60a86fc 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -11,6 +11,7 @@ from nio import ( MatrixRoom, RoomMessageText, RoomSendError, + RoomTypingError, SyncError, ) @@ -19,6 +20,7 @@ from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir LOGGING_STACK_BASE_DEPTH = 2 +TYPING_NOTICE_TIMEOUT_MS = 30_000 class _NioLoguruHandler(logging.Handler): @@ -135,12 +137,15 @@ class MatrixChannel(BaseChannel): if not self.client: return - await self.client.room_send( - room_id=msg.chat_id, - message_type="m.room.message", - content={"msgtype": "m.text", "body": msg.content}, - ignore_unverified_devices=True, - ) + try: + await self.client.room_send( + room_id=msg.chat_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": msg.content}, + ignore_unverified_devices=True, + ) + finally: + await self._set_typing(msg.chat_id, False) def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" @@ -179,6 +184,28 @@ class MatrixChannel(BaseChannel): return logger.warning("Matrix send warning: {}", response) + async def _set_typing(self, room_id: str, typing: bool) -> None: + """Best-effort typing indicator update that never blocks message flow.""" + if not self.client: + return + + try: + response = await self.client.room_typing( + room_id=room_id, + typing_state=typing, + timeout=TYPING_NOTICE_TIMEOUT_MS, + ) + if isinstance(response, RoomTypingError): + logger.debug("Matrix typing update failed for room {}: {}", room_id, response) + except Exception as e: + logger.debug( + "Matrix typing update failed for room {} (typing={}): {}: {}", + room_id, + typing, + type(e).__name__, + str(e), + ) + async def _sync_loop(self) -> None: while self._running: try: @@ -202,9 +229,23 @@ class MatrixChannel(BaseChannel): if event.sender == self.config.user_id: return - await self._handle_message( - sender_id=event.sender, - chat_id=room.room_id, - content=event.body, - metadata={"room": room.display_name}, - ) + if not self.is_allowed(event.sender): + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content=event.body, + metadata={"room": room.display_name}, + ) + return + + await self._set_typing(room.room_id, True) + try: + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content=event.body, + metadata={"room": room.display_name}, + ) + except Exception: + await self._set_typing(room.room_id, False) + raise From 840ef7363f3c51d1d9746abdbe82989a41f4193d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 14:25:33 +0100 Subject: [PATCH 141/415] test(matrix): cover typing indicator lifecycle --- tests/test_matrix_channel.py | 116 ++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index f86543b..b2accbb 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -2,8 +2,9 @@ from types import SimpleNamespace import pytest +from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.matrix import MatrixChannel +from nanobot.channels.matrix import TYPING_NOTICE_TIMEOUT_MS, MatrixChannel from nanobot.config.schema import MatrixConfig @@ -35,6 +36,10 @@ class _FakeAsyncClient: self.join_calls: list[str] = [] self.callbacks: list[tuple[object, object]] = [] self.response_callbacks: list[tuple[object, object]] = [] + self.room_send_calls: list[dict[str, object]] = [] + self.typing_calls: list[tuple[str, bool, int]] = [] + self.raise_on_send = False + self.raise_on_typing = False def add_event_callback(self, callback, event_type) -> None: self.callbacks.append((callback, event_type)) @@ -51,6 +56,34 @@ class _FakeAsyncClient: async def join(self, room_id: str) -> None: self.join_calls.append(room_id) + async def room_send( + self, + room_id: str, + message_type: str, + content: dict[str, object], + ignore_unverified_devices: bool, + ) -> None: + self.room_send_calls.append( + { + "room_id": room_id, + "message_type": message_type, + "content": content, + "ignore_unverified_devices": ignore_unverified_devices, + } + ) + if self.raise_on_send: + raise RuntimeError("send failed") + + async def room_typing( + self, + room_id: str, + typing_state: bool = True, + timeout: int = 30_000, + ) -> None: + self.typing_calls.append((room_id, typing_state, timeout)) + if self.raise_on_typing: + raise RuntimeError("typing failed") + async def close(self) -> None: return None @@ -143,3 +176,84 @@ async def test_room_invite_respects_allow_list_when_configured() -> None: await channel._on_room_invite(room, event) assert client.join_calls == [] + + +@pytest.mark.asyncio +async def test_on_message_sets_typing_for_allowed_sender() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[str] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs["sender_id"]) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello") + + await channel._on_message(room, event) + + assert handled == ["@alice:matrix.org"] + assert client.typing_calls == [ + ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS), + ] + + +@pytest.mark.asyncio +async def test_on_message_skips_typing_for_self_message() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") + event = SimpleNamespace(sender="@bot:matrix.org", body="Hello") + + await channel._on_message(room, event) + + assert client.typing_calls == [] + + +@pytest.mark.asyncio +async def test_on_message_skips_typing_for_denied_sender() -> None: + channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello") + + await channel._on_message(room, event) + + assert client.typing_calls == [] + + +@pytest.mark.asyncio +async def test_send_clears_typing_after_send() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") + ) + + assert len(client.room_send_calls) == 1 + assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) + + +@pytest.mark.asyncio +async def test_send_clears_typing_when_send_fails() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.raise_on_send = True + channel.client = client + + with pytest.raises(RuntimeError, match="send failed"): + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") + ) + + assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) From e716c9caaccde37e74e2ae4f22f1e3086b4b9c0d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:02:03 +0100 Subject: [PATCH 142/415] feat(matrix): send markdown as formatted html messages --- nanobot/channels/matrix.py | 44 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 60a86fc..0cf354c 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -3,6 +3,7 @@ import logging from typing import Any from loguru import logger +from mistune import create_markdown from nio import ( AsyncClient, AsyncClientConfig, @@ -21,6 +22,47 @@ from nanobot.config.loader import get_data_dir LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 +MATRIX_HTML_FORMAT = "org.matrix.custom.html" + +MATRIX_MARKDOWN = create_markdown( + escape=True, + plugins=["table", "strikethrough", "task_lists"], +) + + +def _render_markdown_html(text: str) -> str | None: + """Render markdown to HTML for Matrix formatted messages.""" + try: + formatted = MATRIX_MARKDOWN(text).strip() + except Exception as e: + logger.debug( + "Matrix markdown rendering failed ({}): {}", + type(e).__name__, + str(e), + ) + return None + + if not formatted: + return None + + # Skip formatted_body for plain output (

...

) to keep payload minimal. + stripped = formatted.strip() + if stripped.startswith("

") and stripped.endswith("

") and "

" not in stripped[3:-4]: + return None + + return formatted + + +def _build_matrix_text_content(text: str) -> dict[str, str]: + """Build Matrix m.text payload with plaintext fallback and optional HTML.""" + content: dict[str, str] = {"msgtype": "m.text", "body": text} + formatted_html = _render_markdown_html(text) + if not formatted_html: + return content + + content["format"] = MATRIX_HTML_FORMAT + content["formatted_body"] = formatted_html + return content class _NioLoguruHandler(logging.Handler): @@ -141,7 +183,7 @@ class MatrixChannel(BaseChannel): await self.client.room_send( room_id=msg.chat_id, message_type="m.room.message", - content={"msgtype": "m.text", "body": msg.content}, + content=_build_matrix_text_content(msg.content), ignore_unverified_devices=True, ) finally: diff --git a/pyproject.toml b/pyproject.toml index 18bbe70..82b37a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0", ] [project.optional-dependencies] From 3200135f4b849a9326087de6e151e5e0dd086d9a Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:02:29 +0100 Subject: [PATCH 143/415] test(matrix): cover formatted body and markdown fallback --- tests/test_matrix_channel.py | 61 +++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index b2accbb..e3bea9e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -2,9 +2,14 @@ from types import SimpleNamespace import pytest +import nanobot.channels.matrix as matrix_module from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.matrix import TYPING_NOTICE_TIMEOUT_MS, MatrixChannel +from nanobot.channels.matrix import ( + MATRIX_HTML_FORMAT, + TYPING_NOTICE_TIMEOUT_MS, + MatrixChannel, +) from nanobot.config.schema import MatrixConfig @@ -241,6 +246,7 @@ async def test_send_clears_typing_after_send() -> None: ) assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": "Hi"} assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @@ -257,3 +263,56 @@ async def test_send_clears_typing_when_send_fails() -> None: ) assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) + + +@pytest.mark.asyncio +async def test_send_adds_formatted_body_for_markdown() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + markdown_text = "# Headline\n\n- [x] done\n\n| A | B |\n| - | - |\n| 1 | 2 |" + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) + ) + + content = client.room_send_calls[0]["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == markdown_text + assert content["format"] == MATRIX_HTML_FORMAT + assert "

Headline

" in str(content["formatted_body"]) + assert "" in str(content["formatted_body"]) + assert "task-list-item-checkbox" in str(content["formatted_body"]) + + +@pytest.mark.asyncio +async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + def _raise(text: str) -> str: + raise RuntimeError("boom") + + monkeypatch.setattr(matrix_module, "MATRIX_MARKDOWN", _raise) + markdown_text = "# Headline" + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) + ) + + content = client.room_send_calls[0]["content"] + assert content == {"msgtype": "m.text", "body": markdown_text} + + +@pytest.mark.asyncio +async def test_send_keeps_plaintext_only_for_plain_text() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + text = "just a normal sentence without markdown markers" + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=text) + ) + + assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": text} From fa2049fc602865868f3e5b06fea0d32d02aeeb4d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:30:39 +0100 Subject: [PATCH 144/415] feat(matrix): add group policy and strict mention gating --- nanobot/channels/matrix.py | 53 ++++++++++++++++++++++++++++++++------ nanobot/config/schema.py | 5 ++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 0cf354c..fbe511b 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -266,18 +266,55 @@ class MatrixChannel(BaseChannel): await self.client.join(room.room_id) + def _is_direct_room(self, room: MatrixRoom) -> bool: + """Return True if the room behaves like a DM (2 or fewer members).""" + member_count = getattr(room, "member_count", None) + return isinstance(member_count, int) and member_count <= 2 + + def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessageText) -> bool: + """Resolve mentions strictly from Matrix-native m.mentions payload.""" + source = getattr(event, "source", None) + if not isinstance(source, dict): + return False + + content = source.get("content") + if not isinstance(content, dict): + return False + + mentions = content.get("m.mentions") + if not isinstance(mentions, dict): + return False + + user_ids = mentions.get("user_ids") + if isinstance(user_ids, list) and self.config.user_id in user_ids: + return True + + return bool(self.config.allow_room_mentions and mentions.get("room") is True) + + def _should_process_message(self, room: MatrixRoom, event: RoomMessageText) -> bool: + """Apply sender and room policy checks before processing Matrix messages.""" + if not self.is_allowed(event.sender): + return False + + if self._is_direct_room(room): + return True + + policy = self.config.group_policy + if policy == "open": + return True + if policy == "allowlist": + return room.room_id in (self.config.group_allow_from or []) + if policy == "mention": + return self._is_bot_mentioned_from_mx_mentions(event) + + return False + async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages if event.sender == self.config.user_id: return - if not self.is_allowed(event.sender): - await self._handle_message( - sender_id=event.sender, - chat_id=room.room_id, - content=event.body, - metadata={"room": room.display_name}, - ) + if not self._should_process_message(room, event): return await self._set_typing(room.room_id, True) @@ -286,7 +323,7 @@ class MatrixChannel(BaseChannel): sender_id=event.sender, chat_id=room.room_id, content=event.body, - metadata={"room": room.display_name}, + metadata={"room": getattr(room, "display_name", room.room_id)}, ) except Exception: await self._set_typing(room.room_id, False) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f8d251b..d442104 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,6 +1,8 @@ """Configuration schema using Pydantic.""" from pathlib import Path +from typing import Literal + from pydantic import BaseModel, Field, ConfigDict from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings @@ -73,6 +75,9 @@ class MatrixConfig(Base): # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 allow_from: list[str] = Field(default_factory=list) + group_policy: Literal["open", "mention", "allowlist"] = "open" + group_allow_from: list[str] = Field(default_factory=list) + allow_room_mentions: bool = False class EmailConfig(Base): From cc5cfe68477ce793b8f121cfe3de000b67530d8e Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:30:54 +0100 Subject: [PATCH 145/415] test(matrix): cover mention policy and sender filtering --- tests/test_matrix_channel.py | 145 ++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 3 deletions(-) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index e3bea9e..cc834c3 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -197,7 +197,7 @@ async def test_on_message_sets_typing_for_allowed_sender() -> None: channel._handle_message = _fake_handle_message # type: ignore[method-assign] room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") - event = SimpleNamespace(sender="@alice:matrix.org", body="Hello") + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={}) await channel._on_message(room, event) @@ -214,7 +214,7 @@ async def test_on_message_skips_typing_for_self_message() -> None: channel.client = client room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") - event = SimpleNamespace(sender="@bot:matrix.org", body="Hello") + event = SimpleNamespace(sender="@bot:matrix.org", body="Hello", source={}) await channel._on_message(room, event) @@ -227,14 +227,153 @@ async def test_on_message_skips_typing_for_denied_sender() -> None: client = _FakeAsyncClient("", "", "", None) channel.client = client + handled: list[str] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs["sender_id"]) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room") - event = SimpleNamespace(sender="@alice:matrix.org", body="Hello") + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={}) await channel._on_message(room, event) + assert handled == [] assert client.typing_calls == [] +@pytest.mark.asyncio +async def test_on_message_mention_policy_requires_mx_mentions() -> None: + channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[str] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs["sender_id"]) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}}) + + await channel._on_message(room, event) + + assert handled == [] + assert client.typing_calls == [] + + +@pytest.mark.asyncio +async def test_on_message_mention_policy_accepts_bot_user_mentions() -> None: + channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[str] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs["sender_id"]) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="Hello", + source={"content": {"m.mentions": {"user_ids": ["@bot:matrix.org"]}}}, + ) + + await channel._on_message(room, event) + + assert handled == ["@alice:matrix.org"] + assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] + + +@pytest.mark.asyncio +async def test_on_message_mention_policy_allows_direct_room_without_mentions() -> None: + channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[str] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs["sender_id"]) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!dm:matrix.org", display_name="DM", member_count=2) + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}}) + + await channel._on_message(room, event) + + assert handled == ["@alice:matrix.org"] + assert client.typing_calls == [("!dm:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] + + +@pytest.mark.asyncio +async def test_on_message_allowlist_policy_requires_room_id() -> None: + channel = MatrixChannel( + _make_config(group_policy="allowlist", group_allow_from=["!allowed:matrix.org"]), + MessageBus(), + ) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[str] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs["chat_id"]) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + denied_room = SimpleNamespace(room_id="!denied:matrix.org", display_name="Denied", member_count=3) + event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}}) + await channel._on_message(denied_room, event) + + allowed_room = SimpleNamespace( + room_id="!allowed:matrix.org", + display_name="Allowed", + member_count=3, + ) + await channel._on_message(allowed_room, event) + + assert handled == ["!allowed:matrix.org"] + assert client.typing_calls == [("!allowed:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] + + +@pytest.mark.asyncio +async def test_on_message_room_mention_requires_opt_in() -> None: + channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[str] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs["sender_id"]) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) + room_mention_event = SimpleNamespace( + sender="@alice:matrix.org", + body="Hello everyone", + source={"content": {"m.mentions": {"room": True}}}, + ) + + await channel._on_message(room, room_mention_event) + assert handled == [] + assert client.typing_calls == [] + + channel.config.allow_room_mentions = True + await channel._on_message(room, room_mention_event) + assert handled == ["@alice:matrix.org"] + assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] + + @pytest.mark.asyncio async def test_send_clears_typing_after_send() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 9b14869cb10daa09a65d0f4e321dc63cbd812363 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 15:56:21 +0100 Subject: [PATCH 146/415] feat(matrix): support inline markdown html for url and super/subscript --- nanobot/channels/matrix.py | 16 +++++++++++++--- tests/test_matrix_channel.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index fbe511b..61113ac 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -24,9 +24,16 @@ LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 MATRIX_HTML_FORMAT = "org.matrix.custom.html" +# Keep plugin output aligned with Matrix recommended HTML tags: +# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes +# - table/strikethrough/task_lists are already used in replies. +# - url, superscript, and subscript map to common tags (, , ) +# that Matrix clients (e.g. Element/FluffyChat) can render consistently. +# We intentionally avoid plugins that emit less-portable tags to keep output +# predictable across clients. MATRIX_MARKDOWN = create_markdown( escape=True, - plugins=["table", "strikethrough", "task_lists"], + plugins=["table", "strikethrough", "task_lists", "url", "superscript", "subscript"], ) @@ -47,8 +54,11 @@ def _render_markdown_html(text: str) -> str | None: # Skip formatted_body for plain output (

...

) to keep payload minimal. stripped = formatted.strip() - if stripped.startswith("

") and stripped.endswith("

") and "

" not in stripped[3:-4]: - return None + if stripped.startswith("

") and stripped.endswith("

"): + paragraph_inner = stripped[3:-4] + # Keep plaintext-only paragraphs minimal, but preserve inline markup/links. + if "<" not in paragraph_inner and ">" not in paragraph_inner: + return None return formatted diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index cc834c3..2e3dad2 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -424,6 +424,26 @@ async def test_send_adds_formatted_body_for_markdown() -> None: assert "task-list-item-checkbox" in str(content["formatted_body"]) +@pytest.mark.asyncio +async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + markdown_text = "Visit https://example.com and x^2^ plus H~2~O." + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) + ) + + content = client.room_send_calls[0]["content"] + assert content["msgtype"] == "m.text" + assert content["body"] == markdown_text + assert content["format"] == MATRIX_HTML_FORMAT + assert '
' in str(content["formatted_body"]) + assert "2" in str(content["formatted_body"]) + assert "2" in str(content["formatted_body"]) + + @pytest.mark.asyncio async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 6be7368a38e8c1fe9bafc2b2517453040ce83ac6 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 16:18:47 +0100 Subject: [PATCH 147/415] fix(matrix): sanitize formatted html with nh3 --- nanobot/channels/matrix.py | 92 ++++++++++++++++++++++++++++++++++-- pyproject.toml | 3 +- tests/test_matrix_channel.py | 48 ++++++++++++++++++- 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 61113ac..8240b51 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -2,6 +2,7 @@ import asyncio import logging from typing import Any +import nh3 from loguru import logger from mistune import create_markdown from nio import ( @@ -26,21 +27,106 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html" # Keep plugin output aligned with Matrix recommended HTML tags: # https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes -# - table/strikethrough/task_lists are already used in replies. +# - table/strikethrough are already used in replies. # - url, superscript, and subscript map to common tags (, , ) # that Matrix clients (e.g. Element/FluffyChat) can render consistently. # We intentionally avoid plugins that emit less-portable tags to keep output # predictable across clients. MATRIX_MARKDOWN = create_markdown( escape=True, - plugins=["table", "strikethrough", "task_lists", "url", "superscript", "subscript"], + plugins=["table", "strikethrough", "url", "superscript", "subscript"], +) + +# Sanitizer policy rationale: +# - Baseline follows Matrix formatted message guidance: +# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes +# - We intentionally use a tighter subset than the full spec to keep behavior +# predictable across clients and reduce risk from LLM-generated content. +# - URLs are restricted to common safe schemes for links, and image sources are +# additionally constrained to mxc:// for Matrix-native media handling. +# - Spec items intentionally NOT enabled yet: +# - href schemes ftp/magnet (we keep link schemes smaller for now). +# - a[target] (clients already control link-opening behavior). +# - span[data-mx-bg-color|data-mx-color|data-mx-spoiler|data-mx-maths] +# - div[data-mx-maths] +# These can be added later when we explicitly support those Matrix features. +MATRIX_ALLOWED_HTML_TAGS = { + "p", + "a", + "strong", + "em", + "del", + "code", + "pre", + "blockquote", + "ul", + "ol", + "li", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "br", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "caption", + "sup", + "sub", + "img", +} +MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = { + "a": {"href"}, + "code": {"class"}, + "ol": {"start"}, + "img": {"src", "alt", "title", "width", "height"}, +} +MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"} + + +def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None: + """Filter attribute values to a safe Matrix-compatible subset.""" + if tag == "a" and attr == "href": + lower_value = value.lower() + if lower_value.startswith(("https://", "http://", "matrix:", "mailto:")): + return value + return None + + if tag == "img" and attr == "src": + return value if value.lower().startswith("mxc://") else None + + if tag == "code" and attr == "class": + classes = [ + cls + for cls in value.split() + if cls.startswith("language-") and not cls.startswith("language-_") + ] + return " ".join(classes) if classes else None + + return value + + +MATRIX_HTML_CLEANER = nh3.Cleaner( + tags=MATRIX_ALLOWED_HTML_TAGS, + attributes=MATRIX_ALLOWED_HTML_ATTRIBUTES, + attribute_filter=_filter_matrix_html_attribute, + url_schemes=MATRIX_ALLOWED_URL_SCHEMES, + strip_comments=True, + link_rel="noopener noreferrer", ) def _render_markdown_html(text: str) -> str | None: """Render markdown to HTML for Matrix formatted messages.""" try: - formatted = MATRIX_MARKDOWN(text).strip() + rendered = MATRIX_MARKDOWN(text) + formatted = MATRIX_HTML_CLEANER.clean(rendered).strip() except Exception as e: logger.debug( "Matrix markdown rendering failed ({}): {}", diff --git a/pyproject.toml b/pyproject.toml index 82b37a3..12a1ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,8 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "matrix-nio[e2e]>=0.25.2", - "mistune>=3.0.0", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", ] [project.optional-dependencies] diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 2e3dad2..616b0bc 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -421,7 +421,7 @@ async def test_send_adds_formatted_body_for_markdown() -> None: assert content["format"] == MATRIX_HTML_FORMAT assert "

Headline

" in str(content["formatted_body"]) assert "
" in str(content["formatted_body"]) - assert "task-list-item-checkbox" in str(content["formatted_body"]) + assert "
  • [x] done
  • " in str(content["formatted_body"]) @pytest.mark.asyncio @@ -439,11 +439,55 @@ async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() - assert content["msgtype"] == "m.text" assert content["body"] == markdown_text assert content["format"] == MATRIX_HTML_FORMAT - assert '
    ' in str(content["formatted_body"]) + assert '' in str( + content["formatted_body"] + ) assert "2" in str(content["formatted_body"]) assert "2" in str(content["formatted_body"]) +@pytest.mark.asyncio +async def test_send_sanitizes_disallowed_link_scheme() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + markdown_text = "[click](javascript:alert(1))" + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) + ) + + formatted_body = str(client.room_send_calls[0]["content"]["formatted_body"]) + assert "javascript:" not in formatted_body + assert "x' + cleaned_html = matrix_module.MATRIX_HTML_CLEANER.clean(dirty_html) + + assert " None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + markdown_text = "![ok](mxc://example.org/mediaid) ![no](https://example.com/a.png)" + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text) + ) + + formatted_body = str(client.room_send_calls[0]["content"]["formatted_body"]) + assert 'src="mxc://example.org/mediaid"' in formatted_body + assert 'src="https://example.com/a.png"' not in formatted_body + + @pytest.mark.asyncio async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 7b2adf9d9dab138341daedc3bfab52e4e495b1fa Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 16:29:31 +0100 Subject: [PATCH 148/415] docs(matrix): document raw html escaping in markdown renderer --- nanobot/channels/matrix.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 8240b51..f00f321 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -32,6 +32,10 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html" # that Matrix clients (e.g. Element/FluffyChat) can render consistently. # We intentionally avoid plugins that emit less-portable tags to keep output # predictable across clients. +# escape=True is intentional: raw HTML from model output is rendered as text, +# not as live HTML. This includes Matrix-specific raw snippets such as +# and
    , unless we later add explicit +# structured support for those features. MATRIX_MARKDOWN = create_markdown( escape=True, plugins=["table", "strikethrough", "url", "superscript", "subscript"], From a482a89df6b25e03a89b535c7af542b0bc766ab9 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:09:06 +0100 Subject: [PATCH 149/415] feat(matrix): support inbound media attachments --- nanobot/channels/matrix.py | 357 ++++++++++++++++++++++++++++++++--- nanobot/config/schema.py | 2 + tests/test_matrix_channel.py | 223 ++++++++++++++++++++++ 3 files changed, 556 insertions(+), 26 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index f00f321..3edcf63 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,5 +1,7 @@ import asyncio import logging +import mimetypes +from pathlib import Path from typing import Any import nh3 @@ -8,52 +10,69 @@ from mistune import create_markdown from nio import ( AsyncClient, AsyncClientConfig, + DownloadError, InviteEvent, JoinError, MatrixRoom, + MemoryDownloadResponse, + RoomEncryptedAudio, + RoomEncryptedFile, + RoomEncryptedImage, + RoomEncryptedVideo, + RoomMessageAudio, + RoomMessageFile, + RoomMessageImage, RoomMessageText, + RoomMessageVideo, RoomSendError, RoomTypingError, SyncError, ) +from nio.crypto.attachments import decrypt_attachment +from nio.exceptions import EncryptionError from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel from nanobot.config.loader import get_data_dir +from nanobot.utils.helpers import safe_filename LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 MATRIX_HTML_FORMAT = "org.matrix.custom.html" +MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" +MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" +MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]" +MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment" -# Keep plugin output aligned with Matrix recommended HTML tags: -# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes -# - table/strikethrough are already used in replies. -# - url, superscript, and subscript map to common tags (, , ) -# that Matrix clients (e.g. Element/FluffyChat) can render consistently. -# We intentionally avoid plugins that emit less-portable tags to keep output -# predictable across clients. -# escape=True is intentional: raw HTML from model output is rendered as text, -# not as live HTML. This includes Matrix-specific raw snippets such as -# and
    , unless we later add explicit -# structured support for those features. +MATRIX_MEDIA_EVENT_TYPES = ( + RoomMessageImage, + RoomMessageFile, + RoomMessageAudio, + RoomMessageVideo, + RoomEncryptedImage, + RoomEncryptedFile, + RoomEncryptedAudio, + RoomEncryptedVideo, +) + +# Markdown renderer policy: +# https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes +# - Only enable portable features that map cleanly to Matrix-compatible HTML. +# - escape=True ensures raw model HTML is treated as text unless we explicitly +# add structured support for Matrix-specific HTML features later. MATRIX_MARKDOWN = create_markdown( escape=True, plugins=["table", "strikethrough", "url", "superscript", "subscript"], ) -# Sanitizer policy rationale: -# - Baseline follows Matrix formatted message guidance: -# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes -# - We intentionally use a tighter subset than the full spec to keep behavior -# predictable across clients and reduce risk from LLM-generated content. -# - URLs are restricted to common safe schemes for links, and image sources are -# additionally constrained to mxc:// for Matrix-native media handling. -# - Spec items intentionally NOT enabled yet: -# - href schemes ftp/magnet (we keep link schemes smaller for now). -# - a[target] (clients already control link-opening behavior). -# - span[data-mx-bg-color|data-mx-color|data-mx-spoiler|data-mx-maths] -# - div[data-mx-maths] -# These can be added later when we explicitly support those Matrix features. +# Sanitizer policy: +# https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes +# - Start from Matrix formatted-message guidance, but keep a smaller allowlist +# to reduce risk and keep client behavior predictable for LLM output. +# - Enforce mxc:// for img src to align media rendering with Matrix content +# repository semantics. +# - Unused spec-permitted features (e.g. some href schemes and data-mx-* attrs) +# are intentionally deferred until explicitly needed. MATRIX_ALLOWED_HTML_TAGS = { "p", "a", @@ -292,6 +311,7 @@ class MatrixChannel(BaseChannel): def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" self.client.add_event_callback(self._on_message, RoomMessageText) + self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_TYPES) self.client.add_event_callback(self._on_room_invite, InviteEvent) def _register_response_callbacks(self) -> None: @@ -371,7 +391,7 @@ class MatrixChannel(BaseChannel): member_count = getattr(room, "member_count", None) return isinstance(member_count, int) and member_count <= 2 - def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessageText) -> bool: + def _is_bot_mentioned_from_mx_mentions(self, event: Any) -> bool: """Resolve mentions strictly from Matrix-native m.mentions payload.""" source = getattr(event, "source", None) if not isinstance(source, dict): @@ -391,7 +411,7 @@ class MatrixChannel(BaseChannel): return bool(self.config.allow_room_mentions and mentions.get("room") is True) - def _should_process_message(self, room: MatrixRoom, event: RoomMessageText) -> bool: + def _should_process_message(self, room: MatrixRoom, event: Any) -> bool: """Apply sender and room policy checks before processing Matrix messages.""" if not self.is_allowed(event.sender): return False @@ -409,6 +429,253 @@ class MatrixChannel(BaseChannel): return False + def _media_dir(self) -> Path: + """Return directory used to persist downloaded Matrix attachments.""" + media_dir = get_data_dir() / "media" / "matrix" + media_dir.mkdir(parents=True, exist_ok=True) + return media_dir + + @staticmethod + def _event_source_content(event: Any) -> dict[str, Any]: + """Extract Matrix event content payload when available.""" + source = getattr(event, "source", None) + if not isinstance(source, dict): + return {} + content = source.get("content") + return content if isinstance(content, dict) else {} + + def _event_attachment_type(self, event: Any) -> str: + """Map Matrix event payload/type to a stable attachment kind.""" + msgtype = self._event_source_content(event).get("msgtype") + if msgtype == "m.image": + return "image" + if msgtype == "m.audio": + return "audio" + if msgtype == "m.video": + return "video" + if msgtype == "m.file": + return "file" + + class_name = type(event).__name__.lower() + if "image" in class_name: + return "image" + if "audio" in class_name: + return "audio" + if "video" in class_name: + return "video" + return "file" + + @staticmethod + def _is_encrypted_media_event(event: Any) -> bool: + """Return True for encrypted Matrix media events.""" + return ( + isinstance(getattr(event, "key", None), dict) + and isinstance(getattr(event, "hashes", None), dict) + and isinstance(getattr(event, "iv", None), str) + ) + + def _event_declared_size_bytes(self, event: Any) -> int | None: + """Return declared media size from Matrix event info, if present.""" + info = self._event_source_content(event).get("info") + if not isinstance(info, dict): + return None + size = info.get("size") + if isinstance(size, int) and size >= 0: + return size + return None + + def _event_mime(self, event: Any) -> str | None: + """Best-effort MIME extraction from Matrix media event.""" + info = self._event_source_content(event).get("info") + if isinstance(info, dict): + mime = info.get("mimetype") + if isinstance(mime, str) and mime: + return mime + + mime = getattr(event, "mimetype", None) + if isinstance(mime, str) and mime: + return mime + return None + + def _event_filename(self, event: Any, attachment_type: str) -> str: + """Build a safe filename for a Matrix attachment.""" + body = getattr(event, "body", None) + if isinstance(body, str) and body.strip(): + candidate = safe_filename(Path(body).name) + if candidate: + return candidate + return MATRIX_DEFAULT_ATTACHMENT_NAME if attachment_type == "file" else attachment_type + + def _build_attachment_path( + self, + event: Any, + attachment_type: str, + filename: str, + mime: str | None, + ) -> Path: + """Compute a deterministic local file path for a downloaded attachment.""" + safe_name = safe_filename(Path(filename).name) or MATRIX_DEFAULT_ATTACHMENT_NAME + suffix = Path(safe_name).suffix + if not suffix and mime: + guessed = mimetypes.guess_extension(mime, strict=False) or "" + if guessed: + safe_name = f"{safe_name}{guessed}" + suffix = guessed + + stem = Path(safe_name).stem or attachment_type + stem = stem[:72] + suffix = suffix[:16] + + event_id = safe_filename(str(getattr(event, "event_id", "") or "evt").lstrip("$")) + event_prefix = (event_id[:24] or "evt").strip("_") + return self._media_dir() / f"{event_prefix}_{stem}{suffix}" + + async def _download_media_bytes(self, mxc_url: str) -> bytes | None: + """Download media bytes from Matrix content repository.""" + if not self.client: + return None + + response = await self.client.download(mxc=mxc_url) + if isinstance(response, DownloadError): + logger.warning("Matrix attachment download failed for {}: {}", mxc_url, response) + return None + + body = getattr(response, "body", None) + if isinstance(body, (bytes, bytearray)): + return bytes(body) + + if isinstance(response, MemoryDownloadResponse): + return bytes(response.body) + + if isinstance(body, (str, Path)): + path = Path(body) + if path.is_file(): + try: + return path.read_bytes() + except OSError as e: + logger.warning( + "Matrix attachment read failed for {} ({}): {}", + mxc_url, + type(e).__name__, + str(e), + ) + return None + + logger.warning( + "Matrix attachment download failed for {}: unexpected response type {}", + mxc_url, + type(response).__name__, + ) + return None + + def _decrypt_media_bytes(self, event: Any, ciphertext: bytes) -> bytes | None: + """Decrypt encrypted Matrix attachment bytes.""" + key_obj = getattr(event, "key", None) + hashes = getattr(event, "hashes", None) + iv = getattr(event, "iv", None) + + key = key_obj.get("k") if isinstance(key_obj, dict) else None + sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None + if not isinstance(key, str) or not isinstance(sha256, str) or not isinstance(iv, str): + logger.warning( + "Matrix encrypted attachment missing key material for event {}", + getattr(event, "event_id", ""), + ) + return None + + try: + return decrypt_attachment(ciphertext, key, sha256, iv) + except (EncryptionError, ValueError, TypeError) as e: + logger.warning( + "Matrix encrypted attachment decryption failed for event {} ({}): {}", + getattr(event, "event_id", ""), + type(e).__name__, + str(e), + ) + return None + + async def _fetch_media_attachment( + self, + room: MatrixRoom, + event: Any, + ) -> tuple[dict[str, Any] | None, str]: + """Download and prepare a Matrix attachment for inbound processing.""" + attachment_type = self._event_attachment_type(event) + mime = self._event_mime(event) + filename = self._event_filename(event, attachment_type) + mxc_url = getattr(event, "url", None) + + if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): + logger.warning( + "Matrix attachment skipped in room {}: invalid mxc URL {}", + room.room_id, + mxc_url, + ) + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + + declared_size = self._event_declared_size_bytes(event) + if ( + declared_size is not None + and declared_size > self.config.max_inbound_media_bytes + ): + logger.warning( + "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", + room.room_id, + declared_size, + self.config.max_inbound_media_bytes, + ) + return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + downloaded = await self._download_media_bytes(mxc_url) + if downloaded is None: + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + + encrypted = self._is_encrypted_media_event(event) + data = downloaded + if encrypted: + decrypted = self._decrypt_media_bytes(event, downloaded) + if decrypted is None: + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + data = decrypted + + if len(data) > self.config.max_inbound_media_bytes: + logger.warning( + "Matrix attachment skipped in room {}: downloaded size {} exceeds limit {}", + room.room_id, + len(data), + self.config.max_inbound_media_bytes, + ) + return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + path = self._build_attachment_path( + event, + attachment_type, + filename, + mime, + ) + try: + path.write_bytes(data) + except OSError as e: + logger.warning( + "Matrix attachment persist failed for room {} ({}): {}", + room.room_id, + type(e).__name__, + str(e), + ) + return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + + attachment = { + "type": attachment_type, + "mime": mime, + "filename": filename, + "event_id": str(getattr(event, "event_id", "") or ""), + "encrypted": encrypted, + "size_bytes": len(data), + "path": str(path), + "mxc_url": mxc_url, + } + return attachment, MATRIX_ATTACHMENT_MARKER_TEMPLATE.format(path) + async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: # Ignore self messages if event.sender == self.config.user_id: @@ -428,3 +695,41 @@ class MatrixChannel(BaseChannel): except Exception: await self._set_typing(room.room_id, False) raise + + async def _on_media_message(self, room: MatrixRoom, event: Any) -> None: + """Handle inbound Matrix media events and forward local attachment paths.""" + if event.sender == self.config.user_id: + return + + if not self._should_process_message(room, event): + return + + attachment, marker = await self._fetch_media_attachment(room, event) + attachments = [attachment] if attachment else [] + markers = [marker] + media_paths = [a["path"] for a in attachments] + + body = getattr(event, "body", None) + content_parts: list[str] = [] + if isinstance(body, str) and body.strip(): + content_parts.append(body.strip()) + content_parts.extend(markers) + + # TODO: Optionally add audio transcription support for Matrix attachments, + # similar to Telegram's voice/audio flow, behind explicit config. + + await self._set_typing(room.room_id, True) + try: + await self._handle_message( + sender_id=event.sender, + chat_id=room.room_id, + content="\n".join(content_parts), + media=media_paths, + metadata={ + "room": getattr(room, "display_name", room.room_id), + "attachments": attachments, + }, + ) + except Exception: + await self._set_typing(room.room_id, False) + raise diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index d442104..f0ee410 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -74,6 +74,8 @@ class MatrixConfig(Base): device_id: str = "" # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 + # Max attachment size accepted from inbound Matrix media events. + max_inbound_media_bytes: int = 20 * 1024 * 1024 allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 616b0bc..932e612 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -1,3 +1,4 @@ +from pathlib import Path from types import SimpleNamespace import pytest @@ -43,6 +44,11 @@ class _FakeAsyncClient: self.response_callbacks: list[tuple[object, object]] = [] self.room_send_calls: list[dict[str, object]] = [] self.typing_calls: list[tuple[str, bool, int]] = [] + self.download_calls: list[dict[str, object]] = [] + self.download_response: object | None = None + self.download_bytes: bytes = b"media" + self.download_content_type: str = "application/octet-stream" + self.download_filename: str | None = None self.raise_on_send = False self.raise_on_typing = False @@ -89,6 +95,16 @@ class _FakeAsyncClient: if self.raise_on_typing: raise RuntimeError("typing failed") + async def download(self, **kwargs): + self.download_calls.append(kwargs) + if self.download_response is not None: + return self.download_response + return matrix_module.MemoryDownloadResponse( + body=self.download_bytes, + content_type=self.download_content_type, + filename=self.download_filename, + ) + async def close(self) -> None: return None @@ -133,6 +149,7 @@ async def test_start_skips_load_store_when_device_id_missing( assert len(clients) == 1 assert clients[0].load_store_called is False + assert len(clients[0].callbacks) == 3 assert len(clients[0].response_callbacks) == 3 await channel.stop() @@ -374,6 +391,212 @@ async def test_on_message_room_mention_requires_opt_in() -> None: assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] +@pytest.mark.asyncio +async def test_on_media_message_downloads_attachment_and_sets_metadata( + monkeypatch, tmp_path +) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.download_bytes = b"image" + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="photo.png", + url="mxc://example.org/mediaid", + event_id="$event1", + source={ + "content": { + "msgtype": "m.image", + "info": {"mimetype": "image/png", "size": 5}, + } + }, + ) + + await channel._on_media_message(room, event) + + assert len(client.download_calls) == 1 + assert len(handled) == 1 + assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] + + media_paths = handled[0]["media"] + assert isinstance(media_paths, list) and len(media_paths) == 1 + media_path = Path(media_paths[0]) + assert media_path.is_file() + assert media_path.read_bytes() == b"image" + + metadata = handled[0]["metadata"] + attachments = metadata["attachments"] + assert isinstance(attachments, list) and len(attachments) == 1 + assert attachments[0]["type"] == "image" + assert attachments[0]["mxc_url"] == "mxc://example.org/mediaid" + assert attachments[0]["path"] == str(media_path) + assert "[attachment: " in handled[0]["content"] + + +@pytest.mark.asyncio +async def test_on_media_message_respects_declared_size_limit( + monkeypatch, tmp_path +) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + + channel = MatrixChannel(_make_config(max_inbound_media_bytes=3), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="large.bin", + url="mxc://example.org/large", + event_id="$event2", + source={"content": {"msgtype": "m.file", "info": {"size": 10}}}, + ) + + await channel._on_media_message(room, event) + + assert client.download_calls == [] + assert len(handled) == 1 + assert handled[0]["media"] == [] + assert handled[0]["metadata"]["attachments"] == [] + assert "[attachment: large.bin - too large]" in handled[0]["content"] + + +@pytest.mark.asyncio +async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.download_response = matrix_module.DownloadError("download failed") + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="photo.png", + url="mxc://example.org/mediaid", + event_id="$event3", + source={"content": {"msgtype": "m.image"}}, + ) + + await channel._on_media_message(room, event) + + assert len(client.download_calls) == 1 + assert len(handled) == 1 + assert handled[0]["media"] == [] + assert handled[0]["metadata"]["attachments"] == [] + assert "[attachment: photo.png - download failed]" in handled[0]["content"] + + +@pytest.mark.asyncio +async def test_on_media_message_decrypts_encrypted_media(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr( + matrix_module, + "decrypt_attachment", + lambda ciphertext, key, sha256, iv: b"plain", + ) + + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.download_bytes = b"cipher" + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="secret.txt", + url="mxc://example.org/encrypted", + event_id="$event4", + key={"k": "key"}, + hashes={"sha256": "hash"}, + iv="iv", + source={"content": {"msgtype": "m.file", "info": {"size": 6}}}, + ) + + await channel._on_media_message(room, event) + + assert len(handled) == 1 + media_path = Path(handled[0]["media"][0]) + assert media_path.read_bytes() == b"plain" + attachment = handled[0]["metadata"]["attachments"][0] + assert attachment["encrypted"] is True + assert attachment["size_bytes"] == 5 + + +@pytest.mark.asyncio +async def test_on_media_message_handles_decrypt_error(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + + def _raise(*args, **kwargs): + raise matrix_module.EncryptionError("boom") + + monkeypatch.setattr(matrix_module, "decrypt_attachment", _raise) + + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.download_bytes = b"cipher" + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="secret.txt", + url="mxc://example.org/encrypted", + event_id="$event5", + key={"k": "key"}, + hashes={"sha256": "hash"}, + iv="iv", + source={"content": {"msgtype": "m.file"}}, + ) + + await channel._on_media_message(room, event) + + assert len(handled) == 1 + assert handled[0]["media"] == [] + assert handled[0]["metadata"]["attachments"] == [] + assert "[attachment: secret.txt - download failed]" in handled[0]["content"] + + @pytest.mark.asyncio async def test_send_clears_typing_after_send() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From ca66ddb0bf9886a9663f41defc7ffc942fd7131c Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:21:13 +0100 Subject: [PATCH 150/415] feat(matrix): refresh typing indicator while processing --- nanobot/channels/matrix.py | 50 ++++++++++++++++++++++++++++++++---- tests/test_matrix_channel.py | 37 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 3edcf63..5893bb2 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -38,6 +38,7 @@ from nanobot.utils.helpers import safe_filename LOGGING_STACK_BASE_DEPTH = 2 TYPING_NOTICE_TIMEOUT_MS = 30_000 +TYPING_KEEPALIVE_INTERVAL_SECONDS = 20.0 MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" @@ -224,6 +225,7 @@ class MatrixChannel(BaseChannel): super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None + self._typing_tasks: dict[str, asyncio.Task] = {} async def start(self) -> None: """Start Matrix client and begin sync loop.""" @@ -272,6 +274,9 @@ class MatrixChannel(BaseChannel): """Stop the Matrix channel with graceful sync shutdown.""" self._running = False + for room_id in list(self._typing_tasks): + await self._stop_typing_keepalive(room_id, clear_typing=False) + if self.client: # Request sync_forever loop to exit cleanly. self.client.stop_sync_forever() @@ -306,7 +311,7 @@ class MatrixChannel(BaseChannel): ignore_unverified_devices=True, ) finally: - await self._set_typing(msg.chat_id, False) + await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" @@ -368,6 +373,41 @@ class MatrixChannel(BaseChannel): str(e), ) + async def _start_typing_keepalive(self, room_id: str) -> None: + """Start periodic Matrix typing refresh for a room.""" + await self._stop_typing_keepalive(room_id, clear_typing=False) + await self._set_typing(room_id, True) + if not self._running: + return + + async def _typing_loop() -> None: + try: + while self._running: + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_SECONDS) + await self._set_typing(room_id, True) + except asyncio.CancelledError: + pass + + self._typing_tasks[room_id] = asyncio.create_task(_typing_loop()) + + async def _stop_typing_keepalive( + self, + room_id: str, + *, + clear_typing: bool, + ) -> None: + """Stop periodic Matrix typing refresh for a room.""" + task = self._typing_tasks.pop(room_id, None) + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + if clear_typing: + await self._set_typing(room_id, False) + async def _sync_loop(self) -> None: while self._running: try: @@ -684,7 +724,7 @@ class MatrixChannel(BaseChannel): if not self._should_process_message(room, event): return - await self._set_typing(room.room_id, True) + await self._start_typing_keepalive(room.room_id) try: await self._handle_message( sender_id=event.sender, @@ -693,7 +733,7 @@ class MatrixChannel(BaseChannel): metadata={"room": getattr(room, "display_name", room.room_id)}, ) except Exception: - await self._set_typing(room.room_id, False) + await self._stop_typing_keepalive(room.room_id, clear_typing=True) raise async def _on_media_message(self, room: MatrixRoom, event: Any) -> None: @@ -718,7 +758,7 @@ class MatrixChannel(BaseChannel): # TODO: Optionally add audio transcription support for Matrix attachments, # similar to Telegram's voice/audio flow, behind explicit config. - await self._set_typing(room.room_id, True) + await self._start_typing_keepalive(room.room_id) try: await self._handle_message( sender_id=event.sender, @@ -731,5 +771,5 @@ class MatrixChannel(BaseChannel): }, ) except Exception: - await self._set_typing(room.room_id, False) + await self._stop_typing_keepalive(room.room_id, clear_typing=True) raise diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 932e612..6a33a5e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from types import SimpleNamespace @@ -224,6 +225,24 @@ async def test_on_message_sets_typing_for_allowed_sender() -> None: ] +@pytest.mark.asyncio +async def test_typing_keepalive_refreshes_periodically(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + channel._running = True + + monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_SECONDS", 0.01) + + await channel._start_typing_keepalive("!room:matrix.org") + await asyncio.sleep(0.03) + await channel._stop_typing_keepalive("!room:matrix.org", clear_typing=True) + + true_updates = [call for call in client.typing_calls if call[1] is True] + assert len(true_updates) >= 2 + assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) + + @pytest.mark.asyncio async def test_on_message_skips_typing_for_self_message() -> None: channel = MatrixChannel(_make_config(), MessageBus()) @@ -612,6 +631,24 @@ async def test_send_clears_typing_after_send() -> None: assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) +@pytest.mark.asyncio +async def test_send_stops_typing_keepalive_task() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + channel._running = True + + await channel._start_typing_keepalive("!room:matrix.org") + assert "!room:matrix.org" in channel._typing_tasks + + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") + ) + + assert "!room:matrix.org" not in channel._typing_tasks + assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) + + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 8b3171ca2b59001499a7744d15caf9fd740f86ca Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:21:52 +0100 Subject: [PATCH 151/415] fix(matrix): include empty m.mentions in outgoing messages --- nanobot/channels/matrix.py | 11 +++++++++-- tests/test_matrix_channel.py | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 5893bb2..504e11f 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -173,9 +173,16 @@ def _render_markdown_html(text: str) -> str | None: return formatted -def _build_matrix_text_content(text: str) -> dict[str, str]: +def _build_matrix_text_content(text: str) -> dict[str, Any]: """Build Matrix m.text payload with plaintext fallback and optional HTML.""" - content: dict[str, str] = {"msgtype": "m.text", "body": text} + content: dict[str, Any] = { + "msgtype": "m.text", + "body": text, + # Matrix spec recommends always including m.mentions for message + # semantics/interoperability, even when no mentions are present. + # https://spec.matrix.org/v1.17/client-server-api/#mmentions + "m.mentions": {}, + } formatted_html = _render_markdown_html(text) if not formatted_html: return content diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 6a33a5e..f55fd0f 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -627,7 +627,11 @@ async def test_send_clears_typing_after_send() -> None: ) assert len(client.room_send_calls) == 1 - assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": "Hi"} + assert client.room_send_calls[0]["content"] == { + "msgtype": "m.text", + "body": "Hi", + "m.mentions": {}, + } assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) @@ -678,6 +682,7 @@ async def test_send_adds_formatted_body_for_markdown() -> None: content = client.room_send_calls[0]["content"] assert content["msgtype"] == "m.text" assert content["body"] == markdown_text + assert content["m.mentions"] == {} assert content["format"] == MATRIX_HTML_FORMAT assert "

    Headline

    " in str(content["formatted_body"]) assert "
    " in str(content["formatted_body"]) @@ -698,6 +703,7 @@ async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() - content = client.room_send_calls[0]["content"] assert content["msgtype"] == "m.text" assert content["body"] == markdown_text + assert content["m.mentions"] == {} assert content["format"] == MATRIX_HTML_FORMAT assert '' in str( content["formatted_body"] @@ -764,7 +770,7 @@ async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypat ) content = client.room_send_calls[0]["content"] - assert content == {"msgtype": "m.text", "body": markdown_text} + assert content == {"msgtype": "m.text", "body": markdown_text, "m.mentions": {}} @pytest.mark.asyncio @@ -778,4 +784,8 @@ async def test_send_keeps_plaintext_only_for_plain_text() -> None: OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=text) ) - assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": text} + assert client.room_send_calls[0]["content"] == { + "msgtype": "m.text", + "body": text, + "m.mentions": {}, + } From 085a311d4ba9d15bfc5185d926667eed5f38c729 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:25:01 +0100 Subject: [PATCH 152/415] docs(matrix): clarify typing keepalive spec notes --- nanobot/channels/matrix.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 504e11f..a7ff851 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -37,7 +37,13 @@ from nanobot.config.loader import get_data_dir from nanobot.utils.helpers import safe_filename LOGGING_STACK_BASE_DEPTH = 2 +# Typing state lifetime advertised to Matrix clients/servers. TYPING_NOTICE_TIMEOUT_MS = 30_000 +# Matrix typing notifications are ephemeral; spec guidance is to keep +# refreshing while work is ongoing (practically ~20-30s cadence). +# https://spec.matrix.org/v1.17/client-server-api/#typing-notifications +# Keepalive interval must stay below TYPING_NOTICE_TIMEOUT_MS so the typing +# indicator does not expire while the agent is still processing. TYPING_KEEPALIVE_INTERVAL_SECONDS = 20.0 MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" @@ -173,9 +179,9 @@ def _render_markdown_html(text: str) -> str | None: return formatted -def _build_matrix_text_content(text: str) -> dict[str, Any]: +def _build_matrix_text_content(text: str) -> dict[str, object]: """Build Matrix m.text payload with plaintext fallback and optional HTML.""" - content: dict[str, Any] = { + content: dict[str, object] = { "msgtype": "m.text", "body": text, # Matrix spec recommends always including m.mentions for message @@ -381,7 +387,7 @@ class MatrixChannel(BaseChannel): ) async def _start_typing_keepalive(self, room_id: str) -> None: - """Start periodic Matrix typing refresh for a room.""" + """Start periodic Matrix typing refresh for a room (spec-recommended keepalive).""" await self._stop_typing_keepalive(room_id, clear_typing=False) await self._set_typing(room_id, True) if not self._running: From 566ad1dfc793a1c48dcd1687e9ea8369f7f82ba3 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:50:27 +0100 Subject: [PATCH 153/415] feat(matrix): make e2ee configurable with enabled default --- nanobot/channels/matrix.py | 26 ++++++++++---- nanobot/config/schema.py | 2 ++ tests/test_matrix_channel.py | 70 +++++++++++++++++++++++++++++++----- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index a7ff851..35c4000 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -254,7 +254,7 @@ class MatrixChannel(BaseChannel): store_path=store_path, # Where tokens are saved config=AsyncClientConfig( store_sync_tokens=True, # Auto-persists next_batch tokens - encryption_enabled=True, + encryption_enabled=self.config.e2ee_enabled, ), ) @@ -265,6 +265,14 @@ class MatrixChannel(BaseChannel): self._register_event_callbacks() self._register_response_callbacks() + if self.config.e2ee_enabled: + logger.info("Matrix E2EE is enabled.") + else: + logger.warning( + "Matrix E2EE is disabled; encrypted room messages may be undecryptable and " + "encrypted-device verification is not applied on send." + ) + if self.config.device_id: try: self.client.load_store() @@ -316,13 +324,17 @@ class MatrixChannel(BaseChannel): if not self.client: return + room_send_kwargs: dict[str, Any] = { + "room_id": msg.chat_id, + "message_type": "m.room.message", + "content": _build_matrix_text_content(msg.content), + } + if self.config.e2ee_enabled: + # TODO(matrix): Add explicit config for strict verified-device sending mode. + room_send_kwargs["ignore_unverified_devices"] = True + try: - await self.client.room_send( - room_id=msg.chat_id, - message_type="m.room.message", - content=_build_matrix_text_content(msg.content), - ignore_unverified_devices=True, - ) + await self.client.room_send(**room_send_kwargs) finally: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f0ee410..0861073 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -72,6 +72,8 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" + # Enable Matrix E2EE support (encryption + encrypted room handling). + e2ee_enabled: bool = True # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 # Max attachment size accepted from inbound Matrix media events. diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index f55fd0f..6ea955d 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -14,6 +14,8 @@ from nanobot.channels.matrix import ( ) from nanobot.config.schema import MatrixConfig +_ROOM_SEND_UNSET = object() + class _DummyTask: def __init__(self) -> None: @@ -73,16 +75,16 @@ class _FakeAsyncClient: room_id: str, message_type: str, content: dict[str, object], - ignore_unverified_devices: bool, + ignore_unverified_devices: object = _ROOM_SEND_UNSET, ) -> None: - self.room_send_calls.append( - { - "room_id": room_id, - "message_type": message_type, - "content": content, - "ignore_unverified_devices": ignore_unverified_devices, - } - ) + call: dict[str, object] = { + "room_id": room_id, + "message_type": message_type, + "content": content, + } + if ignore_unverified_devices is not _ROOM_SEND_UNSET: + call["ignore_unverified_devices"] = ignore_unverified_devices + self.room_send_calls.append(call) if self.raise_on_send: raise RuntimeError("send failed") @@ -149,6 +151,7 @@ async def test_start_skips_load_store_when_device_id_missing( await channel.start() assert len(clients) == 1 + assert clients[0].config.encryption_enabled is True assert clients[0].load_store_called is False assert len(clients[0].callbacks) == 3 assert len(clients[0].response_callbacks) == 3 @@ -156,6 +159,40 @@ async def test_start_skips_load_store_when_device_id_missing( await channel.stop() +@pytest.mark.asyncio +async def test_start_disables_e2ee_when_configured( + monkeypatch, tmp_path +) -> None: + clients: list[_FakeAsyncClient] = [] + + def _fake_client(*args, **kwargs) -> _FakeAsyncClient: + client = _FakeAsyncClient(*args, **kwargs) + clients.append(client) + return client + + def _fake_create_task(coro): + coro.close() + return _DummyTask() + + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr( + "nanobot.channels.matrix.AsyncClientConfig", + lambda **kwargs: SimpleNamespace(**kwargs), + ) + monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client) + monkeypatch.setattr( + "nanobot.channels.matrix.asyncio.create_task", _fake_create_task + ) + + channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=False), MessageBus()) + await channel.start() + + assert len(clients) == 1 + assert clients[0].config.encryption_enabled is False + + await channel.stop() + + @pytest.mark.asyncio async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus()) @@ -632,9 +669,24 @@ async def test_send_clears_typing_after_send() -> None: "body": "Hi", "m.mentions": {}, } + assert client.room_send_calls[0]["ignore_unverified_devices"] is True assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) +@pytest.mark.asyncio +async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None: + channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + await channel.send( + OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi") + ) + + assert len(client.room_send_calls) == 1 + assert "ignore_unverified_devices" not in client.room_send_calls[0] + + @pytest.mark.asyncio async def test_send_stops_typing_keepalive_task() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 9b06f682c317698e1a53c7ea36dafa24105816bb Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 17:50:34 +0100 Subject: [PATCH 154/415] docs(readme): document matrix e2eeEnabled option --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index a474367..45de967 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,70 @@ nanobot gateway +
    +Matrix (Element) + +Uses Matrix sync via `matrix-nio` (including inbound media support). + +**1. Create/choose a Matrix account** + +- Create or reuse a Matrix account on your homeserver (for example `matrix.org`). +- Confirm you can log in with Element. + +**2. Get credentials** + +- You need: + - `userId` (example: `@nanobot:matrix.org`) + - `accessToken` + - `deviceId` (recommended so sync tokens can be restored across restarts) +- You can obtain these from your homeserver login API (`/_matrix/client/v3/login`) or from your client's advanced session settings. + +**3. Configure** + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "homeserver": "https://matrix.org", + "userId": "@nanobot:matrix.org", + "accessToken": "syt_xxx", + "deviceId": "NANOBOT01", + "e2eeEnabled": true, + "allowFrom": [], + "groupPolicy": "open", + "groupAllowFrom": [], + "allowRoomMentions": false, + "maxInboundMediaBytes": 20971520 + } + } +} +``` + +> `allowFrom`: Empty allows all senders; set user IDs to restrict access. +> `groupPolicy`: `open`, `mention`, or `allowlist`. +> `groupAllowFrom`: Room allowlist used when `groupPolicy` is `allowlist`. +> `allowRoomMentions`: If `true`, accepts `@room` (`m.mentions.room`) in mention mode. +> `e2eeEnabled`: Enables Matrix E2EE support (default `true`); set `false` only for plaintext-only setups. +> `maxInboundMediaBytes`: Max inbound attachment size in bytes (default `20MB`). + +> [!NOTE] +> Matrix E2EE implications: +> +> - Keep a persistent `matrix-store` and stable `deviceId`; otherwise encrypted session state can be lost after restart. +> - In newly joined encrypted rooms, initial messages may fail until Olm/Megolm sessions are established. +> - With `e2eeEnabled=false`, encrypted room messages may be undecryptable and E2EE send safeguards are not applied. +> - With `e2eeEnabled=true`, the bot sends with `ignore_unverified_devices=true` (more compatible, less strict than verified-only sending). +> - Changing `accessToken`/`deviceId` effectively creates a new device and may require session re-establishment. + +**4. Run** + +```bash +nanobot gateway +``` + +
    +
    WhatsApp From 1103f000fc803918f70eb180573e5eb35ed95ae6 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Tue, 10 Feb 2026 22:41:29 +0100 Subject: [PATCH 155/415] docs(matrix): clarify m.text body plaintext fallback note --- nanobot/channels/matrix.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 35c4000..fcff534 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -183,6 +183,10 @@ def _build_matrix_text_content(text: str) -> dict[str, object]: """Build Matrix m.text payload with plaintext fallback and optional HTML.""" content: dict[str, object] = { "msgtype": "m.text", + # Note: When `formatted_body` is present, Matrix spec expects `body` to + # be its plaintext representation (fallback for clients without HTML). + # We currently keep raw text (often markdown) for simplicity. + # https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes "body": text, # Matrix spec recommends always including m.mentions for message # semantics/interoperability, even when no mentions are present. From 10de3bf329bc5e19a7fbe995b28bb8ed59b9fe85 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Thu, 12 Feb 2026 22:58:53 +0100 Subject: [PATCH 156/415] refactor(matrix): use base media event filter for callbacks - Replaces the explicit media event tuple with MATRIX_MEDIA_EVENT_FILTER based on media base classes: (RoomMessageMedia, RoomEncryptedMedia). - Keeps MatrixMediaEvent as the static typing alias for media-specific handlers. - Removes MatrixInboundEvent and uses RoomMessage in mention-related logic. - Adds regression tests for: - callback registration using MATRIX_MEDIA_EVENT_FILTER - ensuring RoomMessageText is not matched by the media filter. --- nanobot/channels/matrix.py | 98 +++++++++++++++++++++++------------- tests/test_matrix_channel.py | 17 +++++++ 2 files changed, 79 insertions(+), 36 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index fcff534..51df4e8 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -2,7 +2,7 @@ import asyncio import logging import mimetypes from pathlib import Path -from typing import Any +from typing import Any, TypeAlias import nh3 from loguru import logger @@ -15,15 +15,10 @@ from nio import ( JoinError, MatrixRoom, MemoryDownloadResponse, - RoomEncryptedAudio, - RoomEncryptedFile, - RoomEncryptedImage, - RoomEncryptedVideo, - RoomMessageAudio, - RoomMessageFile, - RoomMessageImage, + RoomEncryptedMedia, + RoomMessage, + RoomMessageMedia, RoomMessageText, - RoomMessageVideo, RoomSendError, RoomTypingError, SyncError, @@ -51,16 +46,10 @@ MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]" MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment" -MATRIX_MEDIA_EVENT_TYPES = ( - RoomMessageImage, - RoomMessageFile, - RoomMessageAudio, - RoomMessageVideo, - RoomEncryptedImage, - RoomEncryptedFile, - RoomEncryptedAudio, - RoomEncryptedVideo, -) +# Runtime callback filter for nio event dispatch (checked via isinstance). +MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia) +# Static typing alias for media-specific handlers/helpers. +MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia # Markdown renderer policy: # https://spec.matrix.org/v1.17/client-server-api/#mroommessage-msgtypes @@ -345,7 +334,7 @@ class MatrixChannel(BaseChannel): def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" self.client.add_event_callback(self._on_message, RoomMessageText) - self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_TYPES) + self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) self.client.add_event_callback(self._on_room_invite, InviteEvent) def _register_response_callbacks(self) -> None: @@ -460,7 +449,7 @@ class MatrixChannel(BaseChannel): member_count = getattr(room, "member_count", None) return isinstance(member_count, int) and member_count <= 2 - def _is_bot_mentioned_from_mx_mentions(self, event: Any) -> bool: + def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessage) -> bool: """Resolve mentions strictly from Matrix-native m.mentions payload.""" source = getattr(event, "source", None) if not isinstance(source, dict): @@ -480,7 +469,7 @@ class MatrixChannel(BaseChannel): return bool(self.config.allow_room_mentions and mentions.get("room") is True) - def _should_process_message(self, room: MatrixRoom, event: Any) -> bool: + def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool: """Apply sender and room policy checks before processing Matrix messages.""" if not self.is_allowed(event.sender): return False @@ -505,7 +494,7 @@ class MatrixChannel(BaseChannel): return media_dir @staticmethod - def _event_source_content(event: Any) -> dict[str, Any]: + def _event_source_content(event: RoomMessage) -> dict[str, Any]: """Extract Matrix event content payload when available.""" source = getattr(event, "source", None) if not isinstance(source, dict): @@ -513,7 +502,47 @@ class MatrixChannel(BaseChannel): content = source.get("content") return content if isinstance(content, dict) else {} - def _event_attachment_type(self, event: Any) -> str: + def _event_thread_root_id(self, event: RoomMessage) -> str | None: + """Return thread root event_id if this message is inside a thread.""" + content = self._event_source_content(event) + relates_to = content.get("m.relates_to") + if not isinstance(relates_to, dict): + return None + if relates_to.get("rel_type") != "m.thread": + return None + root_id = relates_to.get("event_id") + return root_id if isinstance(root_id, str) and root_id else None + + def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: + """Build metadata used to reply within a thread.""" + root_id = self._event_thread_root_id(event) + if not root_id: + return None + reply_to = getattr(event, "event_id", None) + meta: dict[str, str] = {"thread_root_event_id": root_id} + if isinstance(reply_to, str) and reply_to: + meta["thread_reply_to_event_id"] = reply_to + return meta + + @staticmethod + def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None: + """Build m.relates_to payload for Matrix thread replies.""" + if not metadata: + return None + root_id = metadata.get("thread_root_event_id") + if not isinstance(root_id, str) or not root_id: + return None + reply_to = metadata.get("thread_reply_to_event_id") or metadata.get("event_id") + if not isinstance(reply_to, str) or not reply_to: + return None + return { + "rel_type": "m.thread", + "event_id": root_id, + "m.in_reply_to": {"event_id": reply_to}, + "is_falling_back": True, + } + + def _event_attachment_type(self, event: MatrixMediaEvent) -> str: """Map Matrix event payload/type to a stable attachment kind.""" msgtype = self._event_source_content(event).get("msgtype") if msgtype == "m.image": @@ -535,7 +564,7 @@ class MatrixChannel(BaseChannel): return "file" @staticmethod - def _is_encrypted_media_event(event: Any) -> bool: + def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool: """Return True for encrypted Matrix media events.""" return ( isinstance(getattr(event, "key", None), dict) @@ -543,7 +572,7 @@ class MatrixChannel(BaseChannel): and isinstance(getattr(event, "iv", None), str) ) - def _event_declared_size_bytes(self, event: Any) -> int | None: + def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None: """Return declared media size from Matrix event info, if present.""" info = self._event_source_content(event).get("info") if not isinstance(info, dict): @@ -553,7 +582,7 @@ class MatrixChannel(BaseChannel): return size return None - def _event_mime(self, event: Any) -> str | None: + def _event_mime(self, event: MatrixMediaEvent) -> str | None: """Best-effort MIME extraction from Matrix media event.""" info = self._event_source_content(event).get("info") if isinstance(info, dict): @@ -566,7 +595,7 @@ class MatrixChannel(BaseChannel): return mime return None - def _event_filename(self, event: Any, attachment_type: str) -> str: + def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str: """Build a safe filename for a Matrix attachment.""" body = getattr(event, "body", None) if isinstance(body, str) and body.strip(): @@ -577,7 +606,7 @@ class MatrixChannel(BaseChannel): def _build_attachment_path( self, - event: Any, + event: MatrixMediaEvent, attachment_type: str, filename: str, mime: str | None, @@ -637,7 +666,7 @@ class MatrixChannel(BaseChannel): ) return None - def _decrypt_media_bytes(self, event: Any, ciphertext: bytes) -> bytes | None: + def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None: """Decrypt encrypted Matrix attachment bytes.""" key_obj = getattr(event, "key", None) hashes = getattr(event, "hashes", None) @@ -666,7 +695,7 @@ class MatrixChannel(BaseChannel): async def _fetch_media_attachment( self, room: MatrixRoom, - event: Any, + event: MatrixMediaEvent, ) -> tuple[dict[str, Any] | None, str]: """Download and prepare a Matrix attachment for inbound processing.""" attachment_type = self._event_attachment_type(event) @@ -683,10 +712,7 @@ class MatrixChannel(BaseChannel): return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) declared_size = self._event_declared_size_bytes(event) - if ( - declared_size is not None - and declared_size > self.config.max_inbound_media_bytes - ): + if declared_size is not None and declared_size > self.config.max_inbound_media_bytes: logger.warning( "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", room.room_id, @@ -765,7 +791,7 @@ class MatrixChannel(BaseChannel): await self._stop_typing_keepalive(room.room_id, clear_typing=True) raise - async def _on_media_message(self, room: MatrixRoom, event: Any) -> None: + async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None: """Handle inbound Matrix media events and forward local attachment paths.""" if event.sender == self.config.user_id: return diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 6ea955d..164ec2e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -159,6 +159,23 @@ async def test_start_skips_load_store_when_device_id_missing( await channel.stop() +@pytest.mark.asyncio +async def test_register_event_callbacks_uses_media_base_filter() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + channel._register_event_callbacks() + + assert len(client.callbacks) == 3 + assert client.callbacks[1][0] == channel._on_media_message + assert client.callbacks[1][1] == matrix_module.MATRIX_MEDIA_EVENT_FILTER + + +def test_media_event_filter_does_not_match_text_events() -> None: + assert not issubclass(matrix_module.RoomMessageText, matrix_module.MATRIX_MEDIA_EVENT_FILTER) + + @pytest.mark.asyncio async def test_start_disables_e2ee_when_configured( monkeypatch, tmp_path From bfd2018095874f96ce9374b2fbecbc6af06a723d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 11:06:35 +0100 Subject: [PATCH 157/415] docs: update maxMediaBytes documentation to include blocking option Add clarification that setting to 0 blocks all attachments --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 45de967..2d988c4 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ Uses Matrix sync via `matrix-nio` (including inbound media support). > `groupAllowFrom`: Room allowlist used when `groupPolicy` is `allowlist`. > `allowRoomMentions`: If `true`, accepts `@room` (`m.mentions.room`) in mention mode. > `e2eeEnabled`: Enables Matrix E2EE support (default `true`); set `false` only for plaintext-only setups. -> `maxInboundMediaBytes`: Max inbound attachment size in bytes (default `20MB`). +> `maxMediaBytes`: Max attachment size in bytes (default `20MB`) for inbound and outbound media handling; set to `0` to block all inbound and outbound attachment uploads. > [!NOTE] > Matrix E2EE implications: From 97cb85ee0b4851db446b1c6ce3ae38c48b40d450 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 10:45:28 +0100 Subject: [PATCH 158/415] feat(matrix): add outbound media uploads and unify media limits with maxMediaBytes - Use OutboundMessage.media for Matrix file/image/audio/video sends - Apply effective media limit as min(m.upload.size, maxMediaBytes) - Rename matrix config key maxInboundMediaBytes -> maxMediaBytes (no legacy fallback) --- README.md | 7 +- nanobot/channels/manager.py | 88 +++++------ nanobot/channels/matrix.py | 278 +++++++++++++++++++++++++++++++++-- nanobot/config/schema.py | 4 +- tests/test_matrix_channel.py | 169 ++++++++++++++++++++- 5 files changed, 482 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 2d988c4..ed7bdec 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ nanobot gateway
    Matrix (Element) -Uses Matrix sync via `matrix-nio` (including inbound media support). +Uses Matrix sync via `matrix-nio` (inbound media + outbound file attachments). **1. Create/choose a Matrix account** @@ -335,7 +335,7 @@ Uses Matrix sync via `matrix-nio` (including inbound media support). "groupPolicy": "open", "groupAllowFrom": [], "allowRoomMentions": false, - "maxInboundMediaBytes": 20971520 + "maxMediaBytes": 20971520 } } } @@ -356,6 +356,9 @@ Uses Matrix sync via `matrix-nio` (including inbound media support). > - With `e2eeEnabled=false`, encrypted room messages may be undecryptable and E2EE send safeguards are not applied. > - With `e2eeEnabled=true`, the bot sends with `ignore_unverified_devices=true` (more compatible, less strict than verified-only sending). > - Changing `accessToken`/`deviceId` effectively creates a new device and may require session re-establishment. +> - Outbound attachments are sent from `OutboundMessage.media`. +> - Effective media limit (inbound + outbound) uses the stricter value of local `maxMediaBytes` and homeserver `m.upload.size` (if advertised). +> - If `tools.restrictToWorkspace=true`, Matrix outbound attachments are limited to files inside the workspace. **4. Run** diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index e860d26..998d90c 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -16,28 +16,29 @@ from nanobot.config.schema import Config class ChannelManager: """ Manages chat channels and coordinates message routing. - + Responsibilities: - Initialize enabled channels (Telegram, WhatsApp, etc.) - Start/stop channels - Route outbound messages """ - + def __init__(self, config: Config, bus: MessageBus): self.config = config self.bus = bus self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None - + self._init_channels() - + def _init_channels(self) -> None: """Initialize channels based on config.""" - + # Telegram channel if self.config.channels.telegram.enabled: try: from nanobot.channels.telegram import TelegramChannel + self.channels["telegram"] = TelegramChannel( self.config.channels.telegram, self.bus, @@ -46,14 +47,13 @@ class ChannelManager: logger.info("Telegram channel enabled") except ImportError as e: logger.warning(f"Telegram channel not available: {e}") - + # WhatsApp channel if self.config.channels.whatsapp.enabled: try: from nanobot.channels.whatsapp import WhatsAppChannel - self.channels["whatsapp"] = WhatsAppChannel( - self.config.channels.whatsapp, self.bus - ) + + self.channels["whatsapp"] = WhatsAppChannel(self.config.channels.whatsapp, self.bus) logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") @@ -62,20 +62,18 @@ class ChannelManager: if self.config.channels.discord.enabled: try: from nanobot.channels.discord import DiscordChannel - self.channels["discord"] = DiscordChannel( - self.config.channels.discord, self.bus - ) + + self.channels["discord"] = DiscordChannel(self.config.channels.discord, self.bus) logger.info("Discord channel enabled") except ImportError as e: logger.warning(f"Discord channel not available: {e}") - + # Feishu channel if self.config.channels.feishu.enabled: try: from nanobot.channels.feishu import FeishuChannel - self.channels["feishu"] = FeishuChannel( - self.config.channels.feishu, self.bus - ) + + self.channels["feishu"] = FeishuChannel(self.config.channels.feishu, self.bus) logger.info("Feishu channel enabled") except ImportError as e: logger.warning(f"Feishu channel not available: {e}") @@ -85,9 +83,7 @@ class ChannelManager: try: from nanobot.channels.mochat import MochatChannel - self.channels["mochat"] = MochatChannel( - self.config.channels.mochat, self.bus - ) + self.channels["mochat"] = MochatChannel(self.config.channels.mochat, self.bus) logger.info("Mochat channel enabled") except ImportError as e: logger.warning(f"Mochat channel not available: {e}") @@ -96,9 +92,8 @@ class ChannelManager: if self.config.channels.dingtalk.enabled: try: from nanobot.channels.dingtalk import DingTalkChannel - self.channels["dingtalk"] = DingTalkChannel( - self.config.channels.dingtalk, self.bus - ) + + self.channels["dingtalk"] = DingTalkChannel(self.config.channels.dingtalk, self.bus) logger.info("DingTalk channel enabled") except ImportError as e: logger.warning(f"DingTalk channel not available: {e}") @@ -107,9 +102,8 @@ class ChannelManager: if self.config.channels.email.enabled: try: from nanobot.channels.email import EmailChannel - self.channels["email"] = EmailChannel( - self.config.channels.email, self.bus - ) + + self.channels["email"] = EmailChannel(self.config.channels.email, self.bus) logger.info("Email channel enabled") except ImportError as e: logger.warning(f"Email channel not available: {e}") @@ -118,9 +112,8 @@ class ChannelManager: if self.config.channels.slack.enabled: try: from nanobot.channels.slack import SlackChannel - self.channels["slack"] = SlackChannel( - self.config.channels.slack, self.bus - ) + + self.channels["slack"] = SlackChannel(self.config.channels.slack, self.bus) logger.info("Slack channel enabled") except ImportError as e: logger.warning(f"Slack channel not available: {e}") @@ -129,6 +122,7 @@ class ChannelManager: if self.config.channels.qq.enabled: try: from nanobot.channels.qq import QQChannel + self.channels["qq"] = QQChannel( self.config.channels.qq, self.bus, @@ -136,7 +130,7 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning(f"QQ channel not available: {e}") - + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -149,23 +143,23 @@ class ChannelManager: if not self.channels: logger.warning("No channels enabled") return - + # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info(f"Starting {name} channel...") tasks.append(asyncio.create_task(self._start_channel(name, channel))) - + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) - + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") - + # Stop dispatcher if self._dispatch_task: self._dispatch_task.cancel() @@ -173,7 +167,7 @@ class ChannelManager: await self._dispatch_task except asyncio.CancelledError: pass - + # Stop all channels for name, channel in self.channels.items(): try: @@ -181,18 +175,15 @@ class ChannelManager: logger.info(f"Stopped {name} channel") except Exception as e: logger.error(f"Error stopping {name}: {e}") - + async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" logger.info("Outbound dispatcher started") - + while True: try: - msg = await asyncio.wait_for( - self.bus.consume_outbound(), - timeout=1.0 - ) - + msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0) + channel = self.channels.get(msg.channel) if channel: try: @@ -201,26 +192,23 @@ class ChannelManager: logger.error(f"Error sending to {msg.channel}: {e}") else: logger.warning(f"Unknown channel: {msg.channel}") - + except asyncio.TimeoutError: continue except asyncio.CancelledError: break - + def get_channel(self, name: str) -> BaseChannel | None: """Get a channel by name.""" return self.channels.get(name) - + def get_status(self) -> dict[str, Any]: """Get status of all channels.""" return { - name: { - "enabled": True, - "running": channel.is_running - } + name: {"enabled": True, "running": channel.is_running} for name, channel in self.channels.items() } - + @property def enabled_channels(self) -> list[str]: """Get list of enabled channel names.""" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 51df4e8..28c3924 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -10,6 +10,7 @@ from mistune import create_markdown from nio import ( AsyncClient, AsyncClientConfig, + ContentRepositoryConfigError, DownloadError, InviteEvent, JoinError, @@ -22,6 +23,7 @@ from nio import ( RoomSendError, RoomTypingError, SyncError, + UploadError, ) from nio.crypto.attachments import decrypt_attachment from nio.exceptions import EncryptionError @@ -44,6 +46,7 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" MATRIX_ATTACHMENT_FAILED_TEMPLATE = "[attachment: {} - download failed]" +MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE = "[attachment: {} - upload failed]" MATRIX_DEFAULT_ATTACHMENT_NAME = "attachment" # Runtime callback filter for nio event dispatch (checked via isinstance). @@ -227,11 +230,22 @@ class MatrixChannel(BaseChannel): name = "matrix" - def __init__(self, config: Any, bus): + def __init__( + self, + config: Any, + bus, + *, + restrict_to_workspace: bool = False, + workspace: Path | None = None, + ): super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} + self._restrict_to_workspace = restrict_to_workspace + self._workspace = workspace.expanduser().resolve() if workspace else None + self._server_upload_limit_bytes: int | None = None + self._server_upload_limit_checked = False async def start(self) -> None: """Start Matrix client and begin sync loop.""" @@ -313,21 +327,266 @@ class MatrixChannel(BaseChannel): if self.client: await self.client.close() - async def send(self, msg: OutboundMessage) -> None: + @staticmethod + def _path_dedupe_key(path: Path) -> str: + """Return a stable deduplication key for attachment paths.""" + expanded = path.expanduser() + try: + return str(expanded.resolve(strict=False)) + except OSError: + return str(expanded) + + def _is_workspace_path_allowed(self, path: Path) -> bool: + """Enforce optional workspace-only outbound attachment policy.""" + if not self._restrict_to_workspace: + return True + + if self._workspace is None: + return False + + try: + path.resolve(strict=False).relative_to(self._workspace) + return True + except ValueError: + return False + + def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]: + """Collect unique outbound attachment paths from OutboundMessage.media.""" + candidates: list[Path] = [] + seen: set[str] = set() + + for raw in media: + if not isinstance(raw, str) or not raw.strip(): + continue + path = Path(raw.strip()).expanduser() + key = self._path_dedupe_key(path) + if key in seen: + continue + seen.add(key) + candidates.append(path) + + return candidates + + @staticmethod + def _build_outbound_attachment_content( + *, + filename: str, + mime: str, + size_bytes: int, + mxc_url: str, + ) -> dict[str, Any]: + """Build Matrix content payload for an uploaded file/image/audio/video.""" + msgtype = "m.file" + if mime.startswith("image/"): + msgtype = "m.image" + elif mime.startswith("audio/"): + msgtype = "m.audio" + elif mime.startswith("video/"): + msgtype = "m.video" + + return { + "msgtype": msgtype, + "body": filename, + "filename": filename, + "url": mxc_url, + "info": { + "mimetype": mime, + "size": size_bytes, + }, + "m.mentions": {}, + } + + async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: + """Send Matrix m.room.message content with configured E2EE send options.""" if not self.client: return room_send_kwargs: dict[str, Any] = { - "room_id": msg.chat_id, + "room_id": room_id, "message_type": "m.room.message", - "content": _build_matrix_text_content(msg.content), + "content": content, } if self.config.e2ee_enabled: # TODO(matrix): Add explicit config for strict verified-device sending mode. room_send_kwargs["ignore_unverified_devices"] = True + await self.client.room_send(**room_send_kwargs) + + async def _resolve_server_upload_limit_bytes(self) -> int | None: + """Resolve homeserver-advertised upload limit once per channel lifecycle.""" + if self._server_upload_limit_checked: + return self._server_upload_limit_bytes + + self._server_upload_limit_checked = True + if not self.client: + return None + try: - await self.client.room_send(**room_send_kwargs) + response = await self.client.content_repository_config() + except Exception as e: + logger.debug( + "Matrix media config lookup failed ({}): {}", + type(e).__name__, + str(e), + ) + return None + + upload_size = getattr(response, "upload_size", None) + if isinstance(upload_size, int) and upload_size > 0: + self._server_upload_limit_bytes = upload_size + return self._server_upload_limit_bytes + + if isinstance(response, ContentRepositoryConfigError): + logger.debug("Matrix media config lookup failed: {}", response) + return None + + logger.debug( + "Matrix media config lookup returned unexpected response {}", + type(response).__name__, + ) + return None + + async def _effective_media_limit_bytes(self) -> int: + """ + Compute effective Matrix media size cap. + + `m.upload.size` (if advertised) is treated as the homeserver-side cap. + `maxMediaBytes` is a local hard limit/fallback. Using the stricter value + keeps resource usage predictable while honoring server constraints. + """ + local_limit = max(int(self.config.max_media_bytes), 0) + server_limit = await self._resolve_server_upload_limit_bytes() + if server_limit is None: + return local_limit + if local_limit == 0: + return 0 + return min(local_limit, server_limit) + + async def _upload_and_send_attachment( + self, room_id: str, path: Path, limit_bytes: int + ) -> str | None: + """Upload one local file to Matrix and send it as a media message.""" + if not self.client: + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format( + path.name or MATRIX_DEFAULT_ATTACHMENT_NAME + ) + + resolved = path.expanduser().resolve(strict=False) + filename = safe_filename(resolved.name) or MATRIX_DEFAULT_ATTACHMENT_NAME + + if not resolved.is_file(): + logger.warning("Matrix outbound attachment missing file: {}", resolved) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + if not self._is_workspace_path_allowed(resolved): + logger.warning( + "Matrix outbound attachment denied by workspace restriction: {}", + resolved, + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + try: + size_bytes = resolved.stat().st_size + except OSError as e: + logger.warning( + "Matrix outbound attachment stat failed for {} ({}): {}", + resolved, + type(e).__name__, + str(e), + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + if limit_bytes and size_bytes > limit_bytes: + logger.warning( + "Matrix outbound attachment skipped: {} bytes exceeds limit {} for {}", + size_bytes, + limit_bytes, + resolved, + ) + return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + try: + data = resolved.read_bytes() + except OSError as e: + logger.warning( + "Matrix outbound attachment read failed for {} ({}): {}", + resolved, + type(e).__name__, + str(e), + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" + upload_response = await self.client.upload( + data, + content_type=mime, + filename=filename, + filesize=len(data), + ) + if isinstance(upload_response, UploadError): + logger.warning( + "Matrix outbound attachment upload failed for {}: {}", + resolved, + upload_response, + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + mxc_url = getattr(upload_response, "content_uri", None) + if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): + logger.warning( + "Matrix outbound attachment upload returned unexpected response {} for {}", + type(upload_response).__name__, + resolved, + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + + content = self._build_outbound_attachment_content( + filename=filename, + mime=mime, + size_bytes=len(data), + mxc_url=mxc_url, + ) + try: + await self._send_room_content(room_id, content) + except Exception as e: + logger.warning( + "Matrix outbound attachment send failed for {} ({}): {}", + resolved, + type(e).__name__, + str(e), + ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) + return None + + async def send(self, msg: OutboundMessage) -> None: + if not self.client: + return + + text = msg.content or "" + candidates = self._collect_outbound_media_candidates(msg.media) + + try: + failures: list[str] = [] + + if candidates: + limit_bytes = await self._effective_media_limit_bytes() + for path in candidates: + failure_marker = await self._upload_and_send_attachment( + room_id=msg.chat_id, + path=path, + limit_bytes=limit_bytes, + ) + if failure_marker: + failures.append(failure_marker) + + if failures: + if text.strip(): + text = f"{text.rstrip()}\n" + "\n".join(failures) + else: + text = "\n".join(failures) + + if text or not candidates: + await self._send_room_content(msg.chat_id, _build_matrix_text_content(text)) finally: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) @@ -711,13 +970,14 @@ class MatrixChannel(BaseChannel): ) return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) + limit_bytes = await self._effective_media_limit_bytes() declared_size = self._event_declared_size_bytes(event) - if declared_size is not None and declared_size > self.config.max_inbound_media_bytes: + if declared_size is not None and declared_size > limit_bytes: logger.warning( "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", room.room_id, declared_size, - self.config.max_inbound_media_bytes, + limit_bytes, ) return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) @@ -733,12 +993,12 @@ class MatrixChannel(BaseChannel): return None, MATRIX_ATTACHMENT_FAILED_TEMPLATE.format(filename) data = decrypted - if len(data) > self.config.max_inbound_media_bytes: + if len(data) > limit_bytes: logger.warning( "Matrix attachment skipped in room {}: downloaded size {} exceeds limit {}", room.room_id, len(data), - self.config.max_inbound_media_bytes, + limit_bytes, ) return None, MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0861073..690b9b2 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -76,8 +76,8 @@ class MatrixConfig(Base): e2ee_enabled: bool = True # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. sync_stop_grace_seconds: int = 2 - # Max attachment size accepted from inbound Matrix media events. - max_inbound_media_bytes: int = 20 * 1024 * 1024 + # Max attachment size accepted for Matrix media handling (inbound + outbound). + max_media_bytes: int = 20 * 1024 * 1024 allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 164ec2e..d625aca 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -48,12 +48,16 @@ class _FakeAsyncClient: self.room_send_calls: list[dict[str, object]] = [] self.typing_calls: list[tuple[str, bool, int]] = [] self.download_calls: list[dict[str, object]] = [] + self.upload_calls: list[dict[str, object]] = [] self.download_response: object | None = None self.download_bytes: bytes = b"media" self.download_content_type: str = "application/octet-stream" self.download_filename: str | None = None + self.upload_response: object | None = None + self.content_repository_config_response: object = SimpleNamespace(upload_size=None) self.raise_on_send = False self.raise_on_typing = False + self.raise_on_upload = False def add_event_callback(self, callback, event_type) -> None: self.callbacks.append((callback, event_type)) @@ -108,6 +112,32 @@ class _FakeAsyncClient: filename=self.download_filename, ) + async def upload( + self, + data_provider, + content_type: str | None = None, + filename: str | None = None, + filesize: int | None = None, + encrypt: bool = False, + ): + if self.raise_on_upload: + raise RuntimeError("upload failed") + self.upload_calls.append( + { + "data_provider": data_provider, + "content_type": content_type, + "filename": filename, + "filesize": filesize, + "encrypt": encrypt, + } + ) + if self.upload_response is not None: + return self.upload_response + return SimpleNamespace(content_uri="mxc://example.org/uploaded") + + async def content_repository_config(self): + return self.content_repository_config_response + async def close(self) -> None: return None @@ -523,7 +553,7 @@ async def test_on_media_message_respects_declared_size_limit( ) -> None: monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) - channel = MatrixChannel(_make_config(max_inbound_media_bytes=3), MessageBus()) + channel = MatrixChannel(_make_config(max_media_bytes=3), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -552,6 +582,42 @@ async def test_on_media_message_respects_declared_size_limit( assert "[attachment: large.bin - too large]" in handled[0]["content"] +@pytest.mark.asyncio +async def test_on_media_message_uses_server_limit_when_smaller_than_local_limit( + monkeypatch, tmp_path +) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + + channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.content_repository_config_response = SimpleNamespace(upload_size=3) + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="large.bin", + url="mxc://example.org/large", + event_id="$event2_server", + source={"content": {"msgtype": "m.file", "info": {"size": 5}}}, + ) + + await channel._on_media_message(room, event) + + assert client.download_calls == [] + assert len(handled) == 1 + assert handled[0]["media"] == [] + assert handled[0]["metadata"]["attachments"] == [] + assert "[attachment: large.bin - too large]" in handled[0]["content"] + + @pytest.mark.asyncio async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None: monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) @@ -690,6 +756,107 @@ async def test_send_clears_typing_after_send() -> None: assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) +@pytest.mark.asyncio +async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + file_path = tmp_path / "test.txt" + file_path.write_text("hello", encoding="utf-8") + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="Please review.", + media=[str(file_path)], + ) + ) + + assert len(client.upload_calls) == 1 + assert client.upload_calls[0]["filename"] == "test.txt" + assert client.upload_calls[0]["filesize"] == 5 + assert len(client.room_send_calls) == 2 + assert client.room_send_calls[0]["content"]["msgtype"] == "m.file" + assert client.room_send_calls[0]["content"]["url"] == "mxc://example.org/uploaded" + assert client.room_send_calls[1]["content"]["body"] == "Please review." + + +@pytest.mark.asyncio +async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + missing_path = tmp_path / "missing.txt" + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content=f"[attachment: {missing_path}]", + ) + ) + + assert client.upload_calls == [] + assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"]["body"] == f"[attachment: {missing_path}]" + + +@pytest.mark.asyncio +async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + file_path = tmp_path / "external.txt" + file_path.write_text("outside", encoding="utf-8") + + channel = MatrixChannel( + _make_config(), + MessageBus(), + restrict_to_workspace=True, + workspace=workspace, + ) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="", + media=[str(file_path)], + ) + ) + + assert client.upload_calls == [] + assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"]["body"] == "[attachment: external.txt - upload failed]" + + +@pytest.mark.asyncio +async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_path) -> None: + channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.content_repository_config_response = SimpleNamespace(upload_size=3) + channel.client = client + + file_path = tmp_path / "tiny.txt" + file_path.write_text("hello", encoding="utf-8") + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="", + media=[str(file_path)], + ) + ) + + assert client.upload_calls == [] + assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"]["body"] == "[attachment: tiny.txt - too large]" + + @pytest.mark.asyncio async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None: channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus()) From a28ae51ce9acaff5be585a43390d4b81c4d360a7 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 10:55:51 +0100 Subject: [PATCH 159/415] fix(matrix): handle matrix-nio upload tuple response --- nanobot/channels/matrix.py | 3 ++- tests/test_matrix_channel.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 28c3924..720aef7 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -517,12 +517,13 @@ class MatrixChannel(BaseChannel): return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" - upload_response = await self.client.upload( + upload_result = await self.client.upload( data, content_type=mime, filename=filename, filesize=len(data), ) + upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result if isinstance(upload_response, UploadError): logger.warning( "Matrix outbound attachment upload failed for {}: {}", diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index d625aca..533d615 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -133,7 +133,7 @@ class _FakeAsyncClient: ) if self.upload_response is not None: return self.upload_response - return SimpleNamespace(content_uri="mxc://example.org/uploaded") + return SimpleNamespace(content_uri="mxc://example.org/uploaded"), None async def content_repository_config(self): return self.content_repository_config_response From d4d87bb4e523d7a9f3691689478b6e9adbf763b8 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 10:57:00 +0100 Subject: [PATCH 160/415] fix(matrix): block outbound media when maxMediaBytes is zero --- nanobot/channels/matrix.py | 10 +++++++++- tests/test_matrix_channel.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 720aef7..eb921da 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -496,7 +496,15 @@ class MatrixChannel(BaseChannel): ) return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - if limit_bytes and size_bytes > limit_bytes: + if limit_bytes <= 0: + logger.warning( + "Matrix outbound attachment skipped: media limit {} blocks all uploads for {}", + limit_bytes, + resolved, + ) + return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + + if size_bytes > limit_bytes: logger.warning( "Matrix outbound attachment skipped: {} bytes exceeds limit {} for {}", size_bytes, diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 533d615..222f14e 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -857,6 +857,29 @@ async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_p assert client.room_send_calls[0]["content"]["body"] == "[attachment: tiny.txt - too large]" +@pytest.mark.asyncio +async def test_send_blocks_all_outbound_media_when_limit_is_zero(tmp_path) -> None: + channel = MatrixChannel(_make_config(max_media_bytes=0), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + file_path = tmp_path / "empty.txt" + file_path.write_bytes(b"") + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="", + media=[str(file_path)], + ) + ) + + assert client.upload_calls == [] + assert len(client.room_send_calls) == 1 + assert client.room_send_calls[0]["content"]["body"] == "[attachment: empty.txt - too large]" + + @pytest.mark.asyncio async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None: channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus()) From 6a4066575313980c519fb7966f56b5a450973fcb Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Wed, 11 Feb 2026 11:50:36 +0100 Subject: [PATCH 161/415] feat(matrix): support outbound attachments via message tool - extend message tool with optional media paths for channel delivery - switch Matrix uploads to stream providers and handle encrypted-room payloads - add/expand tests for message tool media forwarding and Matrix upload edge cases --- nanobot/agent/context.py | 7 +++- nanobot/agent/tools/message.py | 77 ++++++++++++++++++---------------- nanobot/channels/matrix.py | 51 +++++++++++++++------- tests/test_matrix_channel.py | 75 +++++++++++++++++++++++++++++++++ tests/test_message_tool.py | 37 ++++++++++++++++ 5 files changed, 195 insertions(+), 52 deletions(-) create mode 100644 tests/test_message_tool.py diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 876d43d..0253415 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -102,8 +102,11 @@ Your workspace is at: {workspace_path} - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). -For normal conversation, just respond with text - do not call the message tool. +Use the 'message' tool only when you need explicit channel delivery behavior: +- Send to a different channel/chat than the current session +- Send one or more file attachments via `media` (local file paths) +For normal conversation text, respond directly without calling the message tool. +Do not claim that attachments are impossible if a channel supports file send and you can provide local paths. Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 3853725..c5efbf3 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,6 +1,6 @@ """Message tool for sending messages to users.""" -from typing import Any, Callable, Awaitable +from typing import Any, Awaitable, Callable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage @@ -8,84 +8,89 @@ from nanobot.bus.events import OutboundMessage class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( - self, + self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", - default_chat_id: str = "" + default_chat_id: str = "", ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id - + def set_context(self, channel: str, chat_id: str) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id - + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - + @property def name(self) -> str: return "message" - + @property def description(self) -> str: - return "Send a message to the user. Use this when you want to communicate something." - + return ( + "Send a message to the user. Supports optional media/attachment " + "paths for channels that can send files." + ) + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "content": { - "type": "string", - "description": "The message content to send" - }, + "content": {"type": "string", "description": "The message content to send"}, "channel": { "type": "string", - "description": "Optional: target channel (telegram, discord, etc.)" + "description": "Optional: target channel (telegram, discord, etc.)", }, - "chat_id": { - "type": "string", - "description": "Optional: target chat/user ID" + "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, + "media": { + "type": "array", + "description": "Optional: local file paths to send as attachments", + "items": {"type": "string"}, }, + "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, "media": { "type": "array", "items": {"type": "string"}, - "description": "Optional: list of file paths to attach (images, audio, documents)" - } + "description": "Optional: list of file paths to attach (images, audio, documents)", + }, }, - "required": ["content"] + "required": ["content"], } - + async def execute( - self, - content: str, - channel: str | None = None, + self, + content: str, + channel: str | None = None, chat_id: str | None = None, media: list[str] | None = None, - **kwargs: Any + **kwargs: Any, ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id - + if not channel or not chat_id: return "Error: No target channel/chat specified" - + if not self._send_callback: return "Error: Message sending not configured" - - msg = OutboundMessage( - channel=channel, - chat_id=chat_id, - content=content, - media=media or [] - ) - + + media_paths: list[str] = [] + for item in media or []: + if isinstance(item, str): + candidate = item.strip() + if candidate: + media_paths.append(candidate) + + msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media or []) + try: await self._send_callback(msg) media_info = f" with {len(media)} attachments" if media else "" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index eb921da..14d897b 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -374,6 +374,7 @@ class MatrixChannel(BaseChannel): mime: str, size_bytes: int, mxc_url: str, + encryption_info: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build Matrix content payload for an uploaded file/image/audio/video.""" msgtype = "m.file" @@ -384,11 +385,10 @@ class MatrixChannel(BaseChannel): elif mime.startswith("video/"): msgtype = "m.video" - return { + content: dict[str, Any] = { "msgtype": msgtype, "body": filename, "filename": filename, - "url": mxc_url, "info": { "mimetype": mime, "size": size_bytes, @@ -396,6 +396,24 @@ class MatrixChannel(BaseChannel): "m.mentions": {}, } + if encryption_info: + # Encrypted media events use `file` metadata (with url/hash/key/iv), + # while unencrypted media events use top-level `url`. + file_info = dict(encryption_info) + file_info["url"] = mxc_url + content["file"] = file_info + else: + content["url"] = mxc_url + + return content + + def _is_encrypted_room(self, room_id: str) -> bool: + """Return True if the Matrix room is known as encrypted.""" + if not self.client: + return False + room = getattr(self.client, "rooms", {}).get(room_id) + return bool(getattr(room, "encrypted", False)) + async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: """Send Matrix m.room.message content with configured E2EE send options.""" if not self.client: @@ -513,25 +531,29 @@ class MatrixChannel(BaseChannel): ) return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" + encrypt_upload = self.config.e2ee_enabled and self._is_encrypted_room(room_id) try: - data = resolved.read_bytes() - except OSError as e: + with resolved.open("rb") as data_provider: + upload_result = await self.client.upload( + data_provider, + content_type=mime, + filename=filename, + encrypt=encrypt_upload, + filesize=size_bytes, + ) + except Exception as e: logger.warning( - "Matrix outbound attachment read failed for {} ({}): {}", + "Matrix outbound attachment upload failed for {} ({}): {}", resolved, type(e).__name__, str(e), ) return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - - mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" - upload_result = await self.client.upload( - data, - content_type=mime, - filename=filename, - filesize=len(data), - ) upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result + encryption_info: dict[str, Any] | None = None + if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict): + encryption_info = upload_result[1] if isinstance(upload_response, UploadError): logger.warning( "Matrix outbound attachment upload failed for {}: {}", @@ -552,8 +574,9 @@ class MatrixChannel(BaseChannel): content = self._build_outbound_attachment_content( filename=filename, mime=mime, - size_bytes=len(data), + size_bytes=size_bytes, mxc_url=mxc_url, + encryption_info=encryption_info, ) try: await self._send_room_content(room_id, content) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 222f14e..c71bc52 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -45,6 +45,7 @@ class _FakeAsyncClient: self.join_calls: list[str] = [] self.callbacks: list[tuple[object, object]] = [] self.response_callbacks: list[tuple[object, object]] = [] + self.rooms: dict[str, object] = {} self.room_send_calls: list[dict[str, object]] = [] self.typing_calls: list[tuple[str, bool, int]] = [] self.download_calls: list[dict[str, object]] = [] @@ -122,6 +123,11 @@ class _FakeAsyncClient: ): if self.raise_on_upload: raise RuntimeError("upload failed") + if isinstance(data_provider, (bytes, bytearray)): + raise TypeError( + f"data_provider type {type(data_provider)!r} is not of a usable type " + "(Callable, IOBase)" + ) self.upload_calls.append( { "data_provider": data_provider, @@ -133,6 +139,16 @@ class _FakeAsyncClient: ) if self.upload_response is not None: return self.upload_response + if encrypt: + return ( + SimpleNamespace(content_uri="mxc://example.org/uploaded"), + { + "v": "v2", + "iv": "iv", + "hashes": {"sha256": "hash"}, + "key": {"alg": "A256CTR", "k": "key"}, + }, + ) return SimpleNamespace(content_uri="mxc://example.org/uploaded"), None async def content_repository_config(self): @@ -775,6 +791,8 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: ) assert len(client.upload_calls) == 1 + assert not isinstance(client.upload_calls[0]["data_provider"], (bytes, bytearray)) + assert hasattr(client.upload_calls[0]["data_provider"], "read") assert client.upload_calls[0]["filename"] == "test.txt" assert client.upload_calls[0]["filesize"] == 5 assert len(client.room_send_calls) == 2 @@ -783,6 +801,36 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: assert client.room_send_calls[1]["content"]["body"] == "Please review." +@pytest.mark.asyncio +async def test_send_uses_encrypted_media_payload_in_encrypted_room(tmp_path) -> None: + channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.rooms["!encrypted:matrix.org"] = SimpleNamespace(encrypted=True) + channel.client = client + + file_path = tmp_path / "secret.txt" + file_path.write_text("topsecret", encoding="utf-8") + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!encrypted:matrix.org", + content="", + media=[str(file_path)], + ) + ) + + assert len(client.upload_calls) == 1 + assert client.upload_calls[0]["encrypt"] is True + assert len(client.room_send_calls) == 1 + content = client.room_send_calls[0]["content"] + assert content["msgtype"] == "m.file" + assert "file" in content + assert "url" not in content + assert content["file"]["url"] == "mxc://example.org/uploaded" + assert content["file"]["hashes"]["sha256"] == "hash" + + @pytest.mark.asyncio async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> None: channel = MatrixChannel(_make_config(), MessageBus()) @@ -833,6 +881,33 @@ async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) - assert client.room_send_calls[0]["content"]["body"] == "[attachment: external.txt - upload failed]" +@pytest.mark.asyncio +async def test_send_handles_upload_exception_and_reports_failure(tmp_path) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.raise_on_upload = True + channel.client = client + + file_path = tmp_path / "broken.txt" + file_path.write_text("hello", encoding="utf-8") + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="Please review.", + media=[str(file_path)], + ) + ) + + assert len(client.upload_calls) == 0 + assert len(client.room_send_calls) == 1 + assert ( + client.room_send_calls[0]["content"]["body"] + == "Please review.\n[attachment: broken.txt - upload failed]" + ) + + @pytest.mark.asyncio async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_path) -> None: channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus()) diff --git a/tests/test_message_tool.py b/tests/test_message_tool.py new file mode 100644 index 0000000..f7bfad9 --- /dev/null +++ b/tests/test_message_tool.py @@ -0,0 +1,37 @@ +import pytest + +from nanobot.agent.tools.message import MessageTool +from nanobot.bus.events import OutboundMessage + + +@pytest.mark.asyncio +async def test_message_tool_sends_media_paths_with_default_context() -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool( + send_callback=_send, + default_channel="test-channel", + default_chat_id="!room:example.org", + ) + + result = await tool.execute( + content="Here is the file.", + media=[" /tmp/test.txt ", "", " ", "/tmp/report.pdf"], + ) + + assert result == "Message sent to test-channel:!room:example.org" + assert len(sent) == 1 + assert sent[0].channel == "test-channel" + assert sent[0].chat_id == "!room:example.org" + assert sent[0].content == "Here is the file." + assert sent[0].media == ["/tmp/test.txt", "/tmp/report.pdf"] + + +@pytest.mark.asyncio +async def test_message_tool_returns_error_when_no_target_context() -> None: + tool = MessageTool() + result = await tool.execute(content="test") + assert result == "Error: No target channel/chat specified" From 705d5738e3086470075e53427d43e5eaee2eaaf4 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Thu, 12 Feb 2026 17:39:17 +0100 Subject: [PATCH 162/415] feat(matrix): reply in threads with fallback relations Propagate Matrix thread metadata from inbound events and attach m.relates_to (rel_type=m.thread, m.in_reply_to, is_falling_back=true) to outbound messages including attachments. Add tests for thread metadata and thread replies. --- nanobot/channels/matrix.py | 55 ++++++++++--- tests/test_matrix_channel.py | 154 +++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 10 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 14d897b..1a6146a 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -480,8 +480,20 @@ class MatrixChannel(BaseChannel): return 0 return min(local_limit, server_limit) + def _configured_media_limit_bytes(self) -> int: + """Resolve the configured local media limit with backward compatibility.""" + for name in ("max_inbound_media_bytes", "max_media_bytes"): + value = getattr(self.config, name, None) + if isinstance(value, int): + return value + return 0 + async def _upload_and_send_attachment( - self, room_id: str, path: Path, limit_bytes: int + self, + room_id: str, + path: Path, + limit_bytes: int, + relates_to: dict[str, Any] | None = None, ) -> str | None: """Upload one local file to Matrix and send it as a media message.""" if not self.client: @@ -578,6 +590,8 @@ class MatrixChannel(BaseChannel): mxc_url=mxc_url, encryption_info=encryption_info, ) + if relates_to: + content["m.relates_to"] = relates_to try: await self._send_room_content(room_id, content) except Exception as e: @@ -596,6 +610,7 @@ class MatrixChannel(BaseChannel): text = msg.content or "" candidates = self._collect_outbound_media_candidates(msg.media) + relates_to = self._build_thread_relates_to(msg.metadata) try: failures: list[str] = [] @@ -607,6 +622,7 @@ class MatrixChannel(BaseChannel): room_id=msg.chat_id, path=path, limit_bytes=limit_bytes, + relates_to=relates_to, ) if failure_marker: failures.append(failure_marker) @@ -618,7 +634,10 @@ class MatrixChannel(BaseChannel): text = "\n".join(failures) if text or not candidates: - await self._send_room_content(msg.chat_id, _build_matrix_text_content(text)) + content = _build_matrix_text_content(text) + if relates_to: + content["m.relates_to"] = relates_to + await self._send_room_content(msg.chat_id, content) finally: await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) @@ -793,7 +812,7 @@ class MatrixChannel(BaseChannel): content = source.get("content") return content if isinstance(content, dict) else {} - def _event_thread_root_id(self, event: RoomMessage) -> str | None: + def _event_thread_root_id(self, event: Any) -> str | None: """Return thread root event_id if this message is inside a thread.""" content = self._event_source_content(event) relates_to = content.get("m.relates_to") @@ -804,7 +823,7 @@ class MatrixChannel(BaseChannel): root_id = relates_to.get("event_id") return root_id if isinstance(root_id, str) and root_id else None - def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: + def _thread_metadata(self, event: Any) -> dict[str, str] | None: """Build metadata used to reply within a thread.""" root_id = self._event_thread_root_id(event) if not root_id: @@ -833,7 +852,7 @@ class MatrixChannel(BaseChannel): "is_falling_back": True, } - def _event_attachment_type(self, event: MatrixMediaEvent) -> str: + def _event_attachment_type(self, event: Any) -> str: """Map Matrix event payload/type to a stable attachment kind.""" msgtype = self._event_source_content(event).get("msgtype") if msgtype == "m.image": @@ -1073,11 +1092,20 @@ class MatrixChannel(BaseChannel): await self._start_typing_keepalive(room.room_id) try: + metadata: dict[str, Any] = { + "room": getattr(room, "display_name", room.room_id), + } + event_id = getattr(event, "event_id", None) + if isinstance(event_id, str) and event_id: + metadata["event_id"] = event_id + thread_meta = self._thread_metadata(event) + if thread_meta: + metadata.update(thread_meta) await self._handle_message( sender_id=event.sender, chat_id=room.room_id, content=event.body, - metadata={"room": getattr(room, "display_name", room.room_id)}, + metadata=metadata, ) except Exception: await self._stop_typing_keepalive(room.room_id, clear_typing=True) @@ -1107,15 +1135,22 @@ class MatrixChannel(BaseChannel): await self._start_typing_keepalive(room.room_id) try: + metadata: dict[str, Any] = { + "room": getattr(room, "display_name", room.room_id), + "attachments": attachments, + } + event_id = getattr(event, "event_id", None) + if isinstance(event_id, str) and event_id: + metadata["event_id"] = event_id + thread_meta = self._thread_metadata(event) + if thread_meta: + metadata.update(thread_meta) await self._handle_message( sender_id=event.sender, chat_id=room.room_id, content="\n".join(content_parts), media=media_paths, - metadata={ - "room": getattr(room, "display_name", room.room_id), - "attachments": attachments, - }, + metadata=metadata, ) except Exception: await self._stop_typing_keepalive(room.room_id, clear_typing=True) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index c71bc52..47d7ec4 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -510,6 +510,43 @@ async def test_on_message_room_mention_requires_opt_in() -> None: assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)] +@pytest.mark.asyncio +async def test_on_message_sets_thread_metadata_when_threaded_event() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="Hello", + event_id="$reply1", + source={ + "content": { + "m.relates_to": { + "rel_type": "m.thread", + "event_id": "$root1", + } + } + }, + ) + + await channel._on_message(room, event) + + assert len(handled) == 1 + metadata = handled[0]["metadata"] + assert metadata["thread_root_event_id"] == "$root1" + assert metadata["thread_reply_to_event_id"] == "$reply1" + assert metadata["event_id"] == "$reply1" + + @pytest.mark.asyncio async def test_on_media_message_downloads_attachment_and_sets_metadata( monkeypatch, tmp_path @@ -563,6 +600,51 @@ async def test_on_media_message_downloads_attachment_and_sets_metadata( assert "[attachment: " in handled[0]["content"] +@pytest.mark.asyncio +async def test_on_media_message_sets_thread_metadata_when_threaded_event( + monkeypatch, tmp_path +) -> None: + monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.download_bytes = b"image" + channel.client = client + + handled: list[dict[str, object]] = [] + + async def _fake_handle_message(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = _fake_handle_message # type: ignore[method-assign] + + room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2) + event = SimpleNamespace( + sender="@alice:matrix.org", + body="photo.png", + url="mxc://example.org/mediaid", + event_id="$event1", + source={ + "content": { + "msgtype": "m.image", + "info": {"mimetype": "image/png", "size": 5}, + "m.relates_to": { + "rel_type": "m.thread", + "event_id": "$root1", + }, + } + }, + ) + + await channel._on_media_message(room, event) + + assert len(handled) == 1 + metadata = handled[0]["metadata"] + assert metadata["thread_root_event_id"] == "$root1" + assert metadata["thread_reply_to_event_id"] == "$event1" + assert metadata["event_id"] == "$event1" + + @pytest.mark.asyncio async def test_on_media_message_respects_declared_size_limit( monkeypatch, tmp_path @@ -801,6 +883,34 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: assert client.room_send_calls[1]["content"]["body"] == "Please review." +@pytest.mark.asyncio +async def test_send_adds_thread_relates_to_for_thread_metadata() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + metadata = { + "thread_root_event_id": "$root1", + "thread_reply_to_event_id": "$reply1", + } + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="Hi", + metadata=metadata, + ) + ) + + content = client.room_send_calls[0]["content"] + assert content["m.relates_to"] == { + "rel_type": "m.thread", + "event_id": "$root1", + "m.in_reply_to": {"event_id": "$reply1"}, + "is_falling_back": True, + } + + @pytest.mark.asyncio async def test_send_uses_encrypted_media_payload_in_encrypted_room(tmp_path) -> None: channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus()) @@ -851,6 +961,50 @@ async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> assert client.room_send_calls[0]["content"]["body"] == f"[attachment: {missing_path}]" +@pytest.mark.asyncio +async def test_send_passes_thread_relates_to_to_attachment_upload(monkeypatch) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + channel._server_upload_limit_checked = True + channel._server_upload_limit_bytes = None + + captured: dict[str, object] = {} + + async def _fake_upload_and_send_attachment( + *, + room_id: str, + path: Path, + limit_bytes: int, + relates_to: dict[str, object] | None = None, + ) -> str | None: + captured["relates_to"] = relates_to + return None + + monkeypatch.setattr(channel, "_upload_and_send_attachment", _fake_upload_and_send_attachment) + + metadata = { + "thread_root_event_id": "$root1", + "thread_reply_to_event_id": "$reply1", + } + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="Hi", + media=["/tmp/fake.txt"], + metadata=metadata, + ) + ) + + assert captured["relates_to"] == { + "rel_type": "m.thread", + "event_id": "$root1", + "m.in_reply_to": {"event_id": "$reply1"}, + "is_falling_back": True, + } + + @pytest.mark.asyncio async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) -> None: workspace = tmp_path / "workspace" From 334078e242d246cf25ef97e5f052de3745e9b655 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:04:11 +0100 Subject: [PATCH 163/415] fix(message): apply media path filtering and drop attachment count from return value Conflict resolution correction: HEAD's message.py retained raw media list and attachment count in return string, but tests from 3de30bb require stripped/filtered media_paths and a plain return message. Aligns HEAD behavior with cherry-picked tests. --- nanobot/agent/tools/message.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index c5efbf3..7cac933 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -89,11 +89,10 @@ class MessageTool(Tool): if candidate: media_paths.append(candidate) - msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media or []) + msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media_paths) try: await self._send_callback(msg) - media_info = f" with {len(media)} attachments" if media else "" - return f"Message sent to {channel}:{chat_id}{media_info}" + return f"Message sent to {channel}:{chat_id}" except Exception as e: return f"Error sending message: {str(e)}" From 36d650e47560b223f4d693ab9fe3ecb53355f751 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:05:00 +0100 Subject: [PATCH 164/415] revert: restore message.py to tanishra baseline (out of scope) --- nanobot/agent/tools/message.py | 80 ++++++++++++++++------------------ 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 7cac933..3853725 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,6 +1,6 @@ """Message tool for sending messages to users.""" -from typing import Any, Awaitable, Callable +from typing import Any, Callable, Awaitable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage @@ -8,91 +8,87 @@ from nanobot.bus.events import OutboundMessage class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( - self, + self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", - default_chat_id: str = "", + default_chat_id: str = "" ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id - + def set_context(self, channel: str, chat_id: str) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id - + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - + @property def name(self) -> str: return "message" - + @property def description(self) -> str: - return ( - "Send a message to the user. Supports optional media/attachment " - "paths for channels that can send files." - ) - + return "Send a message to the user. Use this when you want to communicate something." + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "content": {"type": "string", "description": "The message content to send"}, + "content": { + "type": "string", + "description": "The message content to send" + }, "channel": { "type": "string", - "description": "Optional: target channel (telegram, discord, etc.)", + "description": "Optional: target channel (telegram, discord, etc.)" }, - "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, - "media": { - "type": "array", - "description": "Optional: local file paths to send as attachments", - "items": {"type": "string"}, + "chat_id": { + "type": "string", + "description": "Optional: target chat/user ID" }, - "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, "media": { "type": "array", "items": {"type": "string"}, - "description": "Optional: list of file paths to attach (images, audio, documents)", - }, + "description": "Optional: list of file paths to attach (images, audio, documents)" + } }, - "required": ["content"], + "required": ["content"] } - + async def execute( - self, - content: str, - channel: str | None = None, + self, + content: str, + channel: str | None = None, chat_id: str | None = None, media: list[str] | None = None, - **kwargs: Any, + **kwargs: Any ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id - + if not channel or not chat_id: return "Error: No target channel/chat specified" - + if not self._send_callback: return "Error: Message sending not configured" - - media_paths: list[str] = [] - for item in media or []: - if isinstance(item, str): - candidate = item.strip() - if candidate: - media_paths.append(candidate) - - msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media_paths) - + + msg = OutboundMessage( + channel=channel, + chat_id=chat_id, + content=content, + media=media or [] + ) + try: await self._send_callback(msg) - return f"Message sent to {channel}:{chat_id}" + media_info = f" with {len(media)} attachments" if media else "" + return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: return f"Error sending message: {str(e)}" From e8a4671565cf19bc46e3beb5ff32ada0ee4035eb Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:06:13 +0100 Subject: [PATCH 165/415] test: remove message tool media test (message.py changes out of scope) --- tests/test_message_tool.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/tests/test_message_tool.py b/tests/test_message_tool.py index f7bfad9..dc8e11d 100644 --- a/tests/test_message_tool.py +++ b/tests/test_message_tool.py @@ -1,33 +1,6 @@ import pytest from nanobot.agent.tools.message import MessageTool -from nanobot.bus.events import OutboundMessage - - -@pytest.mark.asyncio -async def test_message_tool_sends_media_paths_with_default_context() -> None: - sent: list[OutboundMessage] = [] - - async def _send(msg: OutboundMessage) -> None: - sent.append(msg) - - tool = MessageTool( - send_callback=_send, - default_channel="test-channel", - default_chat_id="!room:example.org", - ) - - result = await tool.execute( - content="Here is the file.", - media=[" /tmp/test.txt ", "", " ", "/tmp/report.pdf"], - ) - - assert result == "Message sent to test-channel:!room:example.org" - assert len(sent) == 1 - assert sent[0].channel == "test-channel" - assert sent[0].chat_id == "!room:example.org" - assert sent[0].content == "Here is the file." - assert sent[0].media == ["/tmp/test.txt", "/tmp/report.pdf"] @pytest.mark.asyncio From 52d086d46abfbc1eb8b83676136a67f7fb61c491 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:08:04 +0100 Subject: [PATCH 166/415] revert: restore context.py and manager.py to tanishra baseline (out of scope) --- nanobot/agent/context.py | 7 +-- nanobot/channels/manager.py | 88 +++++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 43 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 0253415..876d43d 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -102,11 +102,8 @@ Your workspace is at: {workspace_path} - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Use the 'message' tool only when you need explicit channel delivery behavior: -- Send to a different channel/chat than the current session -- Send one or more file attachments via `media` (local file paths) -For normal conversation text, respond directly without calling the message tool. -Do not claim that attachments are impossible if a channel supports file send and you can provide local paths. +Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). +For normal conversation, just respond with text - do not call the message tool. Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 998d90c..e860d26 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -16,29 +16,28 @@ from nanobot.config.schema import Config class ChannelManager: """ Manages chat channels and coordinates message routing. - + Responsibilities: - Initialize enabled channels (Telegram, WhatsApp, etc.) - Start/stop channels - Route outbound messages """ - + def __init__(self, config: Config, bus: MessageBus): self.config = config self.bus = bus self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None - + self._init_channels() - + def _init_channels(self) -> None: """Initialize channels based on config.""" - + # Telegram channel if self.config.channels.telegram.enabled: try: from nanobot.channels.telegram import TelegramChannel - self.channels["telegram"] = TelegramChannel( self.config.channels.telegram, self.bus, @@ -47,13 +46,14 @@ class ChannelManager: logger.info("Telegram channel enabled") except ImportError as e: logger.warning(f"Telegram channel not available: {e}") - + # WhatsApp channel if self.config.channels.whatsapp.enabled: try: from nanobot.channels.whatsapp import WhatsAppChannel - - self.channels["whatsapp"] = WhatsAppChannel(self.config.channels.whatsapp, self.bus) + self.channels["whatsapp"] = WhatsAppChannel( + self.config.channels.whatsapp, self.bus + ) logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") @@ -62,18 +62,20 @@ class ChannelManager: if self.config.channels.discord.enabled: try: from nanobot.channels.discord import DiscordChannel - - self.channels["discord"] = DiscordChannel(self.config.channels.discord, self.bus) + self.channels["discord"] = DiscordChannel( + self.config.channels.discord, self.bus + ) logger.info("Discord channel enabled") except ImportError as e: logger.warning(f"Discord channel not available: {e}") - + # Feishu channel if self.config.channels.feishu.enabled: try: from nanobot.channels.feishu import FeishuChannel - - self.channels["feishu"] = FeishuChannel(self.config.channels.feishu, self.bus) + self.channels["feishu"] = FeishuChannel( + self.config.channels.feishu, self.bus + ) logger.info("Feishu channel enabled") except ImportError as e: logger.warning(f"Feishu channel not available: {e}") @@ -83,7 +85,9 @@ class ChannelManager: try: from nanobot.channels.mochat import MochatChannel - self.channels["mochat"] = MochatChannel(self.config.channels.mochat, self.bus) + self.channels["mochat"] = MochatChannel( + self.config.channels.mochat, self.bus + ) logger.info("Mochat channel enabled") except ImportError as e: logger.warning(f"Mochat channel not available: {e}") @@ -92,8 +96,9 @@ class ChannelManager: if self.config.channels.dingtalk.enabled: try: from nanobot.channels.dingtalk import DingTalkChannel - - self.channels["dingtalk"] = DingTalkChannel(self.config.channels.dingtalk, self.bus) + self.channels["dingtalk"] = DingTalkChannel( + self.config.channels.dingtalk, self.bus + ) logger.info("DingTalk channel enabled") except ImportError as e: logger.warning(f"DingTalk channel not available: {e}") @@ -102,8 +107,9 @@ class ChannelManager: if self.config.channels.email.enabled: try: from nanobot.channels.email import EmailChannel - - self.channels["email"] = EmailChannel(self.config.channels.email, self.bus) + self.channels["email"] = EmailChannel( + self.config.channels.email, self.bus + ) logger.info("Email channel enabled") except ImportError as e: logger.warning(f"Email channel not available: {e}") @@ -112,8 +118,9 @@ class ChannelManager: if self.config.channels.slack.enabled: try: from nanobot.channels.slack import SlackChannel - - self.channels["slack"] = SlackChannel(self.config.channels.slack, self.bus) + self.channels["slack"] = SlackChannel( + self.config.channels.slack, self.bus + ) logger.info("Slack channel enabled") except ImportError as e: logger.warning(f"Slack channel not available: {e}") @@ -122,7 +129,6 @@ class ChannelManager: if self.config.channels.qq.enabled: try: from nanobot.channels.qq import QQChannel - self.channels["qq"] = QQChannel( self.config.channels.qq, self.bus, @@ -130,7 +136,7 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning(f"QQ channel not available: {e}") - + async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" try: @@ -143,23 +149,23 @@ class ChannelManager: if not self.channels: logger.warning("No channels enabled") return - + # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info(f"Starting {name} channel...") tasks.append(asyncio.create_task(self._start_channel(name, channel))) - + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) - + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") - + # Stop dispatcher if self._dispatch_task: self._dispatch_task.cancel() @@ -167,7 +173,7 @@ class ChannelManager: await self._dispatch_task except asyncio.CancelledError: pass - + # Stop all channels for name, channel in self.channels.items(): try: @@ -175,15 +181,18 @@ class ChannelManager: logger.info(f"Stopped {name} channel") except Exception as e: logger.error(f"Error stopping {name}: {e}") - + async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" logger.info("Outbound dispatcher started") - + while True: try: - msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0) - + msg = await asyncio.wait_for( + self.bus.consume_outbound(), + timeout=1.0 + ) + channel = self.channels.get(msg.channel) if channel: try: @@ -192,23 +201,26 @@ class ChannelManager: logger.error(f"Error sending to {msg.channel}: {e}") else: logger.warning(f"Unknown channel: {msg.channel}") - + except asyncio.TimeoutError: continue except asyncio.CancelledError: break - + def get_channel(self, name: str) -> BaseChannel | None: """Get a channel by name.""" return self.channels.get(name) - + def get_status(self) -> dict[str, Any]: """Get status of all channels.""" return { - name: {"enabled": True, "running": channel.is_running} + name: { + "enabled": True, + "running": channel.is_running + } for name, channel in self.channels.items() } - + @property def enabled_channels(self) -> list[str]: """Get list of enabled channel names.""" From dd61a9143aa8b97bb15742c192c400f26240d400 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:11:29 +0100 Subject: [PATCH 167/415] fix: remove accidental whitespace-only formatting changes from schema.py --- nanobot/config/schema.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 690b9b2..27bba4d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -29,9 +29,7 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = ( - None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" - ) + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" class FeishuConfig(Base): @@ -108,9 +106,7 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = ( - True # If false, inbound email is read but no automatic reply is sent - ) + auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent poll_interval_seconds: int = 30 mark_seen: bool = True max_body_chars: int = 12000 @@ -187,9 +183,7 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # 机器人 ID (AppID) from q.qq.com secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com - allow_from: list[str] = Field( - default_factory=list - ) # Allowed user openids (empty = public access) + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) class ChannelsConfig(Base): @@ -248,9 +242,7 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field( - default_factory=ProviderConfig - ) # SiliconFlow (硅基流动) API gateway + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -313,9 +305,7 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider( - self, model: str | None = None - ) -> tuple["ProviderConfig | None", str | None]: + def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS From 13561772ad684a33b5de22822363273b961ecfde Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:15:32 +0100 Subject: [PATCH 168/415] fix(matrix): align with fork/main (docstrings, type annotations, formatting) --- nanobot/channels/matrix.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 1a6146a..a3bf482 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -1,3 +1,5 @@ +"""Matrix channel implementation for inbound sync and outbound message/media delivery.""" + import asyncio import logging import mimetypes @@ -238,6 +240,7 @@ class MatrixChannel(BaseChannel): restrict_to_workspace: bool = False, workspace: Path | None = None, ): + """Store Matrix client settings, task handles, and outbound media policy flags.""" super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None @@ -605,6 +608,7 @@ class MatrixChannel(BaseChannel): return None async def send(self, msg: OutboundMessage) -> None: + """Send message text and optional attachments to a Matrix room, then clear typing state.""" if not self.client: return @@ -812,7 +816,7 @@ class MatrixChannel(BaseChannel): content = source.get("content") return content if isinstance(content, dict) else {} - def _event_thread_root_id(self, event: Any) -> str | None: + def _event_thread_root_id(self, event: RoomMessage) -> str | None: """Return thread root event_id if this message is inside a thread.""" content = self._event_source_content(event) relates_to = content.get("m.relates_to") @@ -823,7 +827,7 @@ class MatrixChannel(BaseChannel): root_id = relates_to.get("event_id") return root_id if isinstance(root_id, str) and root_id else None - def _thread_metadata(self, event: Any) -> dict[str, str] | None: + def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: """Build metadata used to reply within a thread.""" root_id = self._event_thread_root_id(event) if not root_id: @@ -852,7 +856,7 @@ class MatrixChannel(BaseChannel): "is_falling_back": True, } - def _event_attachment_type(self, event: Any) -> str: + def _event_attachment_type(self, event: MatrixMediaEvent) -> str: """Map Matrix event payload/type to a stable attachment kind.""" msgtype = self._event_source_content(event).get("msgtype") if msgtype == "m.image": @@ -1131,7 +1135,7 @@ class MatrixChannel(BaseChannel): content_parts.extend(markers) # TODO: Optionally add audio transcription support for Matrix attachments, - # similar to Telegram's voice/audio flow, behind explicit config. + # behind explicit config. await self._start_typing_keepalive(room.room_id) try: From fcece3ec62afee58e27fa199c4caa9f68a29a787 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Fri, 20 Feb 2026 18:17:27 +0100 Subject: [PATCH 169/415] fix(matrix): match fork/main formatting exactly --- nanobot/channels/matrix.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index a3bf482..1ace6ca 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -500,9 +500,7 @@ class MatrixChannel(BaseChannel): ) -> str | None: """Upload one local file to Matrix and send it as a media message.""" if not self.client: - return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format( - path.name or MATRIX_DEFAULT_ATTACHMENT_NAME - ) + return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(path.name or MATRIX_DEFAULT_ATTACHMENT_NAME) resolved = path.expanduser().resolve(strict=False) filename = safe_filename(resolved.name) or MATRIX_DEFAULT_ATTACHMENT_NAME @@ -1027,7 +1025,10 @@ class MatrixChannel(BaseChannel): limit_bytes = await self._effective_media_limit_bytes() declared_size = self._event_declared_size_bytes(event) - if declared_size is not None and declared_size > limit_bytes: + if ( + declared_size is not None + and declared_size > limit_bytes + ): logger.warning( "Matrix attachment skipped in room {}: declared size {} exceeds limit {}", room.room_id, From 33396a522aaf76d5da758666e63310e2a078894a Mon Sep 17 00:00:00 2001 From: themavik Date: Fri, 20 Feb 2026 23:52:40 -0500 Subject: [PATCH 170/415] fix(tools): provide detailed error messages in edit_file when old_text not found Uses difflib to find the best match and shows a helpful diff, making it easier to debug edit_file failures. Co-authored-by: Cursor --- nanobot/agent/tools/filesystem.py | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 419b088..d9ff265 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -150,7 +150,7 @@ class EditFileTool(Tool): content = file_path.read_text(encoding="utf-8") if old_text not in content: - return f"Error: old_text not found in file. Make sure it matches exactly." + return self._not_found_message(old_text, content, path) # Count occurrences count = content.count(old_text) @@ -166,6 +166,39 @@ class EditFileTool(Tool): except Exception as e: return f"Error editing file: {str(e)}" + @staticmethod + def _not_found_message(old_text: str, content: str, path: str) -> str: + """Build a helpful error when old_text is not found.""" + import difflib + + lines = content.splitlines(keepends=True) + old_lines = old_text.splitlines(keepends=True) + + best_ratio = 0.0 + best_start = 0 + window = len(old_lines) + + for i in range(max(1, len(lines) - window + 1)): + chunk = lines[i : i + window] + ratio = difflib.SequenceMatcher(None, old_lines, chunk).ratio() + if ratio > best_ratio: + best_ratio = ratio + best_start = i + + if best_ratio > 0.5: + best_chunk = lines[best_start : best_start + window] + diff = difflib.unified_diff( + old_lines, best_chunk, + fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})", + lineterm="", + ) + diff_str = "\n".join(diff) + return ( + f"Error: old_text not found in {path}.\n" + f"Best match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff_str}" + ) + return f"Error: old_text not found in {path}. No similar text found. Verify the file content." + class ListDirTool(Tool): """Tool to list directory contents.""" From 98ef57e3704860c54b86f6e8ae0d742c646883aa Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Sat, 21 Feb 2026 12:56:57 +0800 Subject: [PATCH 171/415] feat(feishu): add multimedia download support for images, audio and files Add download functionality for multimedia messages in Feishu channel, enabling agents to process images, audio recordings, and file attachments sent through Feishu. Co-Authored-By: Claude Opus 4.6 --- nanobot/channels/feishu.py | 143 ++++++++++++++++++++++++++++++------- 1 file changed, 117 insertions(+), 26 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a8ca1fa..a948d84 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -27,6 +27,8 @@ try: CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji, + GetFileRequest, + GetImageRequest, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True @@ -345,6 +347,80 @@ class FeishuChannel(BaseChannel): logger.error("Error uploading file {}: {}", file_path, e) return None + def _download_image_sync(self, image_key: str) -> tuple[bytes | None, str | None]: + """Download an image from Feishu by image_key.""" + try: + request = GetImageRequest.builder().image_key(image_key).build() + response = self._client.im.v1.image.get(request) + if response.success(): + return response.file, response.file_name + else: + logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) + return None, None + except Exception as e: + logger.error("Error downloading image {}: {}", image_key, e) + return None, None + + def _download_file_sync(self, file_key: str) -> tuple[bytes | None, str | None]: + """Download a file from Feishu by file_key.""" + try: + request = GetFileRequest.builder().file_key(file_key).build() + response = self._client.im.v1.file.get(request) + if response.success(): + return response.file, response.file_name + else: + logger.error("Failed to download file: code={}, msg={}", response.code, response.msg) + return None, None + except Exception as e: + logger.error("Error downloading file {}: {}", file_key, e) + return None, None + + async def _download_and_save_media( + self, + msg_type: str, + content_json: dict + ) -> tuple[str | None, str]: + """ + Download media from Feishu and save to local disk. + + Returns: + (file_path, content_text) - file_path is None if download failed + """ + from pathlib import Path + + loop = asyncio.get_running_loop() + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + + data, filename = None, None + + if msg_type == "image": + image_key = content_json.get("image_key") + if image_key: + data, filename = await loop.run_in_executor( + None, self._download_image_sync, image_key + ) + if not filename: + filename = f"{image_key[:16]}.jpg" + + elif msg_type in ("audio", "file"): + file_key = content_json.get("file_key") + if file_key: + data, filename = await loop.run_in_executor( + None, self._download_file_sync, file_key + ) + if not filename: + ext = ".opus" if msg_type == "audio" else "" + filename = f"{file_key[:16]}{ext}" + + if data and filename: + file_path = media_dir / filename + file_path.write_bytes(data) + logger.debug("Downloaded {} to {}", msg_type, file_path) + return str(file_path), f"[{msg_type}: {filename}]" + + return None, f"[{msg_type}: download failed]" + def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: """Send a single message (text/image/file/interactive) synchronously.""" try: @@ -425,60 +501,75 @@ class FeishuChannel(BaseChannel): event = data.event message = event.message sender = event.sender - + # Deduplication check message_id = message.message_id if message_id in self._processed_message_ids: return self._processed_message_ids[message_id] = None - - # Trim cache: keep most recent 500 when exceeds 1000 + + # Trim cache while len(self._processed_message_ids) > 1000: self._processed_message_ids.popitem(last=False) - + # Skip bot messages - sender_type = sender.sender_type - if sender_type == "bot": + if sender.sender_type == "bot": return - + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" chat_id = message.chat_id - chat_type = message.chat_type # "p2p" or "group" + chat_type = message.chat_type msg_type = message.message_type - - # Add reaction to indicate "seen" + + # Add reaction await self._add_reaction(message_id, "THUMBSUP") - - # Parse message content + + # Parse content + content_parts = [] + media_paths = [] + + try: + content_json = json.loads(message.content) if message.content else {} + except json.JSONDecodeError: + content_json = {} + if msg_type == "text": - try: - content = json.loads(message.content).get("text", "") - except json.JSONDecodeError: - content = message.content or "" + text = content_json.get("text", "") + if text: + content_parts.append(text) + elif msg_type == "post": - try: - content_json = json.loads(message.content) - content = _extract_post_text(content_json) - except (json.JSONDecodeError, TypeError): - content = message.content or "" + text = _extract_post_text(content_json) + if text: + content_parts.append(text) + + elif msg_type in ("image", "audio", "file"): + file_path, content_text = await self._download_and_save_media(msg_type, content_json) + if file_path: + media_paths.append(file_path) + content_parts.append(content_text) + else: - content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]") - - if not content: + content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) + + content = "\n".join(content_parts) if content_parts else "" + + if not content and not media_paths: return - + # Forward to message bus reply_to = chat_id if chat_type == "group" else sender_id await self._handle_message( sender_id=sender_id, chat_id=reply_to, content=content, + media=media_paths, metadata={ "message_id": message_id, "chat_type": chat_type, "msg_type": msg_type, } ) - + except Exception as e: logger.error("Error processing Feishu message: {}", e) From b9c3f8a5a3520fe4815f8baf1aea2f095295b3f8 Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Sat, 21 Feb 2026 14:08:25 +0800 Subject: [PATCH 172/415] feat(feishu): add share card and interactive message parsing - Add content extraction for share cards (chat, user, calendar event) - Add recursive parsing for interactive card elements - Fix image download API to use GetMessageResourceRequest with message_id - Handle BytesIO response from message resource API Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- nanobot/channels/feishu.py | 211 +++++++++++++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 10 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a948d84..7e1d50a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -28,7 +28,7 @@ try: CreateMessageReactionRequestBody, Emoji, GetFileRequest, - GetImageRequest, + GetMessageResourceRequest, P2ImMessageReceiveV1, ) FEISHU_AVAILABLE = True @@ -46,6 +46,182 @@ MSG_TYPE_MAP = { } +def _extract_share_card_content(content_json: dict, msg_type: str) -> str: + """Extract content from share cards and interactive messages. + + Handles: + - share_chat: Group share card + - share_user: User share card + - interactive: Interactive card (may contain links from external shares) + - share_calendar_event: Calendar event share + - system: System messages + """ + parts = [] + + if msg_type == "share_chat": + # Group share: {"chat_id": "oc_xxx"} + chat_id = content_json.get("chat_id", "") + parts.append(f"[分享群聊: {chat_id}]") + + elif msg_type == "share_user": + # User share: {"user_id": "ou_xxx"} + user_id = content_json.get("user_id", "") + parts.append(f"[分享用户: {user_id}]") + + elif msg_type == "interactive": + # Interactive card - extract text and links recursively + parts.extend(_extract_interactive_content(content_json)) + + elif msg_type == "share_calendar_event": + # Calendar event share + event_key = content_json.get("event_key", "") + parts.append(f"[分享日程: {event_key}]") + + elif msg_type == "system": + # System message + parts.append("[系统消息]") + + elif msg_type == "merge_forward": + # Merged forward messages + parts.append("[合并转发消息]") + + return "\n".join(parts) if parts else f"[{msg_type}]" + + +def _extract_interactive_content(content: dict) -> list[str]: + """Recursively extract text and links from interactive card content.""" + parts = [] + + if isinstance(content, str): + # Try to parse as JSON + try: + content = json.loads(content) + except (json.JSONDecodeError, TypeError): + return [content] if content.strip() else [] + + if not isinstance(content, dict): + return parts + + # Extract title + if "title" in content: + title = content["title"] + if isinstance(title, dict): + title_content = title.get("content", "") or title.get("text", "") + if title_content: + parts.append(f"标题: {title_content}") + elif isinstance(title, str): + parts.append(f"标题: {title}") + + # Extract from elements array + elements = content.get("elements", []) + if isinstance(elements, list): + for element in elements: + parts.extend(_extract_element_content(element)) + + # Extract from card config + card = content.get("card", {}) + if card: + parts.extend(_extract_interactive_content(card)) + + # Extract header + header = content.get("header", {}) + if header: + header_title = header.get("title", {}) + if isinstance(header_title, dict): + header_text = header_title.get("content", "") or header_title.get("text", "") + if header_text: + parts.append(f"标题: {header_text}") + + return parts + + +def _extract_element_content(element: dict) -> list[str]: + """Extract content from a single card element.""" + parts = [] + + if not isinstance(element, dict): + return parts + + tag = element.get("tag", "") + + # Markdown element + if tag == "markdown" or tag == "lark_md": + content = element.get("content", "") + if content: + parts.append(content) + + # Div element + elif tag == "div": + text = element.get("text", {}) + if isinstance(text, dict): + text_content = text.get("content", "") or text.get("text", "") + if text_content: + parts.append(text_content) + elif isinstance(text, str): + parts.append(text) + # Check for extra fields + fields = element.get("fields", []) + for field in fields: + if isinstance(field, dict): + field_text = field.get("text", {}) + if isinstance(field_text, dict): + parts.append(field_text.get("content", "")) + + # Link/URL element + elif tag == "a": + href = element.get("href", "") + text = element.get("text", "") + if href: + parts.append(f"链接: {href}") + if text: + parts.append(text) + + # Button element (may contain URL) + elif tag == "button": + text = element.get("text", {}) + if isinstance(text, dict): + parts.append(text.get("content", "")) + url = element.get("url", "") or element.get("multi_url", {}).get("url", "") + if url: + parts.append(f"链接: {url}") + + # Image element + elif tag == "img": + alt = element.get("alt", {}) + if isinstance(alt, dict): + parts.append(alt.get("content", "[图片]")) + else: + parts.append("[图片]") + + # Note element + elif tag == "note": + note_elements = element.get("elements", []) + for ne in note_elements: + parts.extend(_extract_element_content(ne)) + + # Column set + elif tag == "column_set": + columns = element.get("columns", []) + for col in columns: + col_elements = col.get("elements", []) + for ce in col_elements: + parts.extend(_extract_element_content(ce)) + + # Plain text + elif tag == "plain_text": + content = element.get("content", "") + if content: + parts.append(content) + + # Recursively check nested elements + nested = element.get("elements", []) + if isinstance(nested, list): + for ne in nested: + parts.extend(_extract_element_content(ne)) + + return parts + + def _extract_post_text(content_json: dict) -> str: """Extract plain text from Feishu post (rich text) message content. @@ -347,13 +523,21 @@ class FeishuChannel(BaseChannel): logger.error("Error uploading file {}: {}", file_path, e) return None - def _download_image_sync(self, image_key: str) -> tuple[bytes | None, str | None]: - """Download an image from Feishu by image_key.""" + def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: + """Download an image from Feishu message by message_id and image_key.""" try: - request = GetImageRequest.builder().image_key(image_key).build() - response = self._client.im.v1.image.get(request) + request = GetMessageResourceRequest.builder() \ + .message_id(message_id) \ + .file_key(image_key) \ + .type("image") \ + .build() + response = self._client.im.v1.message_resource.get(request) if response.success(): - return response.file, response.file_name + file_data = response.file + # GetMessageResourceRequest returns BytesIO, need to read bytes + if hasattr(file_data, 'read'): + file_data = file_data.read() + return file_data, response.file_name else: logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) return None, None @@ -378,7 +562,8 @@ class FeishuChannel(BaseChannel): async def _download_and_save_media( self, msg_type: str, - content_json: dict + content_json: dict, + message_id: str | None = None ) -> tuple[str | None, str]: """ Download media from Feishu and save to local disk. @@ -396,9 +581,9 @@ class FeishuChannel(BaseChannel): if msg_type == "image": image_key = content_json.get("image_key") - if image_key: + if image_key and message_id: data, filename = await loop.run_in_executor( - None, self._download_image_sync, image_key + None, self._download_image_sync, message_id, image_key ) if not filename: filename = f"{image_key[:16]}.jpg" @@ -544,11 +729,17 @@ class FeishuChannel(BaseChannel): content_parts.append(text) elif msg_type in ("image", "audio", "file"): - file_path, content_text = await self._download_and_save_media(msg_type, content_json) + file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) if file_path: media_paths.append(file_path) content_parts.append(content_text) + elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): + # Handle share cards and interactive messages + text = _extract_share_card_content(content_json, msg_type) + if text: + content_parts.append(text) + else: content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) From 8125d9b6bcf39a2d92f13833aa53be45ed3a9330 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 06:30:26 +0000 Subject: [PATCH 173/415] fix(feishu): fix double recursion, English placeholders, top-level Path import --- nanobot/channels/feishu.py | 130 ++++++++++++------------------------- 1 file changed, 43 insertions(+), 87 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7e1d50a..815d853 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -6,6 +6,7 @@ import os import re import threading from collections import OrderedDict +from pathlib import Path from typing import Any from loguru import logger @@ -47,44 +48,22 @@ MSG_TYPE_MAP = { def _extract_share_card_content(content_json: dict, msg_type: str) -> str: - """Extract content from share cards and interactive messages. - - Handles: - - share_chat: Group share card - - share_user: User share card - - interactive: Interactive card (may contain links from external shares) - - share_calendar_event: Calendar event share - - system: System messages - """ + """Extract text representation from share cards and interactive messages.""" parts = [] - + if msg_type == "share_chat": - # Group share: {"chat_id": "oc_xxx"} - chat_id = content_json.get("chat_id", "") - parts.append(f"[分享群聊: {chat_id}]") - + parts.append(f"[shared chat: {content_json.get('chat_id', '')}]") elif msg_type == "share_user": - # User share: {"user_id": "ou_xxx"} - user_id = content_json.get("user_id", "") - parts.append(f"[分享用户: {user_id}]") - + parts.append(f"[shared user: {content_json.get('user_id', '')}]") elif msg_type == "interactive": - # Interactive card - extract text and links recursively parts.extend(_extract_interactive_content(content_json)) - elif msg_type == "share_calendar_event": - # Calendar event share - event_key = content_json.get("event_key", "") - parts.append(f"[分享日程: {event_key}]") - + parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]") elif msg_type == "system": - # System message - parts.append("[系统消息]") - + parts.append("[system message]") elif msg_type == "merge_forward": - # Merged forward messages - parts.append("[合并转发消息]") - + parts.append("[merged forward messages]") + return "\n".join(parts) if parts else f"[{msg_type}]" @@ -93,44 +72,37 @@ def _extract_interactive_content(content: dict) -> list[str]: parts = [] if isinstance(content, str): - # Try to parse as JSON try: content = json.loads(content) except (json.JSONDecodeError, TypeError): return [content] if content.strip() else [] - + if not isinstance(content, dict): return parts - - # Extract title + if "title" in content: title = content["title"] if isinstance(title, dict): title_content = title.get("content", "") or title.get("text", "") if title_content: - parts.append(f"标题: {title_content}") + parts.append(f"title: {title_content}") elif isinstance(title, str): - parts.append(f"标题: {title}") - - # Extract from elements array - elements = content.get("elements", []) - if isinstance(elements, list): - for element in elements: - parts.extend(_extract_element_content(element)) - - # Extract from card config + parts.append(f"title: {title}") + + for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []: + parts.extend(_extract_element_content(element)) + card = content.get("card", {}) if card: parts.extend(_extract_interactive_content(card)) - - # Extract header + header = content.get("header", {}) if header: header_title = header.get("title", {}) if isinstance(header_title, dict): header_text = header_title.get("content", "") or header_title.get("text", "") if header_text: - parts.append(f"标题: {header_text}") + parts.append(f"title: {header_text}") return parts @@ -144,13 +116,11 @@ def _extract_element_content(element: dict) -> list[str]: tag = element.get("tag", "") - # Markdown element - if tag == "markdown" or tag == "lark_md": + if tag in ("markdown", "lark_md"): content = element.get("content", "") if content: parts.append(content) - - # Div element + elif tag == "div": text = element.get("text", {}) if isinstance(text, dict): @@ -159,64 +129,52 @@ def _extract_element_content(element: dict) -> list[str]: parts.append(text_content) elif isinstance(text, str): parts.append(text) - # Check for extra fields - fields = element.get("fields", []) - for field in fields: + for field in element.get("fields", []): if isinstance(field, dict): field_text = field.get("text", {}) if isinstance(field_text, dict): - parts.append(field_text.get("content", "")) - - # Link/URL element + c = field_text.get("content", "") + if c: + parts.append(c) + elif tag == "a": href = element.get("href", "") text = element.get("text", "") if href: - parts.append(f"链接: {href}") + parts.append(f"link: {href}") if text: parts.append(text) - - # Button element (may contain URL) + elif tag == "button": text = element.get("text", {}) if isinstance(text, dict): - parts.append(text.get("content", "")) + c = text.get("content", "") + if c: + parts.append(c) url = element.get("url", "") or element.get("multi_url", {}).get("url", "") if url: - parts.append(f"链接: {url}") - - # Image element + parts.append(f"link: {url}") + elif tag == "img": alt = element.get("alt", {}) - if isinstance(alt, dict): - parts.append(alt.get("content", "[图片]")) - else: - parts.append("[图片]") - - # Note element + parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]") + elif tag == "note": - note_elements = element.get("elements", []) - for ne in note_elements: + for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) - - # Column set + elif tag == "column_set": - columns = element.get("columns", []) - for col in columns: - col_elements = col.get("elements", []) - for ce in col_elements: + for col in element.get("columns", []): + for ce in col.get("elements", []): parts.extend(_extract_element_content(ce)) - - # Plain text + elif tag == "plain_text": content = element.get("content", "") if content: parts.append(content) - - # Recursively check nested elements - nested = element.get("elements", []) - if isinstance(nested, list): - for ne in nested: + + else: + for ne in element.get("elements", []): parts.extend(_extract_element_content(ne)) return parts @@ -571,8 +529,6 @@ class FeishuChannel(BaseChannel): Returns: (file_path, content_text) - file_path is None if download failed """ - from pathlib import Path - loop = asyncio.get_running_loop() media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) From e0edb904bd337cf5a3aaf675f39627d022043377 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 06:35:10 +0000 Subject: [PATCH 174/415] style(filesystem): move difflib import to top level --- nanobot/agent/tools/filesystem.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index d9ff265..e6c407e 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -1,5 +1,6 @@ """File system tools: read, write, edit.""" +import difflib from pathlib import Path from typing import Any @@ -169,8 +170,6 @@ class EditFileTool(Tool): @staticmethod def _not_found_message(old_text: str, content: str, path: str) -> str: """Build a helpful error when old_text is not found.""" - import difflib - lines = content.splitlines(keepends=True) old_lines = old_text.splitlines(keepends=True) From 4f5cb7d1e421a593cc54d9d1d0aab34870c12a35 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 06:39:04 +0000 Subject: [PATCH 175/415] style(filesystem): simplify best-match loop --- nanobot/agent/tools/filesystem.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index e6c407e..9c169e4 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -172,30 +172,21 @@ class EditFileTool(Tool): """Build a helpful error when old_text is not found.""" lines = content.splitlines(keepends=True) old_lines = old_text.splitlines(keepends=True) - - best_ratio = 0.0 - best_start = 0 window = len(old_lines) + best_ratio, best_start = 0.0, 0 for i in range(max(1, len(lines) - window + 1)): - chunk = lines[i : i + window] - ratio = difflib.SequenceMatcher(None, old_lines, chunk).ratio() + ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio() if ratio > best_ratio: - best_ratio = ratio - best_start = i + best_ratio, best_start = ratio, i if best_ratio > 0.5: - best_chunk = lines[best_start : best_start + window] - diff = difflib.unified_diff( - old_lines, best_chunk, + diff = "\n".join(difflib.unified_diff( + old_lines, lines[best_start : best_start + window], fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})", lineterm="", - ) - diff_str = "\n".join(diff) - return ( - f"Error: old_text not found in {path}.\n" - f"Best match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff_str}" - ) + )) + return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}" return f"Error: old_text not found in {path}. No similar text found. Verify the file content." From c4bee640b838045d6df079c734573523f23de48d Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Sat, 21 Feb 2026 07:51:28 +0100 Subject: [PATCH 176/415] fix(agent): skip empty fallback outbound for non-cli channels --- nanobot/agent/loop.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 4850f9c..336baec 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -273,9 +273,15 @@ class AgentLoop: ) try: response = await self._process_message(msg) - await self.bus.publish_outbound(response or OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content="", - )) + if response is not None: + await self.bus.publish_outbound(response) + elif msg.channel == "cli": + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata=msg.metadata or {}, + )) except Exception as e: logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( From aeb07d3450fafbcadb4ed649355ce2d3c4759225 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 07:32:58 +0000 Subject: [PATCH 177/415] refactor(loop): remove interim text retry, use system prompt constraint instead --- nanobot/agent/context.py | 1 + nanobot/agent/loop.py | 15 --------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 67aad0c..9ef333c 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -106,6 +106,7 @@ Only use the 'message' tool when you need to send a message to a specific chat c For normal conversation, just respond with text - do not call the message tool. Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). +If you need to use tools, call them directly — never send a preliminary message like "Let me check" without actually calling a tool. When remembering something important, write to {workspace_path}/memory/MEMORY.md To recall past events, grep {workspace_path}/memory/HISTORY.md""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 2348df7..b9108e7 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -202,8 +202,6 @@ class AgentLoop: iteration = 0 final_content = None tools_used: list[str] = [] - text_only_retried = False - interim_content: str | None = None # Fallback if retry produces nothing while iteration < self.max_iterations: iteration += 1 @@ -249,19 +247,6 @@ class AgentLoop: ) else: final_content = self._strip_think(response.content) - # Some models send an interim text response before tool calls. - # Give them one retry; save the content as fallback in case - # the retry produces nothing useful (e.g. model already answered). - if not tools_used and not text_only_retried and final_content: - text_only_retried = True - interim_content = final_content - logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80]) - final_content = None - continue - # Fall back to interim content if retry produced nothing - # and no tools were used (if tools ran, interim was truly interim) - if not final_content and interim_content and not tools_used: - final_content = interim_content break return final_content, tools_used From ab026c513162580443e1c386f13539b088aab770 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:14:46 +0000 Subject: [PATCH 178/415] refactor: extract memory consolidation to MemoryStore, slim down AgentLoop --- README.md | 2 +- nanobot/agent/loop.py | 257 ++++++---------------------------------- nanobot/agent/memory.py | 108 +++++++++++++++++ 3 files changed, 147 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index 68ad5a9..c5fd908 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,827 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,806 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 325c1ac..50f6ec2 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -99,33 +99,18 @@ class AgentLoop: def _register_default_tools(self) -> None: """Register the default set of tools.""" - # File tools (workspace for relative paths, restrict if configured) allowed_dir = self.workspace if self.restrict_to_workspace else None - self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) - self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) - - # Shell tool + for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool): + self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir)) self.tools.register(ExecTool( working_dir=str(self.workspace), timeout=self.exec_config.timeout, restrict_to_workspace=self.restrict_to_workspace, )) - - # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - - # Message tool - message_tool = MessageTool(send_callback=self.bus.publish_outbound) - self.tools.register(message_tool) - - # Spawn tool (for subagents) - spawn_tool = SpawnTool(manager=self.subagents) - self.tools.register(spawn_tool) - - # Cron tool (for scheduling) + self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) + self.tools.register(SpawnTool(manager=self.subagents)) if self.cron_service: self.tools.register(CronTool(self.cron_service)) @@ -187,16 +172,7 @@ class AgentLoop: initial_messages: list[dict], on_progress: Callable[[str], Awaitable[None]] | None = None, ) -> tuple[str | None, list[str]]: - """ - Run the agent iteration loop. - - Args: - initial_messages: Starting messages for the LLM conversation. - on_progress: Optional callback to push intermediate content to the user. - - Returns: - Tuple of (final_content, list_of_tools_used). - """ + """Run the agent iteration loop. Returns (final_content, tools_used).""" messages = initial_messages iteration = 0 final_content = None @@ -297,20 +273,25 @@ class AgentLoop: session_key: str | None = None, on_progress: Callable[[str], Awaitable[None]] | None = None, ) -> OutboundMessage | None: - """ - Process a single inbound message. - - Args: - msg: The inbound message to process. - session_key: Override session key (used by process_direct). - on_progress: Optional callback for intermediate output (defaults to bus publish). - - Returns: - The response message, or None if no response needed. - """ - # System messages route back via chat_id ("channel:chat_id") + """Process a single inbound message and return the response.""" + # System messages: parse origin from chat_id ("channel:chat_id") if msg.channel == "system": - return await self._process_system_message(msg) + channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id + else ("cli", msg.chat_id)) + logger.info("Processing system message from {}", msg.sender_id) + key = f"{channel}:{chat_id}" + session = self.sessions.get_or_create(key) + self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) + messages = self.context.build_messages( + history=session.get_history(max_messages=self.memory_window), + current_message=msg.content, channel=channel, chat_id=chat_id, + ) + final_content, _ = await self._run_agent_loop(messages) + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") + session.add_message("assistant", final_content or "Background task completed.") + self.sessions.save(session) + return OutboundMessage(channel=channel, chat_id=chat_id, + content=final_content or "Background task completed.") preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) @@ -318,19 +299,18 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) - # Handle slash commands + # Slash commands cmd = msg.content.strip().lower() if cmd == "/new": - # Capture messages before clearing (avoid race condition with background task) messages_to_archive = session.messages.copy() session.clear() self.sessions.save(session) self.sessions.invalidate(session.key) async def _consolidate_and_cleanup(): - temp_session = Session(key=session.key) - temp_session.messages = messages_to_archive - await self._consolidate_memory(temp_session, archive_all=True) + temp = Session(key=session.key) + temp.messages = messages_to_archive + await self._consolidate_memory(temp, archive_all=True) asyncio.create_task(_consolidate_and_cleanup()) return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, @@ -359,16 +339,14 @@ class AgentLoop: history=session.get_history(max_messages=self.memory_window), current_message=msg.content, media=msg.media if msg.media else None, - channel=msg.channel, - chat_id=msg.chat_id, + channel=msg.channel, chat_id=msg.chat_id, ) async def _bus_progress(content: str) -> None: meta = dict(msg.metadata or {}) meta["_progress"] = True await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content=content, - metadata=meta, + channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, )) final_content, tools_used = await self._run_agent_loop( @@ -391,157 +369,16 @@ class AgentLoop: return None return OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=final_content, - metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) - ) - - async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: - """ - Process a system message (e.g., subagent announce). - - The chat_id field contains "original_channel:original_chat_id" to route - the response back to the correct destination. - """ - logger.info("Processing system message from {}", msg.sender_id) - - # Parse origin from chat_id (format: "channel:chat_id") - if ":" in msg.chat_id: - parts = msg.chat_id.split(":", 1) - origin_channel = parts[0] - origin_chat_id = parts[1] - else: - # Fallback - origin_channel = "cli" - origin_chat_id = msg.chat_id - - session_key = f"{origin_channel}:{origin_chat_id}" - session = self.sessions.get_or_create(session_key) - self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id")) - initial_messages = self.context.build_messages( - history=session.get_history(max_messages=self.memory_window), - current_message=msg.content, - channel=origin_channel, - chat_id=origin_chat_id, - ) - final_content, _ = await self._run_agent_loop(initial_messages) - - if final_content is None: - final_content = "Background task completed." - - session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") - session.add_message("assistant", final_content) - self.sessions.save(session) - - return OutboundMessage( - channel=origin_channel, - chat_id=origin_chat_id, - content=final_content + channel=msg.channel, chat_id=msg.chat_id, content=final_content, + metadata=msg.metadata or {}, ) async def _consolidate_memory(self, session, archive_all: bool = False) -> None: - """Consolidate old messages into MEMORY.md + HISTORY.md. - - Args: - archive_all: If True, clear all messages and reset session (for /new command). - If False, only write to files without modifying session. - """ - memory = MemoryStore(self.workspace) - - if archive_all: - old_messages = session.messages - keep_count = 0 - logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) - else: - keep_count = self.memory_window // 2 - if len(session.messages) <= keep_count: - logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) - return - - messages_to_process = len(session.messages) - session.last_consolidated - if messages_to_process <= 0: - logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) - return - - old_messages = session.messages[session.last_consolidated:-keep_count] - if not old_messages: - return - logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) - - lines = [] - for m in old_messages: - if not m.get("content"): - continue - tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" - lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") - conversation = "\n".join(lines) - current_memory = memory.read_long_term() - - prompt = f"""Process this conversation and call the save_memory tool with your consolidation. - -## Current Long-term Memory -{current_memory or "(empty)"} - -## Conversation to Process -{conversation}""" - - save_memory_tool = [ - { - "type": "function", - "function": { - "name": "save_memory", - "description": "Save the memory consolidation result to persistent storage.", - "parameters": { - "type": "object", - "properties": { - "history_entry": { - "type": "string", - "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.", - }, - "memory_update": { - "type": "string", - "description": "The full updated long-term memory content as a markdown string. Include all existing facts plus any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.", - }, - }, - "required": ["history_entry", "memory_update"], - }, - }, - } - ] - - try: - response = await self.provider.chat( - messages=[ - {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, - {"role": "user", "content": prompt}, - ], - tools=save_memory_tool, - model=self.model, - ) - - if not response.has_tool_calls: - logger.warning("Memory consolidation: LLM did not call save_memory tool, skipping") - return - - args = response.tool_calls[0].arguments - if entry := args.get("history_entry"): - if not isinstance(entry, str): - entry = json.dumps(entry, ensure_ascii=False) - memory.append_history(entry) - if update := args.get("memory_update"): - if not isinstance(update, str): - update = json.dumps(update, ensure_ascii=False) - if update != current_memory: - memory.write_long_term(update) - - if archive_all: - session.last_consolidated = 0 - else: - session.last_consolidated = len(session.messages) - keep_count - logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) - except Exception as e: - logger.error("Memory consolidation failed: {}", e) + """Delegate to MemoryStore.consolidate().""" + await MemoryStore(self.workspace).consolidate( + session, self.provider, self.model, + archive_all=archive_all, memory_window=self.memory_window, + ) async def process_direct( self, @@ -551,26 +388,8 @@ class AgentLoop: chat_id: str = "direct", on_progress: Callable[[str], Awaitable[None]] | None = None, ) -> str: - """ - Process a message directly (for CLI or cron usage). - - Args: - content: The message content. - session_key: Session identifier (overrides channel:chat_id for session lookup). - channel: Source channel (for tool context routing). - chat_id: Source chat ID (for tool context routing). - on_progress: Optional callback for intermediate output. - - Returns: - The agent's response. - """ + """Process a message directly (for CLI or cron usage).""" await self._connect_mcp() - msg = InboundMessage( - channel=channel, - sender_id="user", - chat_id=chat_id, - content=content - ) - + msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) return response.content if response else "" diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 29477c4..51abd8f 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -1,9 +1,46 @@ """Memory system for persistent agent memory.""" +from __future__ import annotations + +import json from pathlib import Path +from typing import TYPE_CHECKING + +from loguru import logger from nanobot.utils.helpers import ensure_dir +if TYPE_CHECKING: + from nanobot.providers.base import LLMProvider + from nanobot.session.manager import Session + + +_SAVE_MEMORY_TOOL = [ + { + "type": "function", + "function": { + "name": "save_memory", + "description": "Save the memory consolidation result to persistent storage.", + "parameters": { + "type": "object", + "properties": { + "history_entry": { + "type": "string", + "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. " + "Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.", + }, + "memory_update": { + "type": "string", + "description": "Full updated long-term memory as markdown. Include all existing " + "facts plus new ones. Return unchanged if nothing new.", + }, + }, + "required": ["history_entry", "memory_update"], + }, + }, + } +] + class MemoryStore: """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log).""" @@ -28,3 +65,74 @@ class MemoryStore: def get_memory_context(self) -> str: long_term = self.read_long_term() return f"## Long-term Memory\n{long_term}" if long_term else "" + + async def consolidate( + self, + session: Session, + provider: LLMProvider, + model: str, + *, + archive_all: bool = False, + memory_window: int = 50, + ) -> None: + """Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.""" + if archive_all: + old_messages = session.messages + keep_count = 0 + logger.info("Memory consolidation (archive_all): {} messages", len(session.messages)) + else: + keep_count = memory_window // 2 + if len(session.messages) <= keep_count: + return + if len(session.messages) - session.last_consolidated <= 0: + return + old_messages = session.messages[session.last_consolidated:-keep_count] + if not old_messages: + return + logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count) + + lines = [] + for m in old_messages: + if not m.get("content"): + continue + tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" + lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") + + current_memory = self.read_long_term() + prompt = f"""Process this conversation and call the save_memory tool with your consolidation. + +## Current Long-term Memory +{current_memory or "(empty)"} + +## Conversation to Process +{chr(10).join(lines)}""" + + try: + response = await provider.chat( + messages=[ + {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, + {"role": "user", "content": prompt}, + ], + tools=_SAVE_MEMORY_TOOL, + model=model, + ) + + if not response.has_tool_calls: + logger.warning("Memory consolidation: LLM did not call save_memory, skipping") + return + + args = response.tool_calls[0].arguments + if entry := args.get("history_entry"): + if not isinstance(entry, str): + entry = json.dumps(entry, ensure_ascii=False) + self.append_history(entry) + if update := args.get("memory_update"): + if not isinstance(update, str): + update = json.dumps(update, ensure_ascii=False) + if update != current_memory: + self.write_long_term(update) + + session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) + except Exception as e: + logger.error("Memory consolidation failed: {}", e) From 0b30f514b4bee99660dc57f27a8326e749df6979 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:27:49 +0000 Subject: [PATCH 179/415] style(loop): compact empty outbound message construction --- nanobot/agent/loop.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5f0962a..7b9317c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -244,10 +244,7 @@ class AgentLoop: await self.bus.publish_outbound(response) elif msg.channel == "cli": await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="", - metadata=msg.metadata or {}, + channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {}, )) except Exception as e: logger.error("Error processing message: {}", e) From ec4bdb651fa13f9ced4fe9955b2c69c7bae7e39d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:33:02 +0000 Subject: [PATCH 180/415] docs: update nanobot news --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c5fd908..bf627a0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ ## 📢 News +- **2026-02-20** 🐦 Feishu now receives images, audio, and files from users. More reliable memory under the hood. +- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. +- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. - **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills. - **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support. @@ -27,20 +30,18 @@ - **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support! -- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). -- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! -- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
    Earlier news - +- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! +- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! - **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot! -
    ## Key Features of nanobot: From 9c61e1389c0ba6d33fb357c5937a5552fd8711ac Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 08:33:31 +0000 Subject: [PATCH 181/415] docs: update nanobot news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf627a0..8e1202c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 📢 News -- **2026-02-20** 🐦 Feishu now receives images, audio, and files from users. More reliable memory under the hood. +- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood. - **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. - **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. - **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. From b3acd19c7ba2e012916d09ae7de52b328cbb5108 Mon Sep 17 00:00:00 2001 From: vincentchen Date: Sat, 21 Feb 2026 20:28:42 +0800 Subject: [PATCH 182/415] Remove redundant tools description (because tools information is passed in with each self.provider.chat() call) --- nanobot/agent/context.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 9ef333c..5794918 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -82,12 +82,7 @@ Skills with available="false" need dependencies installed first - you can try in return f"""# nanobot 🐈 -You are nanobot, a helpful AI assistant. You have access to tools that allow you to: -- Read, write, and edit files -- Execute shell commands -- Search the web and fetch web pages -- Send messages to users on chat channels -- Spawn subagents for complex background tasks +You are nanobot, a helpful AI assistant. ## Current Time {now} ({tz}) From af71ccf0514721777aaae500f05016d844fe5fc6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 13:05:14 +0000 Subject: [PATCH 183/415] release: v0.1.4.post1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 64a884d..c337d02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4" +version = "0.1.4.post1" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 88ca2e05307b66dfebe469d884c17217ea3b17d6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 13:20:55 +0000 Subject: [PATCH 184/415] docs: update v.0.1.4.post1 release news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8e1202c..1002872 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## 📢 News +- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details. - **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood. - **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. - **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. From 01c835aac2bfc676d6213261c6ad6cb7301bb83e Mon Sep 17 00:00:00 2001 From: nanobot-bot Date: Sat, 21 Feb 2026 23:07:18 +0800 Subject: [PATCH 185/415] fix(context): Fix 'Missing `reasoning_content` field' error for deepseek provider. --- nanobot/agent/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 876d43d..b3de8da 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -235,7 +235,7 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md""" msg["tool_calls"] = tool_calls # Include reasoning content when provided (required by some thinking models) - if reasoning_content: + if reasoning_content is not None: msg["reasoning_content"] = reasoning_content messages.append(msg) From 83ccdf61862ffcd665fb096922087b4924b15d9a Mon Sep 17 00:00:00 2001 From: muskliu <862259098@qq.com> Date: Sun, 22 Feb 2026 00:14:22 +0800 Subject: [PATCH 186/415] fix(provider): filter empty text content blocks causing API 400 When MCP tools return empty content, messages may contain empty-string text blocks. OpenAI-compatible providers reject these with HTTP 400. Changes: - Add _prevent_empty_text_blocks() to filter empty text items from content lists and handle empty string content - For assistant messages with tool_calls, set content to None (valid) - For other messages, replace with '(empty)' placeholder - Only copy message dict when modification is needed (zero-copy path for normal messages) Co-Authored-By: nanobot --- nanobot/providers/custom_provider.py | 52 ++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index f190ccf..ec5d48b 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -19,8 +19,12 @@ class CustomProvider(LLMProvider): async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: - kwargs: dict[str, Any] = {"model": model or self.default_model, "messages": messages, - "max_tokens": max(1, max_tokens), "temperature": temperature} + kwargs: dict[str, Any] = { + "model": model or self.default_model, + "messages": self._prevent_empty_text_blocks(messages), + "max_tokens": max(1, max_tokens), + "temperature": temperature, + } if tools: kwargs.update(tools=tools, tool_choice="auto") try: @@ -45,3 +49,47 @@ class CustomProvider(LLMProvider): def get_default_model(self) -> str: return self.default_model + + @staticmethod + def _prevent_empty_text_blocks(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Filter empty text content blocks that cause provider 400 errors. + + When MCP tools return empty content, messages may contain empty-string + text blocks. Most providers (OpenAI-compatible) reject these with 400. + This method filters them out before sending to the API. + """ + patched: list[dict[str, Any]] = [] + for msg in messages: + content = msg.get("content") + + # Empty string content + if isinstance(content, str) and content == "": + clean = dict(msg) + if msg.get("role") == "assistant" and msg.get("tool_calls"): + clean["content"] = None + else: + clean["content"] = "(empty)" + patched.append(clean) + continue + + # List content — filter out empty text items + if isinstance(content, list): + filtered = [ + item for item in content + if not (isinstance(item, dict) + and item.get("type") in {"text", "input_text", "output_text"} + and item.get("text") == "") + ] + if filtered != content: + clean = dict(msg) + if filtered: + clean["content"] = filtered + elif msg.get("role") == "assistant" and msg.get("tool_calls"): + clean["content"] = None + else: + clean["content"] = "(empty)" + patched.append(clean) + continue + + patched.append(msg) + return patched From edc671a8a30fd05c46a15b0da7292fc9c4c4b5be Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 21 Feb 2026 16:39:26 +0000 Subject: [PATCH 187/415] docs: update format of news section --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1002872..cb751ba 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@
    Earlier news + - **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). @@ -43,6 +44,7 @@ - **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot! +
    ## Key Features of nanobot: From 6b7d7e2eb8745103b0bf4a513515b972a27f5885 Mon Sep 17 00:00:00 2001 From: muskliu <862259098@qq.com> Date: Sun, 22 Feb 2026 00:39:53 +0800 Subject: [PATCH 188/415] fix(mcp): add 30s timeout to MCP tool calls to prevent agent hangs --- nanobot/agent/tools/mcp.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index ad352bf..a5e619a 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -1,11 +1,15 @@ """MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" +import asyncio from contextlib import AsyncExitStack from typing import Any import httpx from loguru import logger +# Timeout for individual MCP tool calls (seconds). +MCP_TOOL_TIMEOUT = 30 + from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry @@ -34,7 +38,14 @@ class MCPToolWrapper(Tool): async def execute(self, **kwargs: Any) -> str: from mcp import types - result = await self._session.call_tool(self._original_name, arguments=kwargs) + try: + result = await asyncio.wait_for( + self._session.call_tool(self._original_name, arguments=kwargs), + timeout=MCP_TOOL_TIMEOUT, + ) + except asyncio.TimeoutError: + logger.warning("MCP tool '{}' timed out after {}s", self._name, MCP_TOOL_TIMEOUT) + return f"(MCP tool call timed out after {MCP_TOOL_TIMEOUT}s)" parts = [] for block in result.content: if isinstance(block, types.TextContent): From deae84482d786f5cb99ed526bf84edeba17caba8 Mon Sep 17 00:00:00 2001 From: init-new-world Date: Sun, 22 Feb 2026 00:42:41 +0800 Subject: [PATCH 189/415] fix: change VolcEngine litellm prefix from openai to volcengine --- nanobot/providers/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index ecf092f..2766929 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -147,7 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("volcengine", "volces", "ark"), env_key="OPENAI_API_KEY", display_name="VolcEngine", - litellm_prefix="openai", + litellm_prefix="volcengine", skip_prefixes=(), env_extras=(), is_gateway=True, From de63c31d43426be1e276db111cdc4d31b4f6767f Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:30:57 -0500 Subject: [PATCH 190/415] fix(providers): normalize empty reasoning_content to None at provider level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #947 fixed the consumer side (context.py) but the root cause is at the provider level — getattr returns "" (empty string) instead of None when reasoning_content is empty. This causes DeepSeek API to reject the request with "Missing reasoning_content field" error. `"" or None` evaluates to None, preventing empty strings from propagating downstream. Fixes #946 --- nanobot/providers/custom_provider.py | 2 +- nanobot/providers/litellm_provider.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index f190ccf..4022d9e 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -40,7 +40,7 @@ class CustomProvider(LLMProvider): return LLMResponse( content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop", usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {}, - reasoning_content=getattr(msg, "reasoning_content", None), + reasoning_content=getattr(msg, "reasoning_content", None) or None, ) def get_default_model(self) -> str: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 58c9ac2..784f02c 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -257,7 +257,7 @@ class LiteLLMProvider(LLMProvider): "total_tokens": response.usage.total_tokens, } - reasoning_content = getattr(message, "reasoning_content", None) + reasoning_content = getattr(message, "reasoning_content", None) or None return LLMResponse( content=message.content, From 5c9cb3a208a0a9b3e543c510753269073579adaa Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:34:14 -0500 Subject: [PATCH 191/415] fix(security): prevent path traversal bypass via startswith check `startswith` string comparison allows bypassing directory restrictions. For example, `/home/user/workspace_evil` passes the check against `/home/user/workspace` because the string starts with the allowed path. Replace with `Path.relative_to()` which correctly validates that the resolved path is actually inside the allowed directory tree. Fixes #888 --- nanobot/agent/tools/filesystem.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 9c169e4..b87da11 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -13,8 +13,11 @@ def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | if not p.is_absolute() and workspace: p = workspace / p resolved = p.resolve() - if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())): - raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") + if allowed_dir: + try: + resolved.relative_to(allowed_dir.resolve()) + except ValueError: + raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") return resolved From ef96619039c98ccb45c61481cf1b5203aa5864ac Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:34:50 -0500 Subject: [PATCH 192/415] fix(slack): add exception handling to socket listener _handle_message() in _on_socket_request() had no try/except. If it throws (bus full, permission error, etc.), the exception propagates up and crashes the Socket Mode event loop, causing missed messages. Other channels like Telegram already have explicit error handlers. Fixes #895 --- nanobot/channels/slack.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 4fc1f41..d1c5895 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -179,18 +179,21 @@ class SlackChannel(BaseChannel): except Exception as e: logger.debug("Slack reactions_add failed: {}", e) - await self._handle_message( - sender_id=sender_id, - chat_id=chat_id, - content=text, - metadata={ - "slack": { - "event": event, - "thread_ts": thread_ts, - "channel_type": channel_type, - } - }, - ) + try: + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=text, + metadata={ + "slack": { + "event": event, + "thread_ts": thread_ts, + "channel_type": channel_type, + } + }, + ) + except Exception as e: + logger.error("Error handling Slack message from {}: {}", sender_id, e) def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: if channel_type == "im": From 54a0f3d038e2a7f18315e1768a351010401cf6c2 Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:35:21 -0500 Subject: [PATCH 193/415] fix(session): handle errors in legacy session migration shutil.move() in _load() can fail due to permissions, disk full, or concurrent access. Without error handling, the exception propagates up and prevents the session from loading entirely. Wrap in try/except so migration failures are logged as warnings and the session falls back to loading from the legacy path on next attempt. Fixes #863 --- nanobot/session/manager.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 18e23b2..19d4439 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -108,9 +108,12 @@ class SessionManager: if not path.exists(): legacy_path = self._get_legacy_session_path(key) if legacy_path.exists(): - import shutil - shutil.move(str(legacy_path), str(path)) - logger.info("Migrated session {} from legacy path", key) + try: + import shutil + shutil.move(str(legacy_path), str(path)) + logger.info("Migrated session {} from legacy path", key) + except Exception as e: + logger.warning("Failed to migrate session {}: {}", key, e) if not path.exists(): return None From ba66c6475025e8123e88732f5cec71e5b56b0b14 Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:36:04 -0500 Subject: [PATCH 194/415] fix(email): evict oldest half of dedup set instead of clearing entirely When _processed_uids exceeds 100k entries, the entire set was cleared with .clear(), allowing all previously seen emails to be re-processed. Now evicts the oldest 50% of entries, keeping recent UIDs to prevent duplicate processing while still bounding memory usage. Fixes #890 --- nanobot/channels/email.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 1b6f46b..c90a14d 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -304,7 +304,9 @@ class EmailChannel(BaseChannel): self._processed_uids.add(uid) # mark_seen is the primary dedup; this set is a safety net if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: - self._processed_uids.clear() + # Evict oldest half instead of clearing entirely + to_keep = list(self._processed_uids)[len(self._processed_uids) // 2:] + self._processed_uids = set(to_keep) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") From 8c55b40b9f383c5e11217cbd6234483a63f597aa Mon Sep 17 00:00:00 2001 From: andienguyen-ecoligo Date: Sat, 21 Feb 2026 12:38:24 -0500 Subject: [PATCH 195/415] fix(qq): make start() long-running per base channel contract QQ channel's start() created a background task and returned immediately, violating the base Channel contract which specifies start() should be "a long-running async task". This caused the gateway to exit prematurely when QQ was the only enabled channel. Now directly awaits _run_bot() to stay alive like other channels. Fixes #894 --- nanobot/channels/qq.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 16cbfb8..a940a75 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -71,8 +71,8 @@ class QQChannel(BaseChannel): BotClass = _make_bot_class(self) self._client = BotClass() - self._bot_task = asyncio.create_task(self._run_bot()) logger.info("QQ bot started (C2C private message)") + await self._run_bot() async def _run_bot(self) -> None: """Run the bot connection with auto-reconnect.""" From de5104ab2a91bda425fee0990b5e3d8b72c58239 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Sat, 21 Feb 2026 20:24:46 +0100 Subject: [PATCH 196/415] fix(matrix): keep typing indicator during progress updates --- nanobot/channels/matrix.py | 6 ++++-- tests/test_matrix_channel.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 1ace6ca..794cc51 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -606,13 +606,14 @@ class MatrixChannel(BaseChannel): return None async def send(self, msg: OutboundMessage) -> None: - """Send message text and optional attachments to a Matrix room, then clear typing state.""" + """Send Matrix outbound content and clear typing only for non-progress messages.""" if not self.client: return text = msg.content or "" candidates = self._collect_outbound_media_candidates(msg.media) relates_to = self._build_thread_relates_to(msg.metadata) + is_progress = bool((msg.metadata or {}).get("_progress")) try: failures: list[str] = [] @@ -641,7 +642,8 @@ class MatrixChannel(BaseChannel): content["m.relates_to"] = relates_to await self._send_room_content(msg.chat_id, content) finally: - await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) + if not is_progress: + await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) def _register_event_callbacks(self) -> None: """Register Matrix event callbacks used by this channel.""" diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 47d7ec4..f475aac 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -1141,6 +1141,29 @@ async def test_send_stops_typing_keepalive_task() -> None: assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS) +@pytest.mark.asyncio +async def test_send_progress_keeps_typing_keepalive_running() -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + channel._running = True + + await channel._start_typing_keepalive("!room:matrix.org") + assert "!room:matrix.org" in channel._typing_tasks + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!room:matrix.org", + content="working...", + metadata={"_progress": True, "_progress_kind": "reasoning"}, + ) + ) + + assert "!room:matrix.org" in channel._typing_tasks + assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) + + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: channel = MatrixChannel(_make_config(), MessageBus()) From 494fa8966a91cb7793dc0d92944a5c226d2d2e13 Mon Sep 17 00:00:00 2001 From: Alexander Minges Date: Sat, 21 Feb 2026 20:29:47 +0100 Subject: [PATCH 197/415] refactor(matrix): use milliseconds for typing timing constants --- nanobot/channels/matrix.py | 4 ++-- tests/test_matrix_channel.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 794cc51..f85aab5 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -43,7 +43,7 @@ TYPING_NOTICE_TIMEOUT_MS = 30_000 # https://spec.matrix.org/v1.17/client-server-api/#typing-notifications # Keepalive interval must stay below TYPING_NOTICE_TIMEOUT_MS so the typing # indicator does not expire while the agent is still processing. -TYPING_KEEPALIVE_INTERVAL_SECONDS = 20.0 +TYPING_KEEPALIVE_INTERVAL_MS = 20_000 MATRIX_HTML_FORMAT = "org.matrix.custom.html" MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]" MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]" @@ -715,7 +715,7 @@ class MatrixChannel(BaseChannel): async def _typing_loop() -> None: try: while self._running: - await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_SECONDS) + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000) await self._set_typing(room_id, True) except asyncio.CancelledError: pass diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index f475aac..c6714c2 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -332,7 +332,7 @@ async def test_typing_keepalive_refreshes_periodically(monkeypatch) -> None: channel.client = client channel._running = True - monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_SECONDS", 0.01) + monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_MS", 10) await channel._start_typing_keepalive("!room:matrix.org") await asyncio.sleep(0.03) From 3e406004839ccb2590649736e2e1c8c93761161c Mon Sep 17 00:00:00 2001 From: Rok Pergarec Date: Sat, 21 Feb 2026 20:55:54 +0100 Subject: [PATCH 198/415] docs: add systemd user service instructions to README --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index cb751ba..2c5cdc9 100644 --- a/README.md +++ b/README.md @@ -865,6 +865,57 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!" docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status ``` +## 🐧 Linux Service + +Run the gateway as a systemd user service so it starts automatically and restarts on failure. Below example is for a +`pip` based installation. + +**1. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service`: + +```ini +[Unit] +Description=Nanobot Gateway +After=network.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/nanobot gateway +Restart=always +RestartSec=10 +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=%h + +[Install] +WantedBy=default.target +``` + +**2. Enable and start:** + +```bash +systemctl --user daemon-reload +systemctl --user enable --now nanobot-gateway +``` + +**After config changes**, restart: + +```bash +systemctl --user restart nanobot-gateway +``` + +If you modify the `.service` file itself, reload the unit before restarting: + +```bash +systemctl --user daemon-reload +systemctl --user restart nanobot-gateway +``` + +> **Note:** By default, user services only run while you are logged in. To keep the gateway running after you log out, enable lingering: +> +> ```bash +> loginctl enable-linger $USER +> ``` + ## 📁 Project Structure ``` From b323087631561d4312affd17f57aa0643210a730 Mon Sep 17 00:00:00 2001 From: Yingwen Luo-LUOYW Date: Sun, 22 Feb 2026 12:42:33 +0800 Subject: [PATCH 199/415] feat(cli): add DingTalk, QQ, and Email to channels status output --- nanobot/cli/commands.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 6155463..f1f9b30 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -668,6 +668,33 @@ def channels_status(): slack_config ) + # DingTalk + dt = config.channels.dingtalk + dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]" + table.add_row( + "DingTalk", + "✓" if dt.enabled else "✗", + dt_config + ) + + # QQ + qq = config.channels.qq + qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]" + table.add_row( + "QQ", + "✓" if qq.enabled else "✗", + qq_config + ) + + # Email + em = config.channels.email + em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]" + table.add_row( + "Email", + "✓" if em.enabled else "✗", + em_config + ) + console.print(table) From 973061b01efed370ba6fa2e2f0db54be58861711 Mon Sep 17 00:00:00 2001 From: FloRa <2862182666@qq.com> Date: Sun, 22 Feb 2026 17:15:00 +0800 Subject: [PATCH 200/415] fix(feishu): replace file.get with message_resource.get to fix file download permission issue --- nanobot/channels/feishu.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 815d853..0376e57 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -503,18 +503,28 @@ class FeishuChannel(BaseChannel): logger.error("Error downloading image {}: {}", image_key, e) return None, None - def _download_file_sync(self, file_key: str) -> tuple[bytes | None, str | None]: - """Download a file from Feishu by file_key.""" + def _download_file_sync(self, message_id: str, file_key: str, resource_type: str = "file") -> tuple[ + bytes | None, str | None]: + """Download a file or audio from a Feishu message by message_id and file_key.""" try: - request = GetFileRequest.builder().file_key(file_key).build() - response = self._client.im.v1.file.get(request) + request = GetMessageResourceRequest.builder() \ + .message_id(message_id) \ + .file_key(file_key) \ + .type(resource_type) \ + .build() + response = self._client.im.v1.message_resource.get(request) + if response.success(): - return response.file, response.file_name + file_data = response.file + # GetMessageResourceRequest 返回的是类似 BytesIO 的对象,需要 read() + if hasattr(file_data, 'read'): + file_data = file_data.read() + return file_data, response.file_name else: - logger.error("Failed to download file: code={}, msg={}", response.code, response.msg) + logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg) return None, None except Exception as e: - logger.error("Error downloading file {}: {}", file_key, e) + logger.error("Error downloading {} {}: {}", resource_type, file_key, e) return None, None async def _download_and_save_media( @@ -544,14 +554,18 @@ class FeishuChannel(BaseChannel): if not filename: filename = f"{image_key[:16]}.jpg" + elif msg_type in ("audio", "file"): + file_key = content_json.get("file_key") - if file_key: + + if file_key and message_id: data, filename = await loop.run_in_executor( - None, self._download_file_sync, file_key + None, self._download_file_sync, message_id, file_key, msg_type ) if not filename: ext = ".opus" if msg_type == "audio" else "" + filename = f"{file_key[:16]}{ext}" if data and filename: From 0d3a2963d0d57b66b14f5d2b9c25fb494e6d62f8 Mon Sep 17 00:00:00 2001 From: FloRa <2862182666@qq.com> Date: Sun, 22 Feb 2026 17:37:33 +0800 Subject: [PATCH 201/415] fix(feishu): replace file.get with message_resource.get to fix file download permission issue --- nanobot/channels/feishu.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0376e57..d1eeb2e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -555,23 +555,29 @@ class FeishuChannel(BaseChannel): filename = f"{image_key[:16]}.jpg" - elif msg_type in ("audio", "file"): + elif msg_type in ("audio", "file", "media"): file_key = content_json.get("file_key") - if file_key and message_id: data, filename = await loop.run_in_executor( - None, self._download_file_sync, message_id, file_key, msg_type + None, self._download_file_sync, message_id, file_key ) if not filename: - ext = ".opus" if msg_type == "audio" else "" - + if msg_type == "audio": + ext = ".opus" + elif msg_type == "media": + ext = ".mp4" + else: + ext = "" filename = f"{file_key[:16]}{ext}" if data and filename: file_path = media_dir / filename + file_path.write_bytes(data) + logger.debug("Downloaded {} to {}", msg_type, file_path) + return str(file_path), f"[{msg_type}: {filename}]" return None, f"[{msg_type}: download failed]" @@ -698,7 +704,7 @@ class FeishuChannel(BaseChannel): if text: content_parts.append(text) - elif msg_type in ("image", "audio", "file"): + elif msg_type in ("image", "audio", "file", "media"): file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) if file_path: media_paths.append(file_path) From b93b77a485d3a0c1632a5c3d590bc0b2a37c43bd Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 15:38:19 +0000 Subject: [PATCH 202/415] fix(slack): use logger.exception to capture full traceback --- nanobot/channels/slack.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index d1c5895..b0f9bbb 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -192,8 +192,8 @@ class SlackChannel(BaseChannel): } }, ) - except Exception as e: - logger.error("Error handling Slack message from {}: {}", sender_id, e) + except Exception: + logger.exception("Error handling Slack message from {}", sender_id) def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: if channel_type == "im": From 71de1899e6dcf9e5d01396b6a90e6a63486bc6de Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 15:40:17 +0000 Subject: [PATCH 203/415] fix(session): use logger.exception and move import to top --- nanobot/session/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 19d4439..5f23dc2 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1,6 +1,7 @@ """Session management for conversation history.""" import json +import shutil from pathlib import Path from dataclasses import dataclass, field from datetime import datetime @@ -109,11 +110,10 @@ class SessionManager: legacy_path = self._get_legacy_session_path(key) if legacy_path.exists(): try: - import shutil shutil.move(str(legacy_path), str(path)) logger.info("Migrated session {} from legacy path", key) - except Exception as e: - logger.warning("Failed to migrate session {}: {}", key, e) + except Exception: + logger.exception("Failed to migrate session {}", key) if not path.exists(): return None From 4e8c8cc2274f1c5f505574e8fa7d5a5df790aff6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 15:48:49 +0000 Subject: [PATCH 204/415] fix(email): fix misleading comment and simplify uid eviction --- nanobot/channels/email.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index c90a14d..5dc05fb 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -304,9 +304,8 @@ class EmailChannel(BaseChannel): self._processed_uids.add(uid) # mark_seen is the primary dedup; this set is a safety net if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: - # Evict oldest half instead of clearing entirely - to_keep = list(self._processed_uids)[len(self._processed_uids) // 2:] - self._processed_uids = set(to_keep) + # Evict a random half to cap memory; mark_seen is the primary dedup + self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:]) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") From b13d7f853e8173162df02af0dc1b8e25b674753b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 17:17:35 +0000 Subject: [PATCH 205/415] fix(agent): make tool hint a fallback when no content in on_progress --- nanobot/agent/loop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5762fa9..b05ba90 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -196,7 +196,8 @@ class AgentLoop: clean = self._strip_think(response.content) if clean: await on_progress(clean) - await on_progress(self._tool_hint(response.tool_calls)) + else: + await on_progress(self._tool_hint(response.tool_calls)) tool_call_dicts = [ { From b53c3d39edece22484568bb1896cbe29bfb3c1ae Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 17:35:53 +0000 Subject: [PATCH 206/415] fix(qq): remove dead _bot_task field and fix stop() to close client --- nanobot/channels/qq.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index a940a75..5352a30 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -55,7 +55,6 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) - self._bot_task: asyncio.Task | None = None async def start(self) -> None: """Start the QQ bot.""" @@ -88,11 +87,10 @@ class QQChannel(BaseChannel): async def stop(self) -> None: """Stop the QQ bot.""" self._running = False - if self._bot_task: - self._bot_task.cancel() + if self._client: try: - await self._bot_task - except asyncio.CancelledError: + await self._client.close() + except Exception: pass logger.info("QQ bot stopped") @@ -130,5 +128,5 @@ class QQChannel(BaseChannel): content=content, metadata={"message_id": data.id}, ) - except Exception as e: - logger.error("Error handling QQ message: {}", e) + except Exception: + logger.exception("Error handling QQ message") From 1aa06ea03dcd1c0fa4eed018e822918fe938706a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 17:51:23 +0000 Subject: [PATCH 207/415] docs: improve Linux Service section in README --- README.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2c5cdc9..99f9681 100644 --- a/README.md +++ b/README.md @@ -867,10 +867,15 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status ## 🐧 Linux Service -Run the gateway as a systemd user service so it starts automatically and restarts on failure. Below example is for a -`pip` based installation. +Run the gateway as a systemd user service so it starts automatically and restarts on failure. -**1. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service`: +**1. Find the nanobot binary path:** + +```bash +which nanobot # e.g. /home/user/.local/bin/nanobot +``` + +**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed): ```ini [Unit] @@ -890,27 +895,24 @@ ReadWritePaths=%h WantedBy=default.target ``` -**2. Enable and start:** +**3. Enable and start:** ```bash systemctl --user daemon-reload systemctl --user enable --now nanobot-gateway ``` -**After config changes**, restart: +**Common operations:** ```bash -systemctl --user restart nanobot-gateway +systemctl --user status nanobot-gateway # check status +systemctl --user restart nanobot-gateway # restart after config changes +journalctl --user -u nanobot-gateway -f # follow logs ``` -If you modify the `.service` file itself, reload the unit before restarting: +If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting. -```bash -systemctl --user daemon-reload -systemctl --user restart nanobot-gateway -``` - -> **Note:** By default, user services only run while you are logged in. To keep the gateway running after you log out, enable lingering: +> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering: > > ```bash > loginctl enable-linger $USER From 437ebf4e6eda3711575d71878a92e19b32992912 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 18:04:13 +0000 Subject: [PATCH 208/415] feat(mcp): make tool_timeout configurable per server via config --- README.md | 17 ++++++++++++++++- nanobot/agent/tools/mcp.py | 14 ++++++-------- nanobot/config/schema.py | 1 + 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 99f9681..8399c45 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,806 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,862 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News @@ -776,6 +776,21 @@ Two transport modes are supported: | **Stdio** | `command` + `args` | Local process via `npx` / `uvx` | | **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) | +Use `toolTimeout` to override the default 30s per-call timeout for slow servers: + +```json +{ + "tools": { + "mcpServers": { + "my-slow-server": { + "url": "https://example.com/mcp/", + "toolTimeout": 120 + } + } + } +} +``` + MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed. diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index a5e619a..0257d52 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -7,9 +7,6 @@ from typing import Any import httpx from loguru import logger -# Timeout for individual MCP tool calls (seconds). -MCP_TOOL_TIMEOUT = 30 - from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry @@ -17,12 +14,13 @@ from nanobot.agent.tools.registry import ToolRegistry class MCPToolWrapper(Tool): """Wraps a single MCP server tool as a nanobot Tool.""" - def __init__(self, session, server_name: str, tool_def): + def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30): self._session = session self._original_name = tool_def.name self._name = f"mcp_{server_name}_{tool_def.name}" self._description = tool_def.description or tool_def.name self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} + self._tool_timeout = tool_timeout @property def name(self) -> str: @@ -41,11 +39,11 @@ class MCPToolWrapper(Tool): try: result = await asyncio.wait_for( self._session.call_tool(self._original_name, arguments=kwargs), - timeout=MCP_TOOL_TIMEOUT, + timeout=self._tool_timeout, ) except asyncio.TimeoutError: - logger.warning("MCP tool '{}' timed out after {}s", self._name, MCP_TOOL_TIMEOUT) - return f"(MCP tool call timed out after {MCP_TOOL_TIMEOUT}s)" + logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout) + return f"(MCP tool call timed out after {self._tool_timeout}s)" parts = [] for block in result.content: if isinstance(block, types.TextContent): @@ -94,7 +92,7 @@ async def connect_mcp_servers( tools = await session.list_tools() for tool_def in tools.tools: - wrapper = MCPToolWrapper(session, name, tool_def) + wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout) registry.register(wrapper) logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 966d11d..be36536 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -260,6 +260,7 @@ class MCPServerConfig(Base): env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars url: str = "" # HTTP: streamable HTTP endpoint URL headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers + tool_timeout: int = 30 # Seconds before a tool call is cancelled class ToolsConfig(Base): From efe89c90913d3fd7b8415b13e577021932a60795 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 18:16:45 +0000 Subject: [PATCH 209/415] fix(feishu): pass msg_type as resource_type and clean up style --- nanobot/channels/feishu.py | 39 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index d1eeb2e..2d50d74 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -503,28 +503,29 @@ class FeishuChannel(BaseChannel): logger.error("Error downloading image {}: {}", image_key, e) return None, None - def _download_file_sync(self, message_id: str, file_key: str, resource_type: str = "file") -> tuple[ - bytes | None, str | None]: - """Download a file or audio from a Feishu message by message_id and file_key.""" + def _download_file_sync( + self, message_id: str, file_key: str, resource_type: str = "file" + ) -> tuple[bytes | None, str | None]: + """Download a file/audio/media from a Feishu message by message_id and file_key.""" try: - request = GetMessageResourceRequest.builder() \ - .message_id(message_id) \ - .file_key(file_key) \ - .type(resource_type) \ + request = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) .build() + ) response = self._client.im.v1.message_resource.get(request) - if response.success(): file_data = response.file - # GetMessageResourceRequest 返回的是类似 BytesIO 的对象,需要 read() - if hasattr(file_data, 'read'): + if hasattr(file_data, "read"): file_data = file_data.read() return file_data, response.file_name else: logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg) return None, None - except Exception as e: - logger.error("Error downloading {} {}: {}", resource_type, file_key, e) + except Exception: + logger.exception("Error downloading {} {}", resource_type, file_key) return None, None async def _download_and_save_media( @@ -554,30 +555,20 @@ class FeishuChannel(BaseChannel): if not filename: filename = f"{image_key[:16]}.jpg" - - elif msg_type in ("audio", "file", "media"): file_key = content_json.get("file_key") if file_key and message_id: data, filename = await loop.run_in_executor( - None, self._download_file_sync, message_id, file_key + None, self._download_file_sync, message_id, file_key, msg_type ) if not filename: - if msg_type == "audio": - ext = ".opus" - elif msg_type == "media": - ext = ".mp4" - else: - ext = "" + ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "") filename = f"{file_key[:16]}{ext}" if data and filename: file_path = media_dir / filename - file_path.write_bytes(data) - logger.debug("Downloaded {} to {}", msg_type, file_path) - return str(file_path), f"[{msg_type}: {filename}]" return None, f"[{msg_type}: download failed]" From b653183bb0f76b0f3e157506e9906398efe7c311 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 18:26:42 +0000 Subject: [PATCH 210/415] refactor(providers): move empty content sanitization to base class --- nanobot/providers/base.py | 40 ++++++++++++++++++++++++ nanobot/providers/custom_provider.py | 45 +-------------------------- nanobot/providers/litellm_provider.py | 2 +- 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index c69c38b..eb1599a 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -39,6 +39,46 @@ class LLMProvider(ABC): def __init__(self, api_key: str | None = None, api_base: str | None = None): self.api_key = api_key self.api_base = api_base + + @staticmethod + def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Replace empty text content that causes provider 400 errors. + + Empty content can appear when MCP tools return nothing. Most providers + reject empty-string content or empty text blocks in list content. + """ + result: list[dict[str, Any]] = [] + for msg in messages: + content = msg.get("content") + + if isinstance(content, str) and not content: + clean = dict(msg) + clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)" + result.append(clean) + continue + + if isinstance(content, list): + filtered = [ + item for item in content + if not ( + isinstance(item, dict) + and item.get("type") in ("text", "input_text", "output_text") + and not item.get("text") + ) + ] + if len(filtered) != len(content): + clean = dict(msg) + if filtered: + clean["content"] = filtered + elif msg.get("role") == "assistant" and msg.get("tool_calls"): + clean["content"] = None + else: + clean["content"] = "(empty)" + result.append(clean) + continue + + result.append(msg) + return result @abstractmethod async def chat( diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 9a0901c..a578d14 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -21,7 +21,7 @@ class CustomProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: kwargs: dict[str, Any] = { "model": model or self.default_model, - "messages": self._prevent_empty_text_blocks(messages), + "messages": self._sanitize_empty_content(messages), "max_tokens": max(1, max_tokens), "temperature": temperature, } @@ -50,46 +50,3 @@ class CustomProvider(LLMProvider): def get_default_model(self) -> str: return self.default_model - @staticmethod - def _prevent_empty_text_blocks(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Filter empty text content blocks that cause provider 400 errors. - - When MCP tools return empty content, messages may contain empty-string - text blocks. Most providers (OpenAI-compatible) reject these with 400. - This method filters them out before sending to the API. - """ - patched: list[dict[str, Any]] = [] - for msg in messages: - content = msg.get("content") - - # Empty string content - if isinstance(content, str) and content == "": - clean = dict(msg) - if msg.get("role") == "assistant" and msg.get("tool_calls"): - clean["content"] = None - else: - clean["content"] = "(empty)" - patched.append(clean) - continue - - # List content — filter out empty text items - if isinstance(content, list): - filtered = [ - item for item in content - if not (isinstance(item, dict) - and item.get("type") in {"text", "input_text", "output_text"} - and item.get("text") == "") - ] - if filtered != content: - clean = dict(msg) - if filtered: - clean["content"] = filtered - elif msg.get("role") == "assistant" and msg.get("tool_calls"): - clean["content"] = None - else: - clean["content"] = "(empty)" - patched.append(clean) - continue - - patched.append(msg) - return patched diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 784f02c..7402a2b 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -196,7 +196,7 @@ class LiteLLMProvider(LLMProvider): kwargs: dict[str, Any] = { "model": model, - "messages": self._sanitize_messages(messages), + "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), "max_tokens": max_tokens, "temperature": temperature, } From 25f0a236fdeabd9bda9f4de1622ccdab77bd184f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 18:29:09 +0000 Subject: [PATCH 211/415] docs: fix MiniMax API key link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8399c45..f20e21f 100644 --- a/README.md +++ b/README.md @@ -593,7 +593,7 @@ Config file: `~/.nanobot/config.json` | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) | +| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | | `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) | From 4303026e0da53c5f0b819df33bf91adda8aa3ba5 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sun, 22 Feb 2026 22:01:16 -0300 Subject: [PATCH 212/415] fix: break Discord typing loop on persistent HTTP failure The typing indicator loop catches all exceptions with bare except/pass, so a permanent HTTP failure (client closed, auth error, etc.) causes the loop to spin every 8 seconds doing nothing until the channel is explicitly stopped. Log the error and exit the loop instead, letting the task clean up naturally. --- nanobot/channels/discord.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 1d2d7a6..b9227fb 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -285,8 +285,11 @@ class DiscordChannel(BaseChannel): while self._running: try: await self._http.post(url, headers=headers) - except Exception: - pass + except asyncio.CancelledError: + return + except Exception as e: + logger.debug("Discord typing indicator failed for {}: {}", channel_id, e) + return await asyncio.sleep(8) self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) From 0c412b3728a430f3fed7daea753a23962ad84fa1 Mon Sep 17 00:00:00 2001 From: Yingwen Luo-LUOYW Date: Sun, 22 Feb 2026 23:13:09 +0800 Subject: [PATCH 213/415] feat(channels): add send_progress option to control progress message delivery Add a boolean config option `channels.sendProgress` (default: false) to control whether progress messages (marked with `_progress` metadata) are sent to chat channels. When disabled, progress messages are filtered out in the outbound dispatcher. --- nanobot/channels/manager.py | 3 +++ nanobot/config/schema.py | 1 + 2 files changed, 4 insertions(+) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 6fbab04..8a03883 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -193,6 +193,9 @@ class ChannelManager: timeout=1.0 ) + if msg.metadata.get("_progress") and not self.config.channels.send_progress: + continue + channel = self.channels.get(msg.channel) if channel: try: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 966d11d..bd602dc 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -168,6 +168,7 @@ class QQConfig(Base): class ChannelsConfig(Base): """Configuration for chat channels.""" + send_progress: bool = False whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) From 9025c7088fe834a75addc72efc00630174da911f Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:28:21 +0800 Subject: [PATCH 214/415] fix(heartbeat): route heartbeat runs to enabled chat context --- nanobot/cli/commands.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f1f9b30..b2f6bd3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -389,11 +389,36 @@ def gateway( return response cron.on_job = on_cron_job + # Create channel manager + channels = ChannelManager(config, bus) + + def _pick_heartbeat_target() -> tuple[str, str]: + """Pick a routable channel/chat target for heartbeat-triggered messages.""" + enabled = set(channels.enabled_channels) + # Prefer the most recently updated non-internal session on an enabled channel. + for item in session_manager.list_sessions(): + key = item.get("key") or "" + if ":" not in key: + continue + channel, chat_id = key.split(":", 1) + if channel in {"cli", "system"}: + continue + if channel in enabled and chat_id: + return channel, chat_id + # Fallback keeps prior behavior but remains explicit. + return "cli", "direct" + # Create heartbeat service async def on_heartbeat(prompt: str) -> str: """Execute heartbeat through the agent.""" - return await agent.process_direct(prompt, session_key="heartbeat") - + channel, chat_id = _pick_heartbeat_target() + return await agent.process_direct( + prompt, + session_key="heartbeat", + channel=channel, + chat_id=chat_id, + ) + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, @@ -401,9 +426,6 @@ def gateway( enabled=True ) - # Create channel manager - channels = ChannelManager(config, bus) - if channels.enabled_channels: console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: From bc32e85c25f2366322626d6c8ff98574614be711 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 05:51:44 +0000 Subject: [PATCH 215/415] fix(memory): trigger consolidation by unconsolidated count, not total --- nanobot/agent/loop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b05ba90..0bd05a8 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -352,7 +352,8 @@ class AgentLoop: return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") - if len(session.messages) > self.memory_window and session.key not in self._consolidating: + unconsolidated = len(session.messages) - session.last_consolidated + if (unconsolidated >= self.memory_window and session.key not in self._consolidating): self._consolidating.add(session.key) lock = self._get_consolidation_lock(session.key) From bfdae1b177ed486580416c768f7c91d059464eff Mon Sep 17 00:00:00 2001 From: yzchen Date: Mon, 23 Feb 2026 13:56:37 +0800 Subject: [PATCH 216/415] fix(heartbeat): make start idempotent and check exact OK token --- nanobot/heartbeat/service.py | 15 ++++++++++++- tests/test_heartbeat_service.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_heartbeat_service.py diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 3c1a6aa..ab2bfac 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -1,6 +1,7 @@ """Heartbeat service - periodic agent wake-up to check for tasks.""" import asyncio +import re from pathlib import Path from typing import Any, Callable, Coroutine @@ -35,6 +36,15 @@ def _is_heartbeat_empty(content: str | None) -> bool: return True +def _is_heartbeat_ok_response(response: str | None) -> bool: + """Return True only for an exact HEARTBEAT_OK token (with simple markdown wrappers).""" + if not response: + return False + + normalized = re.sub(r"[\s`*_>\-]", "", response).upper() + return normalized == HEARTBEAT_OK_TOKEN.replace("_", "") + + class HeartbeatService: """ Periodic heartbeat service that wakes the agent to check for tasks. @@ -75,6 +85,9 @@ class HeartbeatService: if not self.enabled: logger.info("Heartbeat disabled") return + if self._running: + logger.warning("Heartbeat already running") + return self._running = True self._task = asyncio.create_task(self._run_loop()) @@ -115,7 +128,7 @@ class HeartbeatService: response = await self.on_heartbeat(HEARTBEAT_PROMPT) # Check if agent said "nothing to do" - if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): + if _is_heartbeat_ok_response(response): logger.info("Heartbeat: OK (no action needed)") else: logger.info("Heartbeat: completed task") diff --git a/tests/test_heartbeat_service.py b/tests/test_heartbeat_service.py new file mode 100644 index 0000000..52d1b96 --- /dev/null +++ b/tests/test_heartbeat_service.py @@ -0,0 +1,40 @@ +import asyncio + +import pytest + +from nanobot.heartbeat.service import ( + HeartbeatService, + _is_heartbeat_ok_response, +) + + +def test_heartbeat_ok_response_requires_exact_token() -> None: + assert _is_heartbeat_ok_response("HEARTBEAT_OK") + assert _is_heartbeat_ok_response("`HEARTBEAT_OK`") + assert _is_heartbeat_ok_response("**HEARTBEAT_OK**") + + assert not _is_heartbeat_ok_response("HEARTBEAT_OK, done") + assert not _is_heartbeat_ok_response("done HEARTBEAT_OK") + assert not _is_heartbeat_ok_response("HEARTBEAT_NOT_OK") + + +@pytest.mark.asyncio +async def test_start_is_idempotent(tmp_path) -> None: + async def _on_heartbeat(_: str) -> str: + return "HEARTBEAT_OK" + + service = HeartbeatService( + workspace=tmp_path, + on_heartbeat=_on_heartbeat, + interval_s=9999, + enabled=True, + ) + + await service.start() + first_task = service._task + await service.start() + + assert service._task is first_task + + service.stop() + await asyncio.sleep(0) From df2c837e252b76a8d3a91bd8a64b8987089f6892 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 07:12:41 +0000 Subject: [PATCH 217/415] feat(channels): split send_progress into send_progress + send_tool_hints --- nanobot/agent/loop.py | 12 +++++++----- nanobot/channels/manager.py | 7 +++++-- nanobot/cli/commands.py | 19 +++++++++++++++++-- nanobot/config/schema.py | 3 ++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 0bd05a8..cd67bdc 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -27,7 +27,7 @@ from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager if TYPE_CHECKING: - from nanobot.config.schema import ExecToolConfig + from nanobot.config.schema import ChannelsConfig, ExecToolConfig from nanobot.cron.service import CronService @@ -59,9 +59,11 @@ class AgentLoop: restrict_to_workspace: bool = False, session_manager: SessionManager | None = None, mcp_servers: dict | None = None, + channels_config: ChannelsConfig | None = None, ): from nanobot.config.schema import ExecToolConfig self.bus = bus + self.channels_config = channels_config self.provider = provider self.workspace = workspace self.model = model or provider.get_default_model() @@ -172,7 +174,7 @@ class AgentLoop: async def _run_agent_loop( self, initial_messages: list[dict], - on_progress: Callable[[str], Awaitable[None]] | None = None, + on_progress: Callable[..., Awaitable[None]] | None = None, ) -> tuple[str | None, list[str]]: """Run the agent iteration loop. Returns (final_content, tools_used).""" messages = initial_messages @@ -196,8 +198,7 @@ class AgentLoop: clean = self._strip_think(response.content) if clean: await on_progress(clean) - else: - await on_progress(self._tool_hint(response.tool_calls)) + await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) tool_call_dicts = [ { @@ -383,9 +384,10 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, ) - async def _bus_progress(content: str) -> None: + async def _bus_progress(content: str, *, tool_hint: bool = False) -> None: meta = dict(msg.metadata or {}) meta["_progress"] = True + meta["_tool_hint"] = tool_hint await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, )) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 8a03883..77b7294 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -193,8 +193,11 @@ class ChannelManager: timeout=1.0 ) - if msg.metadata.get("_progress") and not self.config.channels.send_progress: - continue + if msg.metadata.get("_progress"): + if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: + continue + if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: + continue channel = self.channels.get(msg.channel) if channel: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f1f9b30..fcbd370 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -368,6 +368,7 @@ def gateway( restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, ) # Set cron callback (needs agent) @@ -484,6 +485,7 @@ def agent( cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, ) # Show spinner when logs are off (no output to miss); skip when logs are on @@ -494,7 +496,12 @@ def agent( # Animated spinner is safe to use with prompt_toolkit input handling return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") - async def _cli_progress(content: str) -> None: + async def _cli_progress(content: str, *, tool_hint: bool = False) -> None: + ch = agent_loop.channels_config + if ch and tool_hint and not ch.send_tool_hints: + return + if ch and not tool_hint and not ch.send_progress: + return console.print(f" [dim]↳ {content}[/dim]") if message: @@ -535,7 +542,14 @@ def agent( try: msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) if msg.metadata.get("_progress"): - console.print(f" [dim]↳ {msg.content}[/dim]") + is_tool_hint = msg.metadata.get("_tool_hint", False) + ch = agent_loop.channels_config + if ch and is_tool_hint and not ch.send_tool_hints: + pass + elif ch and not is_tool_hint and not ch.send_progress: + pass + else: + console.print(f" [dim]↳ {msg.content}[/dim]") elif not turn_done.is_set(): if msg.content: turn_response.append(msg.content) @@ -961,6 +975,7 @@ def cron_run( exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, ) store_path = get_data_dir() / "cron" / "jobs.json" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fc9fede..9265602 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -168,7 +168,8 @@ class QQConfig(Base): class ChannelsConfig(Base): """Configuration for chat channels.""" - send_progress: bool = False + send_progress: bool = True # stream agent's text progress to the channel + send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) From 577b3d104a2f2e55e91e89ddbcbf154c760ece4c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 08:08:01 +0000 Subject: [PATCH 218/415] refactor: move workspace/ to nanobot/templates/ for packaging --- README.md | 20 +++ nanobot/cli/commands.py | 80 ++-------- nanobot/templates/AGENTS.md | 29 ++++ {workspace => nanobot/templates}/HEARTBEAT.md | 0 {workspace => nanobot/templates}/SOUL.md | 0 nanobot/templates/TOOLS.md | 36 +++++ {workspace => nanobot/templates}/USER.md | 0 nanobot/templates/__init__.py | 0 .../templates}/memory/MEMORY.md | 0 nanobot/templates/memory/__init__.py | 0 pyproject.toml | 3 +- workspace/AGENTS.md | 51 ------ workspace/TOOLS.md | 150 ------------------ 13 files changed, 102 insertions(+), 267 deletions(-) create mode 100644 nanobot/templates/AGENTS.md rename {workspace => nanobot/templates}/HEARTBEAT.md (100%) rename {workspace => nanobot/templates}/SOUL.md (100%) create mode 100644 nanobot/templates/TOOLS.md rename {workspace => nanobot/templates}/USER.md (100%) create mode 100644 nanobot/templates/__init__.py rename {workspace => nanobot/templates}/memory/MEMORY.md (100%) create mode 100644 nanobot/templates/memory/__init__.py delete mode 100644 workspace/AGENTS.md delete mode 100644 workspace/TOOLS.md diff --git a/README.md b/README.md index f20e21f..8c47f0f 100644 --- a/README.md +++ b/README.md @@ -841,6 +841,26 @@ nanobot cron remove
    +
    +Heartbeat (Periodic Tasks) + +The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel. + +**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`): + +```markdown +## Periodic Tasks + +- [ ] Check weather forecast and send a summary +- [ ] Scan inbox for urgent emails +``` + +The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. + +> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to. + +
    + ## 🐳 Docker > [!TIP] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c8948ee..5edebfa 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -199,84 +199,34 @@ def onboard(): def _create_workspace_templates(workspace: Path): - """Create default workspace template files.""" - templates = { - "AGENTS.md": """# Agent Instructions + """Create default workspace template files from bundled templates.""" + from importlib.resources import files as pkg_files -You are a helpful AI assistant. Be concise, accurate, and friendly. + templates_dir = pkg_files("nanobot") / "templates" -## Guidelines + for item in templates_dir.iterdir(): + if not item.name.endswith(".md"): + continue + dest = workspace / item.name + if not dest.exists(): + dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") + console.print(f" [dim]Created {item.name}[/dim]") -- Always explain what you're doing before taking actions -- Ask for clarification when the request is ambiguous -- Use tools to help accomplish tasks -- Remember important information in memory/MEMORY.md; past events are logged in memory/HISTORY.md -""", - "SOUL.md": """# Soul - -I am nanobot, a lightweight AI assistant. - -## Personality - -- Helpful and friendly -- Concise and to the point -- Curious and eager to learn - -## Values - -- Accuracy over speed -- User privacy and safety -- Transparency in actions -""", - "USER.md": """# User - -Information about the user goes here. - -## Preferences - -- Communication style: (casual/formal) -- Timezone: (your timezone) -- Language: (your preferred language) -""", - } - - for filename, content in templates.items(): - file_path = workspace / filename - if not file_path.exists(): - file_path.write_text(content, encoding="utf-8") - console.print(f" [dim]Created {filename}[/dim]") - - # Create memory directory and MEMORY.md memory_dir = workspace / "memory" memory_dir.mkdir(exist_ok=True) + + memory_template = templates_dir / "memory" / "MEMORY.md" memory_file = memory_dir / "MEMORY.md" if not memory_file.exists(): - memory_file.write_text("""# Long-term Memory - -This file stores important information that should persist across sessions. - -## User Information - -(Important facts about the user) - -## Preferences - -(User preferences learned over time) - -## Important Notes - -(Things to remember) -""", encoding="utf-8") + memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8") console.print(" [dim]Created memory/MEMORY.md[/dim]") - + history_file = memory_dir / "HISTORY.md" if not history_file.exists(): history_file.write_text("", encoding="utf-8") console.print(" [dim]Created memory/HISTORY.md[/dim]") - # Create skills directory for custom user skills - skills_dir = workspace / "skills" - skills_dir.mkdir(exist_ok=True) + (workspace / "skills").mkdir(exist_ok=True) def _make_provider(config: Config): diff --git a/nanobot/templates/AGENTS.md b/nanobot/templates/AGENTS.md new file mode 100644 index 0000000..155a0b2 --- /dev/null +++ b/nanobot/templates/AGENTS.md @@ -0,0 +1,29 @@ +# Agent Instructions + +You are a helpful AI assistant. Be concise, accurate, and friendly. + +## Guidelines + +- Always explain what you're doing before taking actions +- Ask for clarification when the request is ambiguous +- Remember important information in `memory/MEMORY.md`; past events are logged in `memory/HISTORY.md` + +## Scheduled Reminders + +When user asks for a reminder at a specific time, use `exec` to run: +``` +nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL" +``` +Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). + +**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. + +## Heartbeat Tasks + +`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks: + +- **Add**: `edit_file` to append new tasks +- **Remove**: `edit_file` to delete completed tasks +- **Rewrite**: `write_file` to replace all tasks + +When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder. diff --git a/workspace/HEARTBEAT.md b/nanobot/templates/HEARTBEAT.md similarity index 100% rename from workspace/HEARTBEAT.md rename to nanobot/templates/HEARTBEAT.md diff --git a/workspace/SOUL.md b/nanobot/templates/SOUL.md similarity index 100% rename from workspace/SOUL.md rename to nanobot/templates/SOUL.md diff --git a/nanobot/templates/TOOLS.md b/nanobot/templates/TOOLS.md new file mode 100644 index 0000000..757edd2 --- /dev/null +++ b/nanobot/templates/TOOLS.md @@ -0,0 +1,36 @@ +# Tool Usage Notes + +Tool signatures are provided automatically via function calling. +This file documents non-obvious constraints and usage patterns. + +## exec — Safety Limits + +- Commands have a configurable timeout (default 60s) +- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) +- Output is truncated at 10,000 characters +- `restrictToWorkspace` config can limit file access to the workspace + +## Cron — Scheduled Reminders + +Use `exec` to create scheduled reminders: + +```bash +# Recurring: every day at 9am +nanobot cron add --name "morning" --message "Good morning!" --cron "0 9 * * *" + +# With timezone (--tz only works with --cron) +nanobot cron add --name "standup" --message "Standup time!" --cron "0 10 * * 1-5" --tz "Asia/Shanghai" + +# Recurring: every 2 hours +nanobot cron add --name "water" --message "Drink water!" --every 7200 + +# One-time: specific ISO time +nanobot cron add --name "meeting" --message "Meeting starts now!" --at "2025-01-31T15:00:00" + +# Deliver to a specific channel/user +nanobot cron add --name "reminder" --message "Check email" --at "2025-01-31T09:00:00" --deliver --to "USER_ID" --channel "CHANNEL" + +# Manage jobs +nanobot cron list +nanobot cron remove +``` diff --git a/workspace/USER.md b/nanobot/templates/USER.md similarity index 100% rename from workspace/USER.md rename to nanobot/templates/USER.md diff --git a/nanobot/templates/__init__.py b/nanobot/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workspace/memory/MEMORY.md b/nanobot/templates/memory/MEMORY.md similarity index 100% rename from workspace/memory/MEMORY.md rename to nanobot/templates/memory/MEMORY.md diff --git a/nanobot/templates/memory/__init__.py b/nanobot/templates/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index c337d02..cb58ec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,10 +64,11 @@ packages = ["nanobot"] [tool.hatch.build.targets.wheel.sources] "nanobot" = "nanobot" -# Include non-Python files in skills +# Include non-Python files in skills and templates [tool.hatch.build] include = [ "nanobot/**/*.py", + "nanobot/templates/**/*.md", "nanobot/skills/**/*.md", "nanobot/skills/**/*.sh", ] diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md deleted file mode 100644 index 69bd823..0000000 --- a/workspace/AGENTS.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Instructions - -You are a helpful AI assistant. Be concise, accurate, and friendly. - -## Guidelines - -- Always explain what you're doing before taking actions -- Ask for clarification when the request is ambiguous -- Use tools to help accomplish tasks -- Remember important information in your memory files - -## Tools Available - -You have access to: -- File operations (read, write, edit, list) -- Shell commands (exec) -- Web access (search, fetch) -- Messaging (message) -- Background tasks (spawn) - -## Memory - -- `memory/MEMORY.md` — long-term facts (preferences, context, relationships) -- `memory/HISTORY.md` — append-only event log, search with grep to recall past events - -## Scheduled Reminders - -When user asks for a reminder at a specific time, use `exec` to run: -``` -nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL" -``` -Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). - -**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. - -## Heartbeat Tasks - -`HEARTBEAT.md` is checked every 30 minutes. You can manage periodic tasks by editing this file: - -- **Add a task**: Use `edit_file` to append new tasks to `HEARTBEAT.md` -- **Remove a task**: Use `edit_file` to remove completed or obsolete tasks -- **Rewrite tasks**: Use `write_file` to completely rewrite the task list - -Task format examples: -``` -- [ ] Check calendar and remind of upcoming events -- [ ] Scan inbox for urgent emails -- [ ] Check weather forecast for today -``` - -When the user asks you to add a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time reminder. Keep the file small to minimize token usage. diff --git a/workspace/TOOLS.md b/workspace/TOOLS.md deleted file mode 100644 index 0134a64..0000000 --- a/workspace/TOOLS.md +++ /dev/null @@ -1,150 +0,0 @@ -# Available Tools - -This document describes the tools available to nanobot. - -## File Operations - -### read_file -Read the contents of a file. -``` -read_file(path: str) -> str -``` - -### write_file -Write content to a file (creates parent directories if needed). -``` -write_file(path: str, content: str) -> str -``` - -### edit_file -Edit a file by replacing specific text. -``` -edit_file(path: str, old_text: str, new_text: str) -> str -``` - -### list_dir -List contents of a directory. -``` -list_dir(path: str) -> str -``` - -## Shell Execution - -### exec -Execute a shell command and return output. -``` -exec(command: str, working_dir: str = None) -> str -``` - -**Safety Notes:** -- Commands have a configurable timeout (default 60s) -- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) -- Output is truncated at 10,000 characters -- Optional `restrictToWorkspace` config to limit paths - -## Web Access - -### web_search -Search the web using Brave Search API. -``` -web_search(query: str, count: int = 5) -> str -``` - -Returns search results with titles, URLs, and snippets. Requires `tools.web.search.apiKey` in config. - -### web_fetch -Fetch and extract main content from a URL. -``` -web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str -``` - -**Notes:** -- Content is extracted using readability -- Supports markdown or plain text extraction -- Output is truncated at 50,000 characters by default - -## Communication - -### message -Send a message to the user (used internally). -``` -message(content: str, channel: str = None, chat_id: str = None) -> str -``` - -## Background Tasks - -### spawn -Spawn a subagent to handle a task in the background. -``` -spawn(task: str, label: str = None) -> str -``` - -Use for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done. - -## Scheduled Reminders (Cron) - -Use the `exec` tool to create scheduled reminders with `nanobot cron add`: - -### Set a recurring reminder -```bash -# Every day at 9am -nanobot cron add --name "morning" --message "Good morning! ☀️" --cron "0 9 * * *" - -# Every 2 hours -nanobot cron add --name "water" --message "Drink water! 💧" --every 7200 -``` - -### Set a one-time reminder -```bash -# At a specific time (ISO format) -nanobot cron add --name "meeting" --message "Meeting starts now!" --at "2025-01-31T15:00:00" -``` - -### Manage reminders -```bash -nanobot cron list # List all jobs -nanobot cron remove # Remove a job -``` - -## Heartbeat Task Management - -The `HEARTBEAT.md` file in the workspace is checked every 30 minutes. -Use file operations to manage periodic tasks: - -### Add a heartbeat task -```python -# Append a new task -edit_file( - path="HEARTBEAT.md", - old_text="## Example Tasks", - new_text="- [ ] New periodic task here\n\n## Example Tasks" -) -``` - -### Remove a heartbeat task -```python -# Remove a specific task -edit_file( - path="HEARTBEAT.md", - old_text="- [ ] Task to remove\n", - new_text="" -) -``` - -### Rewrite all tasks -```python -# Replace the entire file -write_file( - path="HEARTBEAT.md", - content="# Heartbeat Tasks\n\n- [ ] Task 1\n- [ ] Task 2\n" -) -``` - ---- - -## Adding Custom Tools - -To add custom tools: -1. Create a class that extends `Tool` in `nanobot/agent/tools/` -2. Implement `name`, `description`, `parameters`, and `execute` -3. Register it in `AgentLoop._register_default_tools()` From 491739223d8a5e8bfea0fc040971dffb8a6f3d0f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 08:24:53 +0000 Subject: [PATCH 219/415] fix: lower default temperature from 0.7 to 0.1 --- nanobot/agent/loop.py | 2 +- nanobot/config/schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index cd67bdc..296c908 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -50,7 +50,7 @@ class AgentLoop: workspace: Path, model: str | None = None, max_iterations: int = 20, - temperature: float = 0.7, + temperature: float = 0.1, max_tokens: int = 4096, memory_window: int = 50, brave_api_key: str | None = None, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9265602..10e3fa5 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -187,7 +187,7 @@ class AgentDefaults(Base): workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 - temperature: float = 0.7 + temperature: float = 0.1 max_tool_iterations: int = 20 memory_window: int = 50 From d9462284e1549f86570874096e2e4ea343a7bc17 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 09:13:08 +0000 Subject: [PATCH 220/415] improve agent reliability: behavioral constraints, full tool history, error hints --- README.md | 2 +- nanobot/agent/context.py | 18 +++++++----- nanobot/agent/loop.py | 49 +++++++++++++++++++++++---------- nanobot/agent/tools/registry.py | 13 ++++++--- nanobot/config/schema.py | 4 +-- 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8c47f0f..148c8f4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,862 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,897 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index c5869f3..98c13f2 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -96,14 +96,18 @@ Your workspace is at: {workspace_path} - History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md -IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). -For normal conversation, just respond with text - do not call the message tool. +Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel. -Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). -If you need to use tools, call them directly — never send a preliminary message like "Let me check" without actually calling a tool. -When remembering something important, write to {workspace_path}/memory/MEMORY.md -To recall past events, grep {workspace_path}/memory/HISTORY.md""" +## Tool Call Guidelines +- Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it. +- Before modifying a file, read it first to confirm its current content. +- Do not assume a file or directory exists — use list_dir or read_file to verify. +- After writing or editing a file, re-read it if accuracy matters. +- If a tool call fails, analyze the error before retrying with a different approach. + +## Memory +- Remember important facts: write to {workspace_path}/memory/MEMORY.md +- Recall past events: grep {workspace_path}/memory/HISTORY.md""" def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 296c908..8be8e51 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -49,10 +49,10 @@ class AgentLoop: provider: LLMProvider, workspace: Path, model: str | None = None, - max_iterations: int = 20, + max_iterations: int = 40, temperature: float = 0.1, max_tokens: int = 4096, - memory_window: int = 50, + memory_window: int = 100, brave_api_key: str | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, @@ -175,8 +175,8 @@ class AgentLoop: self, initial_messages: list[dict], on_progress: Callable[..., Awaitable[None]] | None = None, - ) -> tuple[str | None, list[str]]: - """Run the agent iteration loop. Returns (final_content, tools_used).""" + ) -> tuple[str | None, list[str], list[dict]]: + """Run the agent iteration loop. Returns (final_content, tools_used, messages).""" messages = initial_messages iteration = 0 final_content = None @@ -228,7 +228,14 @@ class AgentLoop: final_content = self._strip_think(response.content) break - return final_content, tools_used + if final_content is None and iteration >= self.max_iterations: + logger.warning("Max iterations ({}) reached", self.max_iterations) + final_content = ( + f"I reached the maximum number of tool call iterations ({self.max_iterations}) " + "without completing the task. You can try breaking the task into smaller steps." + ) + + return final_content, tools_used, messages async def run(self) -> None: """Run the agent loop, processing messages from the bus.""" @@ -301,13 +308,13 @@ class AgentLoop: key = f"{channel}:{chat_id}" session = self.sessions.get_or_create(key) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) + history = session.get_history(max_messages=self.memory_window) messages = self.context.build_messages( - history=session.get_history(max_messages=self.memory_window), + history=history, current_message=msg.content, channel=channel, chat_id=chat_id, ) - final_content, _ = await self._run_agent_loop(messages) - session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") - session.add_message("assistant", final_content or "Background task completed.") + final_content, _, all_msgs = await self._run_agent_loop(messages) + self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) return OutboundMessage(channel=channel, chat_id=chat_id, content=final_content or "Background task completed.") @@ -377,8 +384,9 @@ class AgentLoop: if isinstance(message_tool, MessageTool): message_tool.start_turn() + history = session.get_history(max_messages=self.memory_window) initial_messages = self.context.build_messages( - history=session.get_history(max_messages=self.memory_window), + history=history, current_message=msg.content, media=msg.media if msg.media else None, channel=msg.channel, chat_id=msg.chat_id, @@ -392,7 +400,7 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, )) - final_content, tools_used = await self._run_agent_loop( + final_content, _, all_msgs = await self._run_agent_loop( initial_messages, on_progress=on_progress or _bus_progress, ) @@ -402,9 +410,7 @@ class AgentLoop: preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - session.add_message("user", msg.content) - session.add_message("assistant", final_content, - tools_used=tools_used if tools_used else None) + self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) if message_tool := self.tools.get("message"): @@ -416,6 +422,21 @@ class AgentLoop: metadata=msg.metadata or {}, ) + _TOOL_RESULT_MAX_CHARS = 500 + + def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None: + """Save new-turn messages into session, truncating large tool results.""" + from datetime import datetime + for m in messages[skip:]: + entry = {k: v for k, v in m.items() if k != "reasoning_content"} + if entry.get("role") == "tool" and isinstance(entry.get("content"), str): + content = entry["content"] + if len(content) > self._TOOL_RESULT_MAX_CHARS: + entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" + entry.setdefault("timestamp", datetime.now().isoformat()) + session.messages.append(entry) + session.updated_at = datetime.now() + async def _consolidate_memory(self, session, archive_all: bool = False) -> bool: """Delegate to MemoryStore.consolidate(). Returns True on success.""" return await MemoryStore(self.workspace).consolidate( diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index d9b33ff..8256a59 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -49,17 +49,22 @@ class ToolRegistry: Raises: KeyError: If tool not found. """ + _HINT = "\n\n[Analyze the error above and try a different approach.]" + tool = self._tools.get(name) if not tool: - return f"Error: Tool '{name}' not found" + return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" try: errors = tool.validate_params(params) if errors: - return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) - return await tool.execute(**params) + return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT + result = await tool.execute(**params) + if isinstance(result, str) and result.startswith("Error"): + return result + _HINT + return result except Exception as e: - return f"Error executing {name}: {str(e)}" + return f"Error executing {name}: {str(e)}" + _HINT @property def tool_names(self) -> list[str]: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 10e3fa5..fe8dd83 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -188,8 +188,8 @@ class AgentDefaults(Base): model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 temperature: float = 0.1 - max_tool_iterations: int = 20 - memory_window: int = 50 + max_tool_iterations: int = 40 + memory_window: int = 100 class AgentsConfig(Base): From 1f7a81e5eebafad5e21c5760a92880c3155bcefe Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 23 Feb 2026 10:15:12 +0000 Subject: [PATCH 221/415] feat(slack): isolate session context per thread Each Slack thread now gets its own conversation session instead of sharing one session per channel. DM sessions are unchanged. Added as a generic feature to also support if Feishu threads support is added in the future. --- nanobot/bus/events.py | 3 ++- nanobot/channels/base.py | 4 +++- nanobot/channels/slack.py | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index a149e20..a48660d 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -16,11 +16,12 @@ class InboundMessage: timestamp: datetime = field(default_factory=datetime.now) media: list[str] = field(default_factory=list) # Media URLs metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data + session_key_override: str | None = None # Optional override for thread-scoped sessions @property def session_key(self) -> str: """Unique key for session identification.""" - return f"{self.channel}:{self.chat_id}" + return self.session_key_override or f"{self.channel}:{self.chat_id}" @dataclass diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 3a5a785..2201686 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -111,13 +111,15 @@ class BaseChannel(ABC): ) return + meta = metadata or {} msg = InboundMessage( channel=self.name, sender_id=str(sender_id), chat_id=str(chat_id), content=content, media=media or [], - metadata=metadata or {} + metadata=meta, + session_key_override=meta.get("session_key"), ) await self.bus.publish_inbound(msg) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index b0f9bbb..2e91f7b 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -179,6 +179,9 @@ class SlackChannel(BaseChannel): except Exception as e: logger.debug("Slack reactions_add failed: {}", e) + # Thread-scoped session key for channel/group messages + session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None + try: await self._handle_message( sender_id=sender_id, @@ -189,7 +192,8 @@ class SlackChannel(BaseChannel): "event": event, "thread_ts": thread_ts, "channel_type": channel_type, - } + }, + "session_key": session_key, }, ) except Exception: From ea1c4ef02566a94d9d2c9593698a626365e183de Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 12:33:29 +0000 Subject: [PATCH 222/415] fix: suppress heartbeat progress messages to external channels --- nanobot/cli/commands.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5edebfa..e1df6ad 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -363,11 +363,17 @@ def gateway( async def on_heartbeat(prompt: str) -> str: """Execute heartbeat through the agent.""" channel, chat_id = _pick_heartbeat_target() + + async def _silent(*_args, **_kwargs): + pass + return await agent.process_direct( prompt, session_key="heartbeat", channel=channel, chat_id=chat_id, + # suppress: heartbeat should not push progress to external channels + on_progress=_silent, ) heartbeat = HeartbeatService( From 2b983c708dc0f99ad8402b1eaafdf2b14894eeeb Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 13:10:47 +0000 Subject: [PATCH 223/415] refactor: pass session_key as explicit param instead of via metadata --- nanobot/channels/base.py | 9 +++++---- nanobot/channels/slack.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 2201686..3010373 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -89,7 +89,8 @@ class BaseChannel(ABC): chat_id: str, content: str, media: list[str] | None = None, - metadata: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None, + session_key: str | None = None, ) -> None: """ Handle an incoming message from the chat platform. @@ -102,6 +103,7 @@ class BaseChannel(ABC): content: Message text content. media: Optional list of media URLs. metadata: Optional channel-specific metadata. + session_key: Optional session key override (e.g. thread-scoped sessions). """ if not self.is_allowed(sender_id): logger.warning( @@ -111,15 +113,14 @@ class BaseChannel(ABC): ) return - meta = metadata or {} msg = InboundMessage( channel=self.name, sender_id=str(sender_id), chat_id=str(chat_id), content=content, media=media or [], - metadata=meta, - session_key_override=meta.get("session_key"), + metadata=metadata or {}, + session_key_override=session_key, ) await self.bus.publish_inbound(msg) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 2e91f7b..906593b 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -193,8 +193,8 @@ class SlackChannel(BaseChannel): "thread_ts": thread_ts, "channel_type": channel_type, }, - "session_key": session_key, }, + session_key=session_key, ) except Exception: logger.exception("Error handling Slack message from {}", sender_id) From 7671239902f479c8c0b60aac53454e6ef8f28146 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 13:45:09 +0000 Subject: [PATCH 224/415] fix(heartbeat): suppress progress messages and deliver agent response to user --- nanobot/cli/commands.py | 12 +++++++++-- nanobot/heartbeat/service.py | 40 ++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index e1df6ad..90b9f44 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -372,13 +372,21 @@ def gateway( session_key="heartbeat", channel=channel, chat_id=chat_id, - # suppress: heartbeat should not push progress to external channels - on_progress=_silent, + on_progress=_silent, # suppress: heartbeat should not push progress to external channels ) + async def on_heartbeat_notify(response: str) -> None: + """Deliver a heartbeat response to the user's channel.""" + from nanobot.bus.events import OutboundMessage + channel, chat_id = _pick_heartbeat_target() + if channel == "cli": + return # No external channel available to deliver to + await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, + on_notify=on_heartbeat_notify, interval_s=30 * 60, # 30 minutes enabled=True ) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 3c1a6aa..7dbdc03 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -9,14 +9,15 @@ from loguru import logger # Default interval: 30 minutes DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 -# The prompt sent to agent during heartbeat -HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists). -Follow any instructions or tasks listed there. -If nothing needs attention, reply with just: HEARTBEAT_OK""" - -# Token that indicates "nothing to do" +# Token the agent replies with when there is nothing to report HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" +# The prompt sent to agent during heartbeat +HEARTBEAT_PROMPT = ( + "Read HEARTBEAT.md in your workspace and follow any instructions listed there. " + f"If nothing needs attention, reply with exactly: {HEARTBEAT_OK_TOKEN}" +) + def _is_heartbeat_empty(content: str | None) -> bool: """Check if HEARTBEAT.md has no actionable content.""" @@ -38,20 +39,24 @@ def _is_heartbeat_empty(content: str | None) -> bool: class HeartbeatService: """ Periodic heartbeat service that wakes the agent to check for tasks. - - The agent reads HEARTBEAT.md from the workspace and executes any - tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK. + + The agent reads HEARTBEAT.md from the workspace and executes any tasks + listed there. If it has something to report, the response is forwarded + to the user via on_notify. If nothing needs attention, the agent replies + HEARTBEAT_OK and the response is silently dropped. """ - + def __init__( self, workspace: Path, on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None, + on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None, interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S, enabled: bool = True, ): self.workspace = workspace self.on_heartbeat = on_heartbeat + self.on_notify = on_notify self.interval_s = interval_s self.enabled = enabled self._running = False @@ -113,15 +118,14 @@ class HeartbeatService: if self.on_heartbeat: try: response = await self.on_heartbeat(HEARTBEAT_PROMPT) - - # Check if agent said "nothing to do" - if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): - logger.info("Heartbeat: OK (no action needed)") + if HEARTBEAT_OK_TOKEN in response.upper(): + logger.info("Heartbeat: OK (nothing to report)") else: - logger.info("Heartbeat: completed task") - - except Exception as e: - logger.error("Heartbeat execution failed: {}", e) + logger.info("Heartbeat: completed, delivering response") + if self.on_notify: + await self.on_notify(response) + except Exception: + logger.exception("Heartbeat execution failed") async def trigger_now(self) -> str | None: """Manually trigger a heartbeat.""" From eae6059889bcb8000ab8f5dc8fdbe4b9c8816180 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 13:59:47 +0000 Subject: [PATCH 225/415] fix: remove extra blank line --- nanobot/heartbeat/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 3e40f2f..cb1a1c7 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -36,7 +36,6 @@ def _is_heartbeat_empty(content: str | None) -> bool: return True - class HeartbeatService: """ Periodic heartbeat service that wakes the agent to check for tasks. From 35e3f7ed26a02c9d6e47393944069b8ad297175d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 14:10:43 +0000 Subject: [PATCH 226/415] fix(templates): tighten AGENTS.md tool call guidelines to reduce hallucinations --- nanobot/templates/AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nanobot/templates/AGENTS.md b/nanobot/templates/AGENTS.md index 155a0b2..84ba657 100644 --- a/nanobot/templates/AGENTS.md +++ b/nanobot/templates/AGENTS.md @@ -4,7 +4,9 @@ You are a helpful AI assistant. Be concise, accurate, and friendly. ## Guidelines -- Always explain what you're doing before taking actions +- Before calling tools, briefly state your intent — but NEVER predict results before receiving them +- Use precise tense: "I will run X" before the call, "X returned Y" after +- NEVER claim success before a tool result confirms it - Ask for clarification when the request is ambiguous - Remember important information in `memory/MEMORY.md`; past events are logged in `memory/HISTORY.md` From 2f573e591bce5e851bb0926588af7fc5f87718e6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 16:57:08 +0000 Subject: [PATCH 227/415] fix(session): get_history uses last_consolidated cursor, aligns to user turn --- nanobot/session/manager.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 5f23dc2..d59b7c9 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -43,9 +43,18 @@ class Session: self.updated_at = datetime.now() def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]: - """Get recent messages in LLM format, preserving tool metadata.""" + """Return unconsolidated messages for LLM input, aligned to a user turn.""" + unconsolidated = self.messages[self.last_consolidated:] + sliced = unconsolidated[-max_messages:] + + # Drop leading non-user messages to avoid orphaned tool_result blocks + for i, m in enumerate(sliced): + if m.get("role") == "user": + sliced = sliced[i:] + break + out: list[dict[str, Any]] = [] - for m in self.messages[-max_messages:]: + for m in sliced: entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")} for k in ("tool_calls", "tool_call_id", "name"): if k in m: From 3eeac4e8f89c979e9f922a7731100fa23a642c64 Mon Sep 17 00:00:00 2001 From: alairjt Date: Mon, 23 Feb 2026 13:59:49 -0300 Subject: [PATCH 228/415] Fix: handle non-string tool call arguments in memory consolidation Fixes #1042. When the LLM returns tool call arguments as a dict or JSON string instead of parsed values, memory consolidation would fail with "TypeError: data must be str, not dict". Changes: - Add type guard in MemoryStore.consolidate() to parse string arguments and reject unexpected types gracefully - Add regression tests covering dict args, string args, and edge cases --- nanobot/agent/memory.py | 7 ++ tests/test_memory_consolidation_types.py | 147 +++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/test_memory_consolidation_types.py diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index cdbc49f..978b1bc 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -125,6 +125,13 @@ class MemoryStore: return False args = response.tool_calls[0].arguments + # Some providers return arguments as a JSON string instead of dict + if isinstance(args, str): + args = json.loads(args) + if not isinstance(args, dict): + logger.warning("Memory consolidation: unexpected arguments type %s", type(args).__name__) + return False + if entry := args.get("history_entry"): if not isinstance(entry, str): entry = json.dumps(entry, ensure_ascii=False) diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py new file mode 100644 index 0000000..375c802 --- /dev/null +++ b/tests/test_memory_consolidation_types.py @@ -0,0 +1,147 @@ +"""Test MemoryStore.consolidate() handles non-string tool call arguments. + +Regression test for https://github.com/HKUDS/nanobot/issues/1042 +When memory consolidation receives dict values instead of strings from the LLM +tool call response, it should serialize them to JSON instead of raising TypeError. +""" + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.agent.memory import MemoryStore +from nanobot.providers.base import LLMResponse, ToolCallRequest + + +def _make_session(message_count: int = 30, memory_window: int = 50): + """Create a mock session with messages.""" + session = MagicMock() + session.messages = [ + {"role": "user", "content": f"msg{i}", "timestamp": "2026-01-01 00:00"} + for i in range(message_count) + ] + session.last_consolidated = 0 + return session + + +def _make_tool_response(history_entry, memory_update): + """Create an LLMResponse with a save_memory tool call.""" + return LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments={ + "history_entry": history_entry, + "memory_update": memory_update, + }, + ) + ], + ) + + +class TestMemoryConsolidationTypeHandling: + """Test that consolidation handles various argument types correctly.""" + + @pytest.mark.asyncio + async def test_string_arguments_work(self, tmp_path: Path) -> None: + """Normal case: LLM returns string arguments.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + provider.chat = AsyncMock( + return_value=_make_tool_response( + history_entry="[2026-01-01] User discussed testing.", + memory_update="# Memory\nUser likes testing.", + ) + ) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + assert store.history_file.exists() + assert "[2026-01-01] User discussed testing." in store.history_file.read_text() + assert "User likes testing." in store.memory_file.read_text() + + @pytest.mark.asyncio + async def test_dict_arguments_serialized_to_json(self, tmp_path: Path) -> None: + """Issue #1042: LLM returns dict instead of string — must not raise TypeError.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + provider.chat = AsyncMock( + return_value=_make_tool_response( + history_entry={"timestamp": "2026-01-01", "summary": "User discussed testing."}, + memory_update={"facts": ["User likes testing"], "topics": ["testing"]}, + ) + ) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + assert store.history_file.exists() + history_content = store.history_file.read_text() + parsed = json.loads(history_content.strip()) + assert parsed["summary"] == "User discussed testing." + + memory_content = store.memory_file.read_text() + parsed_mem = json.loads(memory_content) + assert "User likes testing" in parsed_mem["facts"] + + @pytest.mark.asyncio + async def test_string_arguments_as_raw_json(self, tmp_path: Path) -> None: + """Some providers return arguments as a JSON string instead of parsed dict.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + # Simulate arguments being a JSON string (not yet parsed) + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=json.dumps({ + "history_entry": "[2026-01-01] User discussed testing.", + "memory_update": "# Memory\nUser likes testing.", + }), + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + assert "User discussed testing." in store.history_file.read_text() + + @pytest.mark.asyncio + async def test_no_tool_call_returns_false(self, tmp_path: Path) -> None: + """When LLM doesn't use the save_memory tool, return False.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + provider.chat = AsyncMock( + return_value=LLMResponse(content="I summarized the conversation.", tool_calls=[]) + ) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + assert not store.history_file.exists() + + @pytest.mark.asyncio + async def test_skips_when_few_messages(self, tmp_path: Path) -> None: + """Consolidation should be a no-op when messages < keep_count.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + session = _make_session(message_count=10) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + provider.chat.assert_not_called() From f8dc6fafa946ef87266448c97e05135ace6d640e Mon Sep 17 00:00:00 2001 From: dulltackle Date: Tue, 24 Feb 2026 01:26:56 +0800 Subject: [PATCH 229/415] **fix(mcp): Remove default timeout for HTTP transport to avoid tool timeout conflicts** Always provide an explicit httpx client to prevent MCP HTTP transport from inheriting httpx's default 5-second timeout, thereby avoiding conflicts with the upper layer tool's timeout settings. --- nanobot/agent/tools/mcp.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 0257d52..37464e1 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -69,20 +69,18 @@ async def connect_mcp_servers( read, write = await stack.enter_async_context(stdio_client(params)) elif cfg.url: from mcp.client.streamable_http import streamable_http_client - if cfg.headers: - http_client = await stack.enter_async_context( - httpx.AsyncClient( - headers=cfg.headers, - follow_redirects=True - ) - ) - read, write, _ = await stack.enter_async_context( - streamable_http_client(cfg.url, http_client=http_client) - ) - else: - read, write, _ = await stack.enter_async_context( - streamable_http_client(cfg.url) + # Always provide an explicit httpx client so MCP HTTP transport does not + # inherit httpx's default 5s timeout and preempt the higher-level tool timeout. + http_client = await stack.enter_async_context( + httpx.AsyncClient( + headers=cfg.headers or None, + follow_redirects=True, + timeout=None, ) + ) + read, write, _ = await stack.enter_async_context( + streamable_http_client(cfg.url, http_client=http_client) + ) else: logger.warning("MCP server '{}': no command or url configured, skipping", name) continue From 30361c9307f9014f49530d80abd5717bc97f554a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 18:28:09 +0000 Subject: [PATCH 230/415] refactor: replace cron usage docs in TOOLS.md with reference to cron skill --- nanobot/templates/TOOLS.md | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/nanobot/templates/TOOLS.md b/nanobot/templates/TOOLS.md index 757edd2..51c3a2d 100644 --- a/nanobot/templates/TOOLS.md +++ b/nanobot/templates/TOOLS.md @@ -10,27 +10,6 @@ This file documents non-obvious constraints and usage patterns. - Output is truncated at 10,000 characters - `restrictToWorkspace` config can limit file access to the workspace -## Cron — Scheduled Reminders +## cron — Scheduled Reminders -Use `exec` to create scheduled reminders: - -```bash -# Recurring: every day at 9am -nanobot cron add --name "morning" --message "Good morning!" --cron "0 9 * * *" - -# With timezone (--tz only works with --cron) -nanobot cron add --name "standup" --message "Standup time!" --cron "0 10 * * 1-5" --tz "Asia/Shanghai" - -# Recurring: every 2 hours -nanobot cron add --name "water" --message "Drink water!" --every 7200 - -# One-time: specific ISO time -nanobot cron add --name "meeting" --message "Meeting starts now!" --at "2025-01-31T15:00:00" - -# Deliver to a specific channel/user -nanobot cron add --name "reminder" --message "Check email" --at "2025-01-31T09:00:00" --deliver --to "USER_ID" --channel "CHANNEL" - -# Manage jobs -nanobot cron list -nanobot cron remove -``` +- Please refer to cron skill for usage. From eeaad6e0c2ffb0e684ee7c19eef7d09dbdf0c447 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Tue, 24 Feb 2026 04:06:22 +0800 Subject: [PATCH 231/415] fix: resolve API key at call time so config changes take effect without restart Previously, WebSearchTool cached the API key in __init__, so keys added to config.json or env vars after gateway startup were never picked up. This caused a confusing 'BRAVE_API_KEY not configured' error even after the key was correctly set (issue #1069). Changes: - Store the init-time key separately, resolve via property at each call - Improve error message to guide users toward the correct fix Closes #1069 --- nanobot/agent/tools/web.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 90cdda8..ae69e9e 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -58,12 +58,22 @@ class WebSearchTool(Tool): } def __init__(self, api_key: str | None = None, max_results: int = 5): - self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "") + self._init_api_key = api_key self.max_results = max_results + + @property + def api_key(self) -> str: + """Resolve API key at call time so env/config changes are picked up.""" + return self._init_api_key or os.environ.get("BRAVE_API_KEY", "") async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: if not self.api_key: - return "Error: BRAVE_API_KEY not configured" + return ( + "Error: Brave Search API key not configured. " + "Set BRAVE_API_KEY environment variable or add " + "tools.web.search.apiKey to ~/.nanobot/config.json, " + "then restart the gateway." + ) try: n = min(max(count or self.max_results, 1), 10) From 8de2f8d58845a13aa7c8df290a9da37705fd4160 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Tue, 24 Feb 2026 04:21:55 +0800 Subject: [PATCH 232/415] fix: preserve reasoning_content in message sanitization for thinking models _sanitize_messages strips all non-standard keys from messages, including reasoning_content. Thinking-enabled models like Moonshot Kimi k2.5 require reasoning_content to be present in assistant tool call messages when thinking mode is on, causing a BadRequestError (#1014). Add reasoning_content to _ALLOWED_MSG_KEYS so it passes through sanitization when present. Fixes #1014 --- nanobot/providers/litellm_provider.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 7402a2b..0918954 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -12,8 +12,9 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway -# Standard OpenAI chat-completion message keys; extras (e.g. reasoning_content) are stripped for strict providers. -_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) +# Standard OpenAI chat-completion message keys plus reasoning_content for +# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.). +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}) class LiteLLMProvider(LLMProvider): From 91e13d91ac1001d0e10fbe04fa13225c6efecb3a Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Tue, 24 Feb 2026 04:26:26 +0800 Subject: [PATCH 233/415] fix(email): allow proactive sends when autoReplyEnabled is false Previously, `autoReplyEnabled=false` would block ALL email sends, including proactive emails triggered from other channels (e.g., asking nanobot on Feishu to send an email). Now `autoReplyEnabled` only controls automatic replies to incoming emails, not proactive sends. This allows users to disable auto-replies while still being able to ask nanobot to send emails on demand. Changes: - Check if recipient is in `_last_subject_by_chat` to determine if it's a reply - Only skip sending when it's a reply AND auto_reply_enabled is false - Add test for proactive send with auto_reply_enabled=false - Update existing test to verify reply behavior --- nanobot/channels/email.py | 14 +++++---- tests/test_email_channel.py | 59 ++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 5dc05fb..16771fb 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -108,11 +108,6 @@ class EmailChannel(BaseChannel): logger.warning("Skip email send: consent_granted is false") return - force_send = bool((msg.metadata or {}).get("force_send")) - if not self.config.auto_reply_enabled and not force_send: - logger.info("Skip automatic email reply: auto_reply_enabled is false") - return - if not self.config.smtp_host: logger.warning("Email channel SMTP host not configured") return @@ -122,6 +117,15 @@ class EmailChannel(BaseChannel): logger.warning("Email channel missing recipient address") return + # Determine if this is a reply (recipient has sent us an email before) + is_reply = to_addr in self._last_subject_by_chat + force_send = bool((msg.metadata or {}).get("force_send")) + + # autoReplyEnabled only controls automatic replies, not proactive sends + if is_reply and not self.config.auto_reply_enabled and not force_send: + logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr) + return + base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply") subject = self._reply_subject(base_subject) if msg.metadata and isinstance(msg.metadata.get("subject"), str): diff --git a/tests/test_email_channel.py b/tests/test_email_channel.py index 8b22d8d..adf35a8 100644 --- a/tests/test_email_channel.py +++ b/tests/test_email_channel.py @@ -169,7 +169,8 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None: @pytest.mark.asyncio -async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None: +async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None: + """When auto_reply_enabled=False, replies should be skipped but proactive sends allowed.""" class FakeSMTP: def __init__(self, _host: str, _port: int, timeout: int = 30) -> None: self.sent_messages: list[EmailMessage] = [] @@ -201,6 +202,11 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None: cfg = _make_config() cfg.auto_reply_enabled = False channel = EmailChannel(cfg, MessageBus()) + + # Mark alice as someone who sent us an email (making this a "reply") + channel._last_subject_by_chat["alice@example.com"] = "Previous email" + + # Reply should be skipped (auto_reply_enabled=False) await channel.send( OutboundMessage( channel="email", @@ -210,6 +216,7 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None: ) assert fake_instances == [] + # Reply with force_send=True should be sent await channel.send( OutboundMessage( channel="email", @@ -222,6 +229,56 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None: assert len(fake_instances[0].sent_messages) == 1 +@pytest.mark.asyncio +async def test_send_proactive_email_when_auto_reply_disabled(monkeypatch) -> None: + """Proactive emails (not replies) should be sent even when auto_reply_enabled=False.""" + class FakeSMTP: + def __init__(self, _host: str, _port: int, timeout: int = 30) -> None: + self.sent_messages: list[EmailMessage] = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def starttls(self, context=None): + return None + + def login(self, _user: str, _pw: str): + return None + + def send_message(self, msg: EmailMessage): + self.sent_messages.append(msg) + + fake_instances: list[FakeSMTP] = [] + + def _smtp_factory(host: str, port: int, timeout: int = 30): + instance = FakeSMTP(host, port, timeout=timeout) + fake_instances.append(instance) + return instance + + monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory) + + cfg = _make_config() + cfg.auto_reply_enabled = False + channel = EmailChannel(cfg, MessageBus()) + + # bob@example.com has never sent us an email (proactive send) + # This should be sent even with auto_reply_enabled=False + await channel.send( + OutboundMessage( + channel="email", + chat_id="bob@example.com", + content="Hello, this is a proactive email.", + ) + ) + assert len(fake_instances) == 1 + assert len(fake_instances[0].sent_messages) == 1 + sent = fake_instances[0].sent_messages[0] + assert sent["To"] == "bob@example.com" + + @pytest.mark.asyncio async def test_send_skips_when_consent_not_granted(monkeypatch) -> None: class FakeSMTP: From abcce1e1db3282651a916f5de9193bb4025ff559 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 Date: Tue, 24 Feb 2026 03:18:23 +0000 Subject: [PATCH 234/415] feat(exec): add path_append config to extend PATH for subprocess --- nanobot/agent/tools/shell.py | 7 +++++++ nanobot/config/schema.py | 1 + 2 files changed, 8 insertions(+) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index e3592a7..c11fa2d 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -19,6 +19,7 @@ class ExecTool(Tool): deny_patterns: list[str] | None = None, allow_patterns: list[str] | None = None, restrict_to_workspace: bool = False, + path_append: str = "/usr/sbin:/usr/local/sbin", ): self.timeout = timeout self.working_dir = working_dir @@ -35,6 +36,7 @@ class ExecTool(Tool): ] self.allow_patterns = allow_patterns or [] self.restrict_to_workspace = restrict_to_workspace + self.path_append = path_append @property def name(self) -> str: @@ -67,12 +69,17 @@ class ExecTool(Tool): if guard_error: return guard_error + env = os.environ.copy() + if self.path_append: + env["PATH"] = env.get("PATH", "") + ":" + self.path_append + try: process = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, + env=env, ) try: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fe8dd83..dd856fe 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -252,6 +252,7 @@ class ExecToolConfig(Base): """Shell exec tool configuration.""" timeout: int = 60 + path_append: str = "/usr/sbin:/usr/local/sbin" class MCPServerConfig(Base): From 4f8033627e05ad3d92fa4e31a5fdfad4f3711273 Mon Sep 17 00:00:00 2001 From: "xzq.xu" Date: Tue, 24 Feb 2026 13:42:07 +0800 Subject: [PATCH 235/415] feat(feishu): support images in post (rich text) messages Co-authored-by: Cursor --- nanobot/channels/feishu.py | 54 ++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 2d50d74..480bf7b 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -180,21 +180,25 @@ def _extract_element_content(element: dict) -> list[str]: return parts -def _extract_post_text(content_json: dict) -> str: - """Extract plain text from Feishu post (rich text) message content. +def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: + """Extract text and image keys from Feishu post (rich text) message content. Supports two formats: 1. Direct format: {"title": "...", "content": [...]} 2. Localized format: {"zh_cn": {"title": "...", "content": [...]}} + + Returns: + (text, image_keys) - extracted text and list of image keys """ - def extract_from_lang(lang_content: dict) -> str | None: + def extract_from_lang(lang_content: dict) -> tuple[str | None, list[str]]: if not isinstance(lang_content, dict): - return None + return None, [] title = lang_content.get("title", "") content_blocks = lang_content.get("content", []) if not isinstance(content_blocks, list): - return None + return None, [] text_parts = [] + image_keys = [] if title: text_parts.append(title) for block in content_blocks: @@ -209,22 +213,36 @@ def _extract_post_text(content_json: dict) -> str: text_parts.append(element.get("text", "")) elif tag == "at": text_parts.append(f"@{element.get('user_name', 'user')}") - return " ".join(text_parts).strip() if text_parts else None + elif tag == "img": + img_key = element.get("image_key") + if img_key: + image_keys.append(img_key) + text = " ".join(text_parts).strip() if text_parts else None + return text, image_keys # Try direct format first if "content" in content_json: - result = extract_from_lang(content_json) - if result: - return result + text, images = extract_from_lang(content_json) + if text or images: + return text or "", images # Try localized format for lang_key in ("zh_cn", "en_us", "ja_jp"): lang_content = content_json.get(lang_key) - result = extract_from_lang(lang_content) - if result: - return result + text, images = extract_from_lang(lang_content) + if text or images: + return text or "", images - return "" + return "", [] + + +def _extract_post_text(content_json: dict) -> str: + """Extract plain text from Feishu post (rich text) message content. + + Legacy wrapper for _extract_post_content, returns only text. + """ + text, _ = _extract_post_content(content_json) + return text class FeishuChannel(BaseChannel): @@ -691,9 +709,17 @@ class FeishuChannel(BaseChannel): content_parts.append(text) elif msg_type == "post": - text = _extract_post_text(content_json) + text, image_keys = _extract_post_content(content_json) if text: content_parts.append(text) + # Download images embedded in post + for img_key in image_keys: + file_path, content_text = await self._download_and_save_media( + "image", {"image_key": img_key}, message_id + ) + if file_path: + media_paths.append(file_path) + content_parts.append(content_text) elif msg_type in ("image", "audio", "file", "media"): file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) From ef572259747a59625865ef7d7c6fc72edb448c0c Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Tue, 24 Feb 2026 18:19:47 +0800 Subject: [PATCH 236/415] fix(web): resolve API key on each call + improve error message - Defer Brave API key resolution to execute() time instead of __init__, so env var or config changes take effect without gateway restart - Improve error message to reference actual config path (tools.web.search.apiKey) instead of only mentioning env var Fixes #1069 (issues 1 and 2 of 3) --- nanobot/agent/tools/web.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 90cdda8..4ca788c 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -58,12 +58,21 @@ class WebSearchTool(Tool): } def __init__(self, api_key: str | None = None, max_results: int = 5): - self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "") + self._config_api_key = api_key self.max_results = max_results - + + def _resolve_api_key(self) -> str: + """Resolve API key on each call to support hot-reload and env var changes.""" + return self._config_api_key or os.environ.get("BRAVE_API_KEY", "") + async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: - if not self.api_key: - return "Error: BRAVE_API_KEY not configured" + api_key = self._resolve_api_key() + if not api_key: + return ( + "Error: Brave Search API key not configured. " + "Set it in ~/.nanobot/config.json under tools.web.search.apiKey " + "(or export BRAVE_API_KEY), then restart the gateway." + ) try: n = min(max(count or self.max_results, 1), 10) @@ -71,7 +80,7 @@ class WebSearchTool(Tool): r = await client.get( "https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": n}, - headers={"Accept": "application/json", "X-Subscription-Token": self.api_key}, + headers={"Accept": "application/json", "X-Subscription-Token": api_key}, timeout=10.0 ) r.raise_for_status() From ec55f7791256cfcec28d947296247aac72f5701b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 24 Feb 2026 11:04:56 +0000 Subject: [PATCH 237/415] fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision --- nanobot/cli/commands.py | 17 ++-- nanobot/config/schema.py | 8 ++ nanobot/heartbeat/service.py | 162 +++++++++++++++++++++-------------- 3 files changed, 117 insertions(+), 70 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 90b9f44..ca71694 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -360,19 +360,19 @@ def gateway( return "cli", "direct" # Create heartbeat service - async def on_heartbeat(prompt: str) -> str: - """Execute heartbeat through the agent.""" + async def on_heartbeat_execute(tasks: str) -> str: + """Phase 2: execute heartbeat tasks through the full agent loop.""" channel, chat_id = _pick_heartbeat_target() async def _silent(*_args, **_kwargs): pass return await agent.process_direct( - prompt, + tasks, session_key="heartbeat", channel=channel, chat_id=chat_id, - on_progress=_silent, # suppress: heartbeat should not push progress to external channels + on_progress=_silent, ) async def on_heartbeat_notify(response: str) -> None: @@ -383,12 +383,15 @@ def gateway( return # No external channel available to deliver to await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + hb_cfg = config.gateway.heartbeat heartbeat = HeartbeatService( workspace=config.workspace_path, - on_heartbeat=on_heartbeat, + provider=provider, + model=agent.model, + on_execute=on_heartbeat_execute, on_notify=on_heartbeat_notify, - interval_s=30 * 60, # 30 minutes - enabled=True + interval_s=hb_cfg.interval_s, + enabled=hb_cfg.enabled, ) if channels.enabled_channels: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fe8dd83..215f38d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -228,11 +228,19 @@ class ProvidersConfig(Base): github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) +class HeartbeatConfig(Base): + """Heartbeat service configuration.""" + + enabled: bool = True + interval_s: int = 30 * 60 # 30 minutes + + class GatewayConfig(Base): """Gateway/server configuration.""" host: str = "0.0.0.0" port: int = 18790 + heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) class WebSearchConfig(Base): diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index cb1a1c7..e534017 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -1,80 +1,110 @@ """Heartbeat service - periodic agent wake-up to check for tasks.""" +from __future__ import annotations + import asyncio from pathlib import Path -from typing import Any, Callable, Coroutine +from typing import TYPE_CHECKING, Any, Callable, Coroutine from loguru import logger -# Default interval: 30 minutes -DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 +if TYPE_CHECKING: + from nanobot.providers.base import LLMProvider -# Token the agent replies with when there is nothing to report -HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" - -# The prompt sent to agent during heartbeat -HEARTBEAT_PROMPT = ( - "Read HEARTBEAT.md in your workspace and follow any instructions listed there. " - f"If nothing needs attention, reply with exactly: {HEARTBEAT_OK_TOKEN}" -) - - -def _is_heartbeat_empty(content: str | None) -> bool: - """Check if HEARTBEAT.md has no actionable content.""" - if not content: - return True - - # Lines to skip: empty, headers, HTML comments, empty checkboxes - skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"} - - for line in content.split("\n"): - line = line.strip() - if not line or line.startswith("#") or line.startswith("