diff --git a/.gitignore b/.gitignore index 48f20b6a..d0fe6acb 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ plugins.bak coverage.xml .coverage src/langbot/web/ +testsdk/ # Build artifacts /dist diff --git a/README.md b/README.md index 397aa49b..4018e9b9 100644 --- a/README.md +++ b/README.md @@ -84,45 +84,48 @@ docker compose up -d | Platform | Status | Notes | |----------|--------|-------| -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | Personal & Official API | +| Discord | ✅ | Official | +| Telegram | ✅ | Official | +| Slack | ✅ | Official | +| LINE | ✅ | Official | +| QQ | ✅ | Personal & Official API (Channel, DM, Group) | | WeCom | ✅ | Enterprise WeChat, External CS, AI Bot | | WeChat | ✅ | Personal & Official Account | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | +| Lark | ✅ | Official | +| DingTalk | ✅ | Official | +| KOOK | ✅ | Official | | Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more | --- ## Supported LLMs & Integrations -| Provider | Type | Status | -|----------|------|--------| -| [OpenAI](https://platform.openai.com/) | LLM | ✅ | -| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | -| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | -| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | -| [xAI](https://x.ai/) | LLM | ✅ | -| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | -| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | -| [Ollama](https://ollama.com/) | Local LLM | ✅ | -| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ | -| [Dify](https://dify.ai) | LLMOps | ✅ | -| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ | -| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ | -| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ | -| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ | -| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ | -| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ | -| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ | -| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ | -| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ | -| [接口 AI](https://jiekou.ai/) | Gateway | ✅ | -| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ | +| Provider | Type | Status | +| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ | +| [OpenAI](https://platform.openai.com/) | LLM | ✅ | +| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | +| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | +| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ | +| [xAI](https://x.ai/) | LLM | ✅ | +| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ | +| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ | +| [Ollama](https://ollama.com/) | Local LLM | ✅ | +| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ | +| [Dify](https://dify.ai) | LLMOps | ✅ | +| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ | +| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ | +| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ | +| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ | +| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ | +| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ | +| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ | +| [接口 AI](https://jiekou.ai/) | Gateway | ✅ | +| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ | [→ View all integrations](https://link.langbot.app/en/docs/features) @@ -130,22 +133,23 @@ docker compose up -d ## Why LangBot? -| Use Case | How LangBot Helps | -|----------|-------------------| -| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base | -| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes | -| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction | -| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard | +| Use Case | How LangBot Helps | +| --------------------------- | ------------------------------------------------------------------------------------------ | +| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base | +| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes | +| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction | +| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard | --- ## Live Demo **Try it now:** https://demo.langbot.dev/ + - Email: `demo@langbot.app` - Password: `langbot123456` -*Note: Public demo environment. Do not enter sensitive information.* +_Note: Public demo environment. Do not enter sensitive information._ --- diff --git a/README_CN.md b/README_CN.md index 66082554..7dcdd4ca 100644 --- a/README_CN.md +++ b/README_CN.md @@ -87,13 +87,16 @@ docker compose up -d | QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) | | 微信 | ✅ | 个人微信、微信公众号 | | 企业微信 | ✅ | 应用消息、对外客服、智能机器人 | -| 飞书 | ✅ | | -| 钉钉 | ✅ | | -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| KOOK | ✅ | | +| 飞书 | ✅ | 官方 | +| 钉钉 | ✅ | 官方 | +| Satori | ✅ | | +| Discord | ✅ | 官方 | +| Telegram | ✅ | 官方 | +| Slack | ✅ | 官方 | +| LINE | ✅ | 官方 | +| KOOK | ✅ | 官方 | +| Email | ✅ | 只 Matrix、Satori | +| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 | --- @@ -124,6 +127,7 @@ docker compose up -d | [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | | [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ | | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | +| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | [→ 查看完整集成列表](https://link.langbot.app/zh/docs/features) diff --git a/README_ES.md b/README_ES.md index 0755f4db..c9b61c12 100644 --- a/README_ES.md +++ b/README_ES.md @@ -83,17 +83,19 @@ docker compose up -d | Plataforma | Estado | Notas | |----------|--------|-------| -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | Personal y API Oficial | +| Discord | ✅ | Oficial | +| Telegram | ✅ | Oficial | +| Slack | ✅ | Oficial | +| LINE | ✅ | Oficial | +| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) | | WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot | | WeChat | ✅ | Personal y Cuenta Oficial | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | +| Lark | ✅ | Oficial | +| DingTalk | ✅ | Oficial | +| KOOK | ✅ | Oficial | | Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más | --- @@ -122,6 +124,7 @@ docker compose up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ | | [接口 AI](https://jiekou.ai/) | Pasarela | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ | [→ Ver todas las integraciones](https://link.langbot.app/en/docs/features) diff --git a/README_FR.md b/README_FR.md index b1031f27..9f9714dc 100644 --- a/README_FR.md +++ b/README_FR.md @@ -83,17 +83,19 @@ docker compose up -d | Plateforme | Statut | Notes | |----------|--------|-------| -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | Personnel & API Officielle | +| Discord | ✅ | Officiel | +| Telegram | ✅ | Officiel | +| Slack | ✅ | Officiel | +| LINE | ✅ | Officiel | +| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) | | WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot | | WeChat | ✅ | Personnel & Compte Officiel | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | +| Lark | ✅ | Officiel | +| DingTalk | ✅ | Officiel | +| KOOK | ✅ | Officiel | | Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. | --- @@ -122,6 +124,7 @@ docker compose up -d | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ | [→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features) diff --git a/README_JP.md b/README_JP.md index 7dcaaf73..353f42c5 100644 --- a/README_JP.md +++ b/README_JP.md @@ -83,17 +83,19 @@ docker compose up -d | プラットフォーム | ステータス | 備考 | |----------|--------|-------| -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | 個人 & 公式API | +| Discord | ✅ | 公式 | +| Telegram | ✅ | 公式 | +| Slack | ✅ | 公式 | +| LINE | ✅ | 公式 | +| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) | | WeCom | ✅ | 企業WeChat、外部CS、AIボット | -| WeChat | ✅ | 個人 & 公式アカウント | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | +| WeChat | ✅ | 個人・公式アカウント | +| Lark | ✅ | 公式 | +| DingTalk | ✅ | 公式 | +| KOOK | ✅ | 公式 | | Satori | ✅ | | +| Email | ✅ | Matrix、Satori | +| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 | --- @@ -122,6 +124,7 @@ docker compose up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ | | [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ | | [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ | [→ すべての統合を表示](https://link.langbot.app/en/docs/features) diff --git a/README_KO.md b/README_KO.md index 0d337028..fa2f29d3 100644 --- a/README_KO.md +++ b/README_KO.md @@ -83,17 +83,19 @@ docker compose up -d | 플랫폼 | 상태 | 비고 | |--------|------|------| -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | 개인 및 공식 API | +| Discord | ✅ | 공식 | +| Telegram | ✅ | 공식 | +| Slack | ✅ | 공식 | +| LINE | ✅ | 공식 | +| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) | | WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot | | WeChat | ✅ | 개인 및 공식 계정 | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | +| Lark | ✅ | 공식 | +| DingTalk | ✅ | 공식 | +| KOOK | ✅ | 공식 | | Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 | --- @@ -122,6 +124,7 @@ docker compose up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ | | [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ | [→ 모든 통합 보기](https://link.langbot.app/en/docs/features) diff --git a/README_RU.md b/README_RU.md index bbefb680..9cd6671e 100644 --- a/README_RU.md +++ b/README_RU.md @@ -83,17 +83,19 @@ docker compose up -d | Платформа | Статус | Примечания | |-----------|--------|------------| -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | Личный и официальный API | +| Discord | ✅ | Официальный | +| Telegram | ✅ | Официальный | +| Slack | ✅ | Официальный | +| LINE | ✅ | Официальный | +| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) | | WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот | | WeChat | ✅ | Личный и официальный аккаунт | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | +| Lark | ✅ | Официальный | +| DingTalk | ✅ | Официальный | +| KOOK | ✅ | Официальный | | Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие | --- @@ -122,6 +124,7 @@ docker compose up -d | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ | [→ Смотреть все интеграции](https://link.langbot.app/en/docs/features) diff --git a/README_TW.md b/README_TW.md index 2f73652a..2c723a1f 100644 --- a/README_TW.md +++ b/README_TW.md @@ -85,17 +85,19 @@ docker compose up -d | 平台 | 狀態 | 備註 | |------|------|------| +| Discord | ✅ | 官方 | +| Telegram | ✅ | 官方 | +| Slack | ✅ | 官方 | +| LINE | ✅ | 官方 | | QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) | -| 微信 | ✅ | 個人微信、微信公眾號 | | 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 | -| 飛書 | ✅ | | -| 釘釘 | ✅ | | -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| KOOK | ✅ | | +| 微信 | ✅ | 個人微信、微信公眾號 | +| 飛書 | ✅ | 官方 | +| 釘釘 | ✅ | 官方 | +| KOOK | ✅ | 官方 | | Satori | ✅ | | +| Email | ✅ | 只 Matrix、Satori | +| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 | --- @@ -124,6 +126,7 @@ docker compose up -d | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ | | [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | ### TTS(語音合成) diff --git a/README_VI.md b/README_VI.md index 6d81a9ea..8cbe42ae 100644 --- a/README_VI.md +++ b/README_VI.md @@ -83,17 +83,19 @@ docker compose up -d | Nền tảng | Trạng thái | Ghi chú | |----------|--------|-------| -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | Cá nhân & API chính thức | +| Discord | ✅ | Chính thức | +| Telegram | ✅ | Chính thức | +| Slack | ✅ | Chính thức | +| LINE | ✅ | Chính thức | +| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) | | WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot | | WeChat | ✅ | Cá nhân & Tài khoản công khai | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | +| Lark | ✅ | Chính thức | +| DingTalk | ✅ | Chính thức | +| KOOK | ✅ | Chính thức | | Satori | ✅ | | +| Email | ✅ | Matrix, Satori | +| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa | --- @@ -122,6 +124,7 @@ docker compose up -d | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ | | [接口 AI](https://jiekou.ai/) | Cổng | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ | +| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ | [→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features) diff --git a/pyproject.toml b/pyproject.toml index cb8ac1fe..f6e86e81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,9 +69,10 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin==0.3.8", + "langbot-plugin==0.3.10", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", + "matrix-nio>=0.25.2", "tboxsdk>=0.0.10", "boto3>=1.35.0", "pymilvus>=2.6.4", diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index 723ee9e8..f453d0bf 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -481,6 +481,12 @@ class DingTalkClient: card_data['config'] = json.dumps({'autoLayout': card_auto_layout}) card_data['content'] = '' + # 将用户的消息内容作为卡片的查询参数,方便后续处理 + if incoming_message.message_type == 'text': + card_data['query'] = incoming_message.get_text_list()[0] + else: + card_data['query'] = '...' + card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message) # print(card_instance) # 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index 51a56d53..db3194b6 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -1,8 +1,10 @@ +import re import time +import asyncio from quart import request import httpx from quart import Quart -from typing import Callable, Dict, Any +from typing import Callable, Dict, Any, Optional import langbot_plugin.api.entities.builtin.platform.events as platform_events from .qqofficialevent import QQOfficialEvent import json @@ -32,6 +34,8 @@ class QQOfficialClient: self.access_token = '' self.access_token_expiry_time = None self.logger = logger + self._msg_seq_counter = 0 + self._token_refresh_task: Optional[asyncio.Task] = None async def check_access_token(self): """检查access_token是否存在""" @@ -50,18 +54,18 @@ class QQOfficialClient: headers = { 'content-type': 'application/json', } - try: - response = await client.post(url, json=params, headers=headers) - if response.status_code == 200: - response_data = response.json() - access_token = response_data.get('access_token') - expires_in = int(response_data.get('expires_in', 7200)) - self.access_token_expiry_time = time.time() + expires_in - 60 - if access_token: - self.access_token = access_token - except Exception as e: - await self.logger.error(f'获取access_token失败: {response_data}') - raise Exception(f'获取access_token失败: {e}') + response = await client.post(url, json=params, headers=headers) + if response.status_code != 200: + raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}') + response_data = response.json() + access_token = response_data.get('access_token') + expires_in = int(response_data.get('expires_in', 7200)) + self.access_token_expiry_time = time.time() + expires_in - 60 + if access_token: + self.access_token = access_token + await self.logger.info(f'access_token obtained, expires_in={expires_in}s') + else: + raise Exception('Failed to get access_token: no access_token in response') async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)""" @@ -87,10 +91,10 @@ class QQOfficialClient: try: body = await req.get_data() - print(f'[QQ Official] Received request, body length: {len(body)}') + await self.logger.info(f'Received request, body length: {len(body)}') if not body or len(body) == 0: - print('[QQ Official] Received empty body, might be health check or GET request') + await self.logger.info('Received empty body, might be health check or GET request') return {'code': 0, 'message': 'ok'}, 200 payload = json.loads(body) @@ -111,7 +115,6 @@ class QQOfficialClient: return {'code': 0, 'message': 'success'} except Exception as e: - print(f'[QQ Official] ERROR: {traceback.format_exc()}') await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') return {'error': str(e)}, 400 @@ -139,21 +142,24 @@ class QQOfficialClient: async def get_message(self, msg: dict) -> Dict[str, Any]: """获取消息""" + d = msg.get('d', {}) + if not isinstance(d, dict): + return {} message_data = { 't': msg.get('t', {}), - 'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}), - 'timestamp': msg.get('d', {}).get('timestamp', {}), - 'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}), - 'content': msg.get('d', {}).get('content', {}), - 'd_id': msg.get('d', {}).get('id', {}), + 'user_openid': d.get('author', {}).get('user_openid', {}), + 'timestamp': d.get('timestamp', {}), + 'd_author_id': d.get('author', {}).get('id', {}), + 'content': d.get('content', {}), + 'd_id': d.get('id', {}), 'id': msg.get('id', {}), - 'channel_id': msg.get('d', {}).get('channel_id', {}), - 'username': msg.get('d', {}).get('author', {}).get('username', {}), - 'guild_id': msg.get('d', {}).get('guild_id', {}), - 'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}), - 'group_openid': msg.get('d', {}).get('group_openid', {}), + 'channel_id': d.get('channel_id', {}), + 'username': d.get('author', {}).get('username', {}), + 'guild_id': d.get('guild_id', {}), + 'member_openid': d.get('author', {}).get('openid', {}), + 'group_openid': d.get('group_openid', {}), } - attachments = msg.get('d', {}).get('attachments', []) + attachments = d.get('attachments', []) image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)] image_attachments_type = [ attachment['content_type'] for attachment in attachments if await self.is_image(attachment) @@ -192,7 +198,7 @@ class QQOfficialClient: if response.status_code == 200: return else: - await self.logger.error(f'发送私聊消息失败: {response_data}') + await self.logger.error(f'Failed to send private message: {response_data}') raise ValueError(response) async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str): @@ -215,7 +221,7 @@ class QQOfficialClient: if response.status_code == 200: return else: - await self.logger.error(f'发送群聊消息失败:{response.json()}') + await self.logger.error(f'Failed to send group message: {response.json()}') raise Exception(response.read().decode()) async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str): @@ -238,7 +244,7 @@ class QQOfficialClient: if response.status_code == 200: return True else: - await self.logger.error(f'发送频道群聊消息失败: {response.json()}') + await self.logger.error(f'Failed to send channel group message: {response.json()}') raise Exception(response) async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str): @@ -261,9 +267,224 @@ class QQOfficialClient: if response.status_code == 200: return True else: - await self.logger.error(f'发送频道私聊消息失败: {response.json()}') + await self.logger.error(f'Failed to send channel private message: {response.json()}') raise Exception(response) + # ---- 富媒体消息 ---- + + # 媒体文件类型 + MEDIA_TYPE_IMAGE = 1 + MEDIA_TYPE_VIDEO = 2 + MEDIA_TYPE_VOICE = 3 + MEDIA_TYPE_FILE = 4 + + async def upload_media( + self, + target_type: str, + target_id: str, + file_type: int, + file_url: str = None, + file_data: str = None, + file_name: str = None, + ) -> str: + """上传媒体文件,返回 file_info。 + + Args: + target_type: 'c2c' | 'group' + target_id: 用户 openid 或群 openid + file_type: 1=图片, 2=视频, 3=语音, 4=文件 + file_url: 在线 URL(与 file_data 二选一) + file_data: base64 编码的文件数据或 data URL(与 file_url 二选一) + file_name: 文件名(file_type=4 时必填) + """ + if not await self.check_access_token(): + await self.get_access_token() + + if target_type == 'c2c': + url = f'{self.base_url}/v2/users/{target_id}/files' + elif target_type == 'group': + url = f'{self.base_url}/v2/groups/{target_id}/files' + else: + raise ValueError(f'Unsupported target_type: {target_type}') + + body = { + 'file_type': file_type, + 'srv_send_msg': False, + } + if file_url: + body['url'] = file_url + elif file_data: + # 处理 data URL 格式: data:image/png;base64,xxxxx + if file_data.startswith('data:'): + match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL) + if match: + body['file_data'] = match.group(1) + else: + body['file_data'] = file_data + else: + body['file_data'] = file_data + else: + raise ValueError('file_url or file_data is required') + + if file_type == self.MEDIA_TYPE_FILE and file_name: + body['file_name'] = file_name + + async with httpx.AsyncClient(timeout=120) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + response = await client.post(url, headers=headers, json=body) + if response.status_code == 200: + data = response.json() + file_info = data.get('file_info', '') + preview = file_info[:80] + '...' if len(file_info) > 80 else file_info + await self.logger.info(f'Upload media success, file_info={preview}') + return file_info + else: + raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}') + + async def _send_media_msg( + self, + target_type: str, + target_id: str, + file_info: str, + msg_id: str = None, + content: str = None, + ): + """发送富媒体消息(msg_type=7)""" + if not await self.check_access_token(): + await self.get_access_token() + + if target_type == 'c2c': + url = f'{self.base_url}/v2/users/{target_id}/messages' + elif target_type == 'group': + url = f'{self.base_url}/v2/groups/{target_id}/messages' + else: + raise ValueError(f'Unsupported target_type: {target_type}') + + self._msg_seq_counter += 1 + msg_seq = self._msg_seq_counter + body = { + 'msg_type': 7, + 'media': {'file_info': file_info}, + 'msg_seq': msg_seq, + } + if content: + body['content'] = content + if msg_id: + body['msg_id'] = msg_id + + async with httpx.AsyncClient(timeout=120) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}') + response = await client.post(url, headers=headers, json=body) + if response.status_code != 200: + raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}') + + async def send_image_msg( + self, + target_type: str, + target_id: str, + file_url: str = None, + file_data: str = None, + msg_id: str = None, + content: str = None, + ): + """发送图片消息""" + file_info = await self.upload_media( + target_type, + target_id, + self.MEDIA_TYPE_IMAGE, + file_url=file_url, + file_data=file_data, + ) + await self._send_media_msg(target_type, target_id, file_info, msg_id, content) + + async def send_voice_msg( + self, + target_type: str, + target_id: str, + file_url: str = None, + file_data: str = None, + msg_id: str = None, + ): + """发送语音消息""" + file_info = await self.upload_media( + target_type, + target_id, + self.MEDIA_TYPE_VOICE, + file_url=file_url, + file_data=file_data, + ) + await self._send_media_msg(target_type, target_id, file_info, msg_id) + + async def send_file_msg( + self, + target_type: str, + target_id: str, + file_url: str = None, + file_data: str = None, + file_name: str = None, + msg_id: str = None, + ): + """发送文件消息(含视频)""" + file_info = await self.upload_media( + target_type, + target_id, + self.MEDIA_TYPE_FILE, + file_url=file_url, + file_data=file_data, + file_name=file_name, + ) + await self._send_media_msg(target_type, target_id, file_info, msg_id) + + async def send_stream_msg( + self, + user_openid: str, + content: str, + event_id: str, + msg_id: str, + msg_seq: int = 1, + index: int = 0, + stream_msg_id: str = None, + input_state: int = 1, + ): + """发送流式消息(C2C 私聊)。 + + Args: + input_state: 1=生成中, 10=生成结束 + """ + if not await self.check_access_token(): + await self.get_access_token() + + url = f'{self.base_url}/v2/users/{user_openid}/stream_messages' + body = { + 'input_mode': 'replace', + 'input_state': input_state, + 'content_type': 'markdown', + 'content_raw': content, + 'event_id': event_id, + 'msg_id': msg_id, + 'msg_seq': msg_seq, + 'index': index, + } + if stream_msg_id: + body['stream_msg_id'] = stream_msg_id + + async with httpx.AsyncClient(timeout=120) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + response = await client.post(url, headers=headers, json=body) + if response.status_code != 200: + raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}') + return response.json() + async def is_token_expired(self): """检查token是否过期""" if self.access_token_expiry_time is None: @@ -292,3 +513,325 @@ class QQOfficialClient: 'signature': signature, } return response + + # ---- WebSocket Gateway ---- + # Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html + + INTENT_GUILDS = 1 << 0 + INTENT_GUILD_MEMBERS = 1 << 1 + INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30 + INTENT_DIRECT_MESSAGE = 1 << 12 + INTENT_GROUP_AND_C2C = 1 << 25 + INTENT_INTERACTION = 1 << 26 + + FULL_INTENTS = ( + INTENT_GUILDS + | INTENT_GUILD_MEMBERS + | INTENT_PUBLIC_GUILD_MESSAGES + | INTENT_DIRECT_MESSAGE + | INTENT_GROUP_AND_C2C + | INTENT_INTERACTION + ) + + async def get_gateway_url(self) -> str: + """获取 WebSocket 网关地址""" + if not await self.check_access_token(): + await self.get_access_token() + + url = f'{self.base_url}/gateway' + async with httpx.AsyncClient() as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + } + response = await client.get(url, headers=headers) + if response.status_code == 200: + data = response.json() + ws_url = data.get('url', '') + if not ws_url: + raise Exception('Gateway URL is empty') + return ws_url + else: + raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}') + + async def _background_token_refresh(self): + """在 token 到期前主动刷新""" + try: + while True: + if self.access_token_expiry_time: + remain = self.access_token_expiry_time - time.time() + if remain > 120: + await asyncio.sleep(remain - 60) + continue + self.access_token = '' + self.access_token_expiry_time = None + if await self.check_access_token(): + await asyncio.sleep(60) + else: + await self.get_access_token() + await asyncio.sleep(60) + except asyncio.CancelledError: + pass + + async def connect_gateway( + self, + on_event: Callable[[str, dict], Any], + on_ready: Optional[Callable[[], Any]] = None, + on_error: Optional[Callable[[Exception], Any]] = None, + ): + """WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。 + + Args: + on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data) + on_ready: 连接就绪 (收到 READY) 时的回调 + on_error: 发生错误时的回调 + """ + import websockets + + session_id = '' + last_seq = 0 + reconnect_attempts = 0 + max_reconnect_attempts = 100 + backoff_delays = [1, 2, 5, 10, 30, 60] + rate_limit_delay = 60 + + # Cancel previous token refresh task if any + if self._token_refresh_task and not self._token_refresh_task.done(): + self._token_refresh_task.cancel() + try: + await self._token_refresh_task + except asyncio.CancelledError: + pass + self._token_refresh_task = None + + while reconnect_attempts <= max_reconnect_attempts: + heartbeat_interval = 45000 + should_refresh_token = False + ws = None + heartbeat_task = None + + # Refresh token if needed + if should_refresh_token: + self.access_token = '' + self.access_token_expiry_time = None + + try: + ws_url = await self.get_gateway_url() + await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...') + except Exception as e: + error_msg = str(e) + await self.logger.error(f'Failed to get gateway URL: {e}') + reconnect_attempts += 1 + if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg: + delay = rate_limit_delay + else: + delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)] + await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})') + await asyncio.sleep(delay) + continue + + try: + await self.logger.info('Connecting to WebSocket gateway...') + ws = await websockets.connect(ws_url) + await self.logger.info('WebSocket connected') + except Exception as e: + await self.logger.error(f'WebSocket connection failed: {e}') + reconnect_attempts += 1 + delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)] + await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})') + await asyncio.sleep(delay) + continue + + try: + async for raw_msg in ws: + try: + payload = json.loads(raw_msg) + except json.JSONDecodeError: + await self.logger.error(f'Failed to parse message: {raw_msg}') + continue + + op = payload.get('op') + d = payload.get('d', {}) + s = payload.get('s') + t = payload.get('t') + + if not isinstance(d, dict): + d = {} + + if op == 10: # Hello + heartbeat_interval = d.get('heartbeat_interval', 45000) + await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms') + + # Send Identify or Resume + if session_id and last_seq > 0: + resume_payload = { + 'op': 6, + 'd': { + 'token': f'QQBot {self.access_token}', + 'session_id': session_id, + 'seq': last_seq, + }, + } + await ws.send(json.dumps(resume_payload)) + await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}') + else: + identify_payload = { + 'op': 2, + 'd': { + 'token': f'QQBot {self.access_token}', + 'intents': self.FULL_INTENTS, + 'shard': [0, 1], + }, + } + await ws.send(json.dumps(identify_payload)) + await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}') + + # Start heartbeat + async def _heartbeat_loop(conn, interval_ms): + interval_sec = interval_ms / 1000.0 + try: + while True: + await asyncio.sleep(interval_sec) + try: + hb_payload = {'op': 1, 'd': last_seq} + await conn.send(json.dumps(hb_payload)) + except Exception: + break + except asyncio.CancelledError: + pass + + heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval)) + + elif op == 0: # Dispatch + if s is not None: + last_seq = s + + if t == 'READY': + session_id = d.get('session_id', '') + reconnect_attempts = 0 + await self.logger.info(f'READY, session_id={session_id}') + if on_ready: + try: + result = on_ready() + if asyncio.iscoroutine(result): + await result + except Exception: + pass + # Track token refresh task to avoid leaks + if self._token_refresh_task and not self._token_refresh_task.done(): + self._token_refresh_task.cancel() + try: + await self._token_refresh_task + except asyncio.CancelledError: + pass + self._token_refresh_task = asyncio.create_task(self._background_token_refresh()) + + elif t == 'RESUMED': + reconnect_attempts = 0 + await self.logger.info('RESUMED') + + else: + await self.logger.debug(f'Received event: {t}, seq={s}') + if on_event: + try: + result = on_event(t, d) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}') + + elif op == 11: # Heartbeat ACK + pass + + elif op == 7: # Reconnect + await self.logger.info('Received Reconnect directive') + break + + elif op == 9: # Invalid Session + can_resume = d.get('can_resume', False) + await self.logger.warning(f'Invalid Session, can_resume={can_resume}') + if not can_resume: + session_id = '' + last_seq = 0 + should_refresh_token = True + break + + # Connection closed normally (end of async for) + try: + close_code = ws.close_code + close_reason = ws.close_reason or '' + except Exception: + close_code = None + close_reason = '' + await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}') + + if close_code == 4004: + should_refresh_token = True + elif close_code in (4006, 4007, 4009): + session_id = '' + last_seq = 0 + should_refresh_token = True + elif close_code == 4008: + reconnect_attempts += 1 + delay = rate_limit_delay + await self.logger.info( + f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})' + ) + await asyncio.sleep(delay) + continue + elif close_code in (4914, 4915): + err = Exception(f'Bot disconnected/banned (close_code={close_code})') + if on_error: + await self._safe_callback(on_error, err) + return + elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913): + session_id = '' + last_seq = 0 + + if close_code == 1000: + return + + except asyncio.CancelledError: + raise + except Exception: + await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}') + finally: + if heartbeat_task: + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + if ws: + try: + await ws.close() + except Exception: + pass + + # If we reach here, we need to reconnect + reconnect_attempts += 1 + if reconnect_attempts > max_reconnect_attempts: + await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping') + if on_error: + await self._safe_callback(on_error, Exception('Max reconnect attempts reached')) + return + delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)] + await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})') + await asyncio.sleep(delay) + + async def _safe_callback(self, callback, *args): + """Safely invoke a callback, handling both sync and async functions.""" + try: + result = callback(*args) + if asyncio.iscoroutine(result): + await result + except Exception: + pass + + async def connect_gateway_loop( + self, + on_event: Callable[[str, dict], Any], + on_ready: Optional[Callable[[], Any]] = None, + on_error: Optional[Callable[[Exception], Any]] = None, + ): + """持续重连的网关循环。""" + await self.connect_gateway(on_event, on_ready, on_error) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py new file mode 100644 index 00000000..99c00944 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -0,0 +1,384 @@ +"""Embed widget routes - serve embeddable chat widget for external websites. + +All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that +internal pipeline identifiers are never exposed to end-users. Each handler +resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts +the bound pipeline_uuid for internal routing. +""" + +import asyncio +import datetime +import json +import logging +import uuid +import hmac +import hashlib +import time +import re +import httpx + +import quart + +from ... import group +from ......utils import paths +from ......platform.sources.websocket_manager import ws_connection_manager + +logger = logging.getLogger(__name__) + +# Cache the widget template content +_widget_template_cache: str | None = None +_logo_bytes_cache: bytes | None = None + + +def _is_valid_uuid(s: str) -> bool: + return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s)) + + +def _get_widget_template() -> str: + """Load and cache the widget JS template.""" + global _widget_template_cache + if _widget_template_cache is None: + template_path = paths.get_resource_path('templates/embed/widget.js') + with open(template_path, 'r', encoding='utf-8') as f: + _widget_template_cache = f.read() + return _widget_template_cache + + +def _get_logo_bytes() -> bytes: + """Load and cache the logo image.""" + global _logo_bytes_cache + if _logo_bytes_cache is None: + logo_path = paths.get_resource_path('templates/embed/logo.webp') + with open(logo_path, 'rb') as f: + _logo_bytes_cache = f.read() + return _logo_bytes_cache + + +@group.group_class('embed', '/api/v1/embed') +class EmbedRouterGroup(group.RouterGroup): + # -- helpers ------------------------------------------------------------- + + def _resolve_bot(self, bot_uuid: str): + """Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``. + + Returns ``(None, None)`` when the bot does not exist, is not a + ``web_page_bot``, is disabled, or has no pipeline bound. + """ + for bot in self.ap.platform_mgr.bots: + if ( + bot.bot_entity.uuid == bot_uuid + and bot.bot_entity.adapter == 'web_page_bot' + and bot.bot_entity.enable + and bot.bot_entity.use_pipeline_uuid + ): + return bot, bot.bot_entity.use_pipeline_uuid + return None, None + + def _get_bot_config(self, bot_uuid: str) -> dict: + for bot in self.ap.platform_mgr.bots: + if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot': + return bot.bot_entity.adapter_config + return {} + + async def _verify_session_token(self, request, bot_uuid: str) -> bool: + config = self._get_bot_config(bot_uuid) + secret = config.get('turnstile_secret_key', '') + if not secret: + return True + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return False + token = auth_header[7:] + try: + ts_str, mac = token.split('.', 1) + ts = float(ts_str) + if time.time() - ts > 86400: + return False + expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(mac, expected_mac) + except Exception: + return False + + # -- routes -------------------------------------------------------------- + + async def initialize(self) -> None: + @self.route('//turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE) + async def verify_turnstile(bot_uuid: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + try: + data = await quart.request.get_json() + token = data.get('token') + if not token: + return self.http_status(400, -1, 'Token is required') + + config = self._get_bot_config(bot_uuid) + secret = config.get('turnstile_secret_key', '') + if not secret: + ts = time.time() + return self.success(data={'token': f'{ts}.dummy'}) + + async with httpx.AsyncClient() as client: + resp = await client.post( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + data={'secret': secret, 'response': token}, + ) + result = resp.json() + + if not result.get('success'): + return self.http_status(403, -1, 'Turnstile verification failed') + + ts = time.time() + mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest() + session_token = f'{ts}.{mac}' + + return self.success(data={'token': session_token}) + + except Exception as e: + logger.error(f'Turnstile verify failed: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//widget.js', methods=['GET'], auth_type=group.AuthType.NONE) + async def serve_widget(bot_uuid: str) -> quart.Response: + """Serve the embed widget JavaScript with injected configuration.""" + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return quart.Response( + '// Bot not found or not available', status=404, content_type='application/javascript' + ) + try: + template = _get_widget_template() + except FileNotFoundError: + return quart.Response('// Widget template not found', status=404, content_type='application/javascript') + + base_url = quart.request.host_url.rstrip('/') + webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '') + if webhook_prefix: + base_url = webhook_prefix.rstrip('/') + + if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url): + base_url = quart.request.host_url.rstrip('/') + + config = self._get_bot_config(bot_uuid) + site_key = config.get('turnstile_site_key', '') + locale = config.get('language', 'en_US') or 'en_US' + bubble_icon = config.get('bubble_icon', 'logo') or 'logo' + widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key) + widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid) + widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url) + widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale) + widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon) + + response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8') + response.headers['Cache-Control'] = 'public, max-age=300' + return response + + @self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE) + async def serve_logo() -> quart.Response: + """Serve the LangBot logo for the embed widget.""" + try: + logo_data = _get_logo_bytes() + except FileNotFoundError: + return quart.Response('', status=404) + + response = quart.Response(logo_data, content_type='image/webp') + response.headers['Cache-Control'] = 'public, max-age=86400' + return response + + @self.route('//messages/', methods=['GET'], auth_type=group.AuthType.NONE) + async def get_embed_messages(bot_uuid: str, session_type: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type) + return self.success(data={'messages': messages}) + + except Exception as e: + logger.error(f'Failed to get embed messages: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//reset/', methods=['POST'], auth_type=group.AuthType.NONE) + async def reset_embed_session(bot_uuid: str, session_type: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + if session_type not in ['person', 'group']: + return self.http_status(400, -1, 'session_type must be person or group') + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + return self.http_status(404, -1, 'WebSocket adapter not found') + + websocket_adapter.reset_session(pipeline_uuid, session_type) + return self.success(data={'message': 'Session reset successfully'}) + + except Exception as e: + logger.error(f'Failed to reset embed session: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + @self.route('//feedback', methods=['POST'], auth_type=group.AuthType.NONE) + async def submit_feedback(bot_uuid: str) -> str: + if not _is_valid_uuid(bot_uuid): + return self.http_status(400, -1, 'Invalid bot_uuid format') + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + return self.http_status(404, -1, 'Bot not found or not available') + if not await self._verify_session_token(quart.request, bot_uuid): + return self.http_status(403, -1, 'Unauthorized or session expired') + try: + data = await quart.request.get_json() + message_id = data.get('message_id', '') + feedback_type = data.get('feedback_type') + + if feedback_type not in (1, 2, 3): + return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)') + + feedback_id = f'embed_{uuid.uuid4().hex[:12]}' + + await self.ap.monitoring_service.record_feedback( + feedback_id=feedback_id, + feedback_type=feedback_type, + bot_id=runtime_bot.bot_entity.uuid, + bot_name=runtime_bot.bot_entity.name or bot_uuid, + pipeline_id=pipeline_uuid, + message_id=str(message_id), + platform='web_page_bot', + ) + + return self.success(data={'feedback_id': feedback_id}) + + except Exception as e: + logger.error(f'Failed to record feedback: {e}', exc_info=True) + return self.http_status(500, -1, 'Internal server error') + + # -- Embed WebSocket endpoint ---------------------------------------- + + @self.quart_app.websocket(self.path + '//ws/connect') + async def embed_websocket_connect(bot_uuid: str): + """WebSocket connection for embed widget, keyed by bot_uuid.""" + if not _is_valid_uuid(bot_uuid): + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'})) + return + + runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid) + if runtime_bot is None: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'})) + return + + session_type = quart.websocket.args.get('session_type', 'person') + if session_type not in ['person', 'group']: + await quart.websocket.send( + json.dumps({'type': 'error', 'message': 'session_type must be person or group'}) + ) + return + + websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter + if not websocket_adapter: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) + return + + try: + connection = await ws_connection_manager.add_connection( + websocket=quart.websocket._get_current_object(), + pipeline_uuid=pipeline_uuid, + session_type=session_type, + metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')}, + ) + + await quart.websocket.send( + json.dumps( + { + 'type': 'connected', + 'connection_id': connection.connection_id, + 'bot_uuid': bot_uuid, + 'session_type': session_type, + 'timestamp': connection.created_at.isoformat(), + } + ) + ) + + logger.debug( + f'Embed WebSocket connected: {connection.connection_id} ' + f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})' + ) + + receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot)) + send_task = asyncio.create_task(self._handle_send(connection)) + + try: + await asyncio.gather(receive_task, send_task) + except Exception as e: + logger.error(f'Embed WebSocket task error: {e}') + finally: + await ws_connection_manager.remove_connection(connection.connection_id) + + except Exception as e: + logger.error(f'Embed WebSocket connection error: {e}', exc_info=True) + try: + await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'})) + except Exception: + pass + + # -- WebSocket receive/send helpers -------------------------------------- + + async def _handle_receive(self, connection, websocket_adapter, owner_bot): + try: + while connection.is_active: + message = await quart.websocket.receive() + await ws_connection_manager.update_activity(connection.connection_id) + + try: + data = json.loads(message) + message_type = data.get('type', 'message') + + if message_type == 'ping': + await connection.send_queue.put( + {'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()} + ) + elif message_type == 'message': + await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot) + elif message_type == 'disconnect': + break + + except json.JSONDecodeError: + await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + + except Exception as e: + logger.error(f'Embed receive error: {e}', exc_info=True) + finally: + connection.is_active = False + + async def _handle_send(self, connection): + try: + while connection.is_active: + try: + message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0) + await quart.websocket.send(json.dumps(message)) + except asyncio.TimeoutError: + continue + except Exception as e: + logger.error(f'Embed send error: {e}', exc_info=True) + finally: + connection.is_active = False diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py index d03790b1..c85ecc77 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py @@ -43,6 +43,9 @@ class WebSocketChatRouterGroup(group.RouterGroup): await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) return + # Find the owning bot for this pipeline (e.g. a web_page_bot) + owner_bot = self._find_owner_bot(pipeline_uuid) + # 注册连接 connection = await ws_connection_manager.add_connection( websocket=quart.websocket._get_current_object(), @@ -70,7 +73,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): ) # 创建接收和发送任务 - receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter)) + receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot)) send_task = asyncio.create_task(self._handle_send(connection)) # 等待任务完成 @@ -178,7 +181,14 @@ class WebSocketChatRouterGroup(group.RouterGroup): except Exception as e: return self.http_status(500, -1, f'Internal server error: {str(e)}') - async def _handle_receive(self, connection, websocket_adapter): + def _find_owner_bot(self, pipeline_uuid: str): + """Find a user-created bot (e.g. web_page_bot) that owns this pipeline.""" + for bot in self.ap.platform_mgr.bots: + if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid: + return bot + return None + + async def _handle_receive(self, connection, websocket_adapter, owner_bot=None): """处理接收消息的任务""" try: while connection.is_active: @@ -203,7 +213,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): logger.debug(f'收到消息: {data} from {connection.connection_id}') # 处理消息(不等待响应,响应会通过broadcast异步发送) - await websocket_adapter.handle_websocket_message(connection, data) + await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot) elif message_type == 'disconnect': # 客户端主动断开 diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 73afbdec..7b7e16b9 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -7,11 +7,38 @@ import httpx import uuid import os from urllib.parse import urlparse +import posixpath from .....core import taskmgr from .. import group from langbot_plugin.runtime.plugin.mgr import PluginInstallSource +# Resolve the built-in page SDK JS from the langbot_plugin package +_PAGE_SDK_PATH = None +try: + import langbot_plugin.assets as _assets_pkg + + _candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js') + if os.path.exists(_candidate): + _PAGE_SDK_PATH = _candidate +except Exception: + pass + + +def _normalize_plugin_asset_path(filepath: str) -> str | None: + filepath = filepath.replace('\\', '/') + if filepath.startswith('/'): + return None + + normalized = posixpath.normpath(filepath) + if normalized == '.' or normalized.startswith('../') or normalized == '..': + return None + + if normalized.startswith('components/pages/'): + return normalized + + return f'assets/{normalized}' + @group.group_class('plugins', '/api/v1/plugins') class PluginsRouterGroup(group.RouterGroup): @@ -65,6 +92,15 @@ class PluginsRouterGroup(group.RouterGroup): return None async def initialize(self) -> None: + @self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> quart.Response: + """Serve the built-in LangBot page SDK JavaScript.""" + if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH): + with open(_PAGE_SDK_PATH, 'r') as f: + content = f.read() + return quart.Response(content, mimetype='application/javascript') + return quart.Response('// SDK not found', status=404, mimetype='application/javascript') + @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: plugins = await self.ap.plugin_connector.list_plugins() @@ -173,15 +209,62 @@ class PluginsRouterGroup(group.RouterGroup): return quart.Response(icon_data, mimetype=mime_type) @self.route( - '///assets/', + '///assets/', methods=['GET'], auth_type=group.AuthType.NONE, ) async def _(author: str, plugin_name: str, filepath: str) -> quart.Response: - asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath) + asset_path = _normalize_plugin_asset_path(filepath) + if asset_path is None: + return quart.Response('Asset not found', status=404) + + asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path) + if not asset_data.get('asset_base64'): + return quart.Response('Asset not found', status=404) asset_bytes = base64.b64decode(asset_data['asset_base64']) mime_type = asset_data['mime_type'] - return quart.Response(asset_bytes, mimetype=mime_type) + resp = quart.Response(asset_bytes, mimetype=mime_type) + # CSP for HTML pages served to sandboxed iframes (opaque origin). + # 'self' doesn't work in sandboxed iframes — use actual server origin. + if mime_type and mime_type.startswith('text/html'): + origin = f'{quart.request.scheme}://{quart.request.host}' + resp.headers['Content-Security-Policy'] = ( + f'default-src {origin}; ' + f"script-src {origin} 'unsafe-inline'; " + f"style-src {origin} 'unsafe-inline'; " + f'img-src {origin} data:; ' + f'connect-src {origin}; ' + "frame-src 'none'; " + "object-src 'none'" + ) + return resp + + @self.route( + '///page-api', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def _(author: str, plugin_name: str) -> str: + """Forward a page API request to the plugin.""" + data = await quart.request.json + if not isinstance(data, dict): + return self.http_status(400, -1, 'invalid request body') + + page_id = data.get('page_id', '') + endpoint = data.get('endpoint', '') + method = data.get('method', 'POST') + body = data.get('body') + if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str): + return self.http_status(400, -1, 'invalid page api request') + if not endpoint.startswith('/') or '..' in endpoint: + return self.http_status(400, -1, 'invalid endpoint') + + result = await self.ap.plugin_connector.handle_page_api( + author, plugin_name, page_id, endpoint, method.upper(), body + ) + if result.get('error'): + return self.http_status(400, -1, result['error']) + return self.success(data=result.get('data')) @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 8f211537..0cc5c990 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -136,6 +136,10 @@ class SystemRouterGroup(group.RouterGroup): return self.success(data=task.to_dict()) + @self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + return self.success(data=await self.ap.maintenance_service.get_storage_analysis()) + @self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: if not constants.debug_mode: diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py new file mode 100644 index 00000000..e755800e --- /dev/null +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import datetime +import os +import re +from pathlib import Path +from typing import Any + +import sqlalchemy + +from ....core import app +from ....entity.persistence import bstorage as persistence_bstorage +from ....entity.persistence import monitoring as persistence_monitoring + + +LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$') +DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7 +DEFAULT_LOG_RETENTION_DAYS = 3 + + +class MaintenanceService: + """Storage maintenance and diagnostics.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def cleanup_expired_files(self) -> dict[str, int]: + cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {}) + upload_retention_days = self._positive_int( + cleanup_cfg.get('uploaded_file_retention_days'), + DEFAULT_UPLOAD_FILE_RETENTION_DAYS, + 'storage.cleanup.uploaded_file_retention_days', + ) + log_retention_days = self._positive_int( + cleanup_cfg.get('log_retention_days'), + DEFAULT_LOG_RETENTION_DAYS, + 'storage.cleanup.log_retention_days', + ) + + return { + 'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days), + 'log_files': self._cleanup_expired_log_files(log_retention_days), + } + + async def get_storage_analysis(self) -> dict[str, Any]: + cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {}) + upload_retention_days = self._positive_int( + cleanup_cfg.get('uploaded_file_retention_days'), + DEFAULT_UPLOAD_FILE_RETENTION_DAYS, + 'storage.cleanup.uploaded_file_retention_days', + ) + log_retention_days = self._positive_int( + cleanup_cfg.get('log_retention_days'), + DEFAULT_LOG_RETENTION_DAYS, + 'storage.cleanup.log_retention_days', + ) + + database_cfg = self.ap.instance_config.data.get('database', {}) + database_type = database_cfg.get('use', 'sqlite') + database_path = ( + Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None + ) + roots: list[tuple[str, Path | None]] = [ + ('database', database_path), + ('logs', Path('data/logs')), + ('storage', Path('data/storage')), + ('vector_store', Path('data/chroma')), + ('plugins', Path('data/plugins')), + ('mcp', Path('data/mcp')), + ('temp', Path('data/temp')), + ] + + sections = [] + for key, path in roots: + sections.append( + { + 'key': key, + 'path': str(path) if path else '', + 'exists': path.exists() if path else False, + 'size_bytes': self._path_size(path) if path else 0, + 'file_count': self._file_count(path) if path else 0, + } + ) + + monitoring_counts = await self._monitoring_counts() + binary_storage = await self._binary_storage_stats() + upload_candidates = await self._expired_uploaded_candidates(upload_retention_days) + log_candidates = self._expired_log_candidates(log_retention_days) + + return { + 'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'cleanup_policy': { + 'uploaded_file_retention_days': upload_retention_days, + 'log_retention_days': log_retention_days, + }, + 'sections': sections, + 'database': { + 'type': database_type, + 'monitoring_counts': monitoring_counts, + 'binary_storage': binary_storage, + }, + 'cleanup_candidates': { + 'uploaded_files': upload_candidates, + 'log_files': log_candidates, + }, + 'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {}, + } + + async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int: + provider = self.ap.storage_mgr.storage_provider + provider_name = provider.__class__.__name__ + if provider_name == 'LocalStorageProvider': + candidates = self._expired_local_upload_candidates(retention_days, include_paths=True) + deleted = 0 + for item in candidates: + try: + os.remove(item['path']) + deleted += 1 + except FileNotFoundError: + pass + except Exception as e: + self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}') + return deleted + + if provider_name == 'S3StorageProvider': + return await self._cleanup_expired_s3_uploaded_files(retention_days) + + return 0 + + async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]: + provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__ + if provider_name == 'LocalStorageProvider': + return self._expired_local_upload_candidates(retention_days) + if provider_name == 'S3StorageProvider': + return await self._expired_s3_upload_candidates(retention_days) + return [] + + async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int: + provider = self.ap.storage_mgr.storage_provider + candidates = await self._expired_s3_upload_candidates(retention_days) + deleted = 0 + for item in candidates: + await provider.delete(item['key']) + deleted += 1 + return deleted + + async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]: + provider = self.ap.storage_mgr.storage_provider + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days) + candidates = [] + paginator = provider.s3_client.get_paginator('list_objects_v2') + + for page in paginator.paginate(Bucket=provider.bucket_name): + for obj in page.get('Contents', []): + key = obj.get('Key', '') + last_modified = obj.get('LastModified') + if not self._is_uploaded_file_key(key): + continue + if last_modified and last_modified < cutoff: + candidates.append( + { + 'key': key, + 'size_bytes': obj.get('Size', 0), + 'modified_at': last_modified.isoformat(), + } + ) + + return candidates + + def _cleanup_expired_log_files(self, retention_days: int) -> int: + deleted = 0 + for item in self._expired_log_candidates(retention_days, include_paths=True): + try: + os.remove(item['path']) + deleted += 1 + except FileNotFoundError: + pass + except Exception as e: + self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}') + return deleted + + def _expired_local_upload_candidates( + self, retention_days: int, include_paths: bool = False + ) -> list[dict[str, Any]]: + storage_root = Path('data/storage') + if not storage_root.exists(): + return [] + + cutoff = datetime.datetime.now().timestamp() - retention_days * 86400 + candidates = [] + for entry in storage_root.iterdir(): + if not entry.is_file() or not self._is_uploaded_file_key(entry.name): + continue + stat = entry.stat() + if stat.st_mtime >= cutoff: + continue + item = { + 'key': entry.name, + 'size_bytes': stat.st_size, + 'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(), + } + if include_paths: + item['path'] = str(entry) + candidates.append(item) + return candidates + + def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]: + log_root = Path('data/logs') + if not log_root.exists(): + return [] + + cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1) + candidates = [] + for entry in log_root.iterdir(): + if not entry.is_file(): + continue + match = LOG_FILE_PATTERN.match(entry.name) + if not match: + continue + try: + file_date = datetime.date.fromisoformat(match.group(1)) + except ValueError: + continue + if file_date >= cutoff_date: + continue + stat = entry.stat() + item = { + 'name': entry.name, + 'date': file_date.isoformat(), + 'size_bytes': stat.st_size, + } + if include_paths: + item['path'] = str(entry) + candidates.append(item) + return candidates + + def _is_uploaded_file_key(self, key: str) -> bool: + return '/' not in key and not key.startswith('plugin_config_') + + async def _monitoring_counts(self) -> dict[str, int]: + tables = { + 'messages': persistence_monitoring.MonitoringMessage.id, + 'llm_calls': persistence_monitoring.MonitoringLLMCall.id, + 'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id, + 'errors': persistence_monitoring.MonitoringError.id, + 'sessions': persistence_monitoring.MonitoringSession.session_id, + 'feedback': persistence_monitoring.MonitoringFeedback.id, + } + counts: dict[str, int] = {} + for key, column in tables.items(): + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column))) + counts[key] = result.scalar() or 0 + return counts + + async def _binary_storage_stats(self) -> dict[str, Any]: + count_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key)) + ) + size_bytes = None + try: + size_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value))) + ) + size_bytes = size_result.scalar() or 0 + except Exception as e: + self.ap.logger.warning(f'Failed to estimate binary storage size: {e}') + + return { + 'count': count_result.scalar() or 0, + 'size_bytes': size_bytes, + } + + def _path_size(self, path: Path) -> int: + if not path.exists(): + return 0 + if path.is_file(): + return path.stat().st_size + total = 0 + for root, _, files in os.walk(path): + for file_name in files: + file_path = Path(root) / file_name + try: + total += file_path.stat().st_size + except FileNotFoundError: + pass + return total + + def _file_count(self, path: Path) -> int: + if not path.exists(): + return 0 + if path.is_file(): + return 1 + count = 0 + for _, _, files in os.walk(path): + count += len(files) + return count + + def _positive_int(self, value: Any, default: int, name: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + if parsed < 1: + self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + return parsed diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index b670e99d..320104d8 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -23,6 +23,17 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict: return provider_dict +def _runtime_model_data(model_uuid: str, model_data: dict) -> dict: + """Return model data for rebuilding runtime models after an update. + + Update payloads intentionally omit uuid before writing to the database. + Runtime model entities still need the stable uuid so pipeline configs can + resolve the in-memory model immediately after an edit, without requiring a + process restart. + """ + return {**model_data, 'uuid': model_uuid} + + class LLMModelsService: ap: app.Application @@ -173,7 +184,7 @@ class LLMModelsService: raise Exception('provider not found') runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( - persistence_model.LLMModel(**model_data), + persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)), runtime_provider, ) self.ap.model_mgr.llm_models.append(runtime_llm_model) @@ -334,7 +345,7 @@ class EmbeddingModelsService: raise Exception('provider not found') runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider( - persistence_model.EmbeddingModel(**model_data), + persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)), runtime_provider, ) self.ap.model_mgr.embedding_models.append(runtime_embedding_model) @@ -492,7 +503,7 @@ class RerankModelsService: raise Exception('provider not found') runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider( - persistence_model.RerankModel(**model_data), + persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)), runtime_provider, ) self.ap.model_mgr.rerank_models.append(runtime_rerank_model) diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index e1c60fec..1ba66482 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -18,55 +18,119 @@ class MonitoringService: # ========== Cleanup Methods ========== - async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]: + async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]: """Delete monitoring records older than the specified retention period. Args: retention_days: Number of days to retain records. + batch_size: Maximum rows to delete per table batch. Returns: A dict mapping table name to the number of deleted rows. """ + if retention_days < 1: + raise ValueError('retention_days must be >= 1') + if batch_size < 1: + raise ValueError('batch_size must be >= 1') + cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta( days=retention_days ) - tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [ + tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [ ( 'monitoring_messages', persistence_monitoring.MonitoringMessage, persistence_monitoring.MonitoringMessage.timestamp, + persistence_monitoring.MonitoringMessage.id, ), ( 'monitoring_llm_calls', persistence_monitoring.MonitoringLLMCall, persistence_monitoring.MonitoringLLMCall.timestamp, + persistence_monitoring.MonitoringLLMCall.id, ), ( 'monitoring_embedding_calls', persistence_monitoring.MonitoringEmbeddingCall, persistence_monitoring.MonitoringEmbeddingCall.timestamp, + persistence_monitoring.MonitoringEmbeddingCall.id, ), ( 'monitoring_errors', persistence_monitoring.MonitoringError, persistence_monitoring.MonitoringError.timestamp, + persistence_monitoring.MonitoringError.id, ), ( 'monitoring_sessions', persistence_monitoring.MonitoringSession, persistence_monitoring.MonitoringSession.last_activity, + persistence_monitoring.MonitoringSession.session_id, + ), + ( + 'monitoring_feedback', + persistence_monitoring.MonitoringFeedback, + persistence_monitoring.MonitoringFeedback.timestamp, + persistence_monitoring.MonitoringFeedback.id, ), ] deleted_counts: dict[str, int] = {} - for table_name, model_cls, ts_column in tables_and_columns: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff)) - deleted_counts[table_name] = result.rowcount + for table_name, model_cls, ts_column, pk_column in tables_and_columns: + deleted_counts[table_name] = await self._delete_expired_in_batches( + model_cls=model_cls, + ts_column=ts_column, + pk_column=pk_column, + cutoff=cutoff, + batch_size=batch_size, + ) + + if sum(deleted_counts.values()) > 0: + await self._release_sqlite_space() return deleted_counts + async def _delete_expired_in_batches( + self, + model_cls: type, + ts_column: sqlalchemy.Column, + pk_column: sqlalchemy.Column, + cutoff: datetime.datetime, + batch_size: int, + ) -> int: + deleted_total = 0 + + while True: + select_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size) + ) + pk_values = list(select_result.scalars().all()) + if not pk_values: + break + + delete_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values)) + ) + deleted = delete_result.rowcount or 0 + deleted_total += deleted + + if len(pk_values) < batch_size: + break + + return deleted_total + + async def _release_sqlite_space(self) -> None: + database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite') + if database_type != 'sqlite': + return + + async with self.ap.persistence_mgr.get_db_engine().connect() as conn: + autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT') + await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)')) + await autocommit_conn.execute(sqlalchemy.text('VACUUM')) + # ========== Recording Methods ========== async def record_message( diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index c05e4896..6de25932 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -179,7 +179,7 @@ class SpaceService: space_url = space_config['url'] session = httpclient.get_session() - async with session.get(f'{space_url}/api/v1/models') as response: + async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response: if response.status != 200: raise ValueError(f'Failed to get models: {await response.text()}') data = await response.json() diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index aedf4b17..6e91c2b0 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -33,6 +33,7 @@ from ..api.http.service import apikey as apikey_service from ..api.http.service import webhook as webhook_service from ..api.http.service import monitoring as monitoring_service from ..api.http.service import skill as skill_service +from ..api.http.service import maintenance as maintenance_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -162,6 +163,8 @@ class Application: skill_mgr: skill_mgr.SkillManager = None + maintenance_service: maintenance_service.MaintenanceService = None + def __init__(self): pass @@ -201,14 +204,30 @@ class Application: monitoring_cfg = self.instance_config.data.get('monitoring', {}) auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {}) if auto_cleanup_cfg.get('enabled', True): - retention_days = auto_cleanup_cfg.get('retention_days', 30) - check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1) + retention_days = self._get_positive_int_config( + auto_cleanup_cfg.get('retention_days', 30), + default=30, + name='monitoring.auto_cleanup.retention_days', + ) + delete_batch_size = self._get_positive_int_config( + auto_cleanup_cfg.get('delete_batch_size', 1000), + default=1000, + name='monitoring.auto_cleanup.delete_batch_size', + ) + check_interval_hours = self._get_positive_float_config( + auto_cleanup_cfg.get('check_interval_hours', 1), + default=1, + name='monitoring.auto_cleanup.check_interval_hours', + ) async def monitoring_cleanup_loop(): check_interval_seconds = check_interval_hours * 3600 while True: try: - deleted = await self.monitoring_service.cleanup_expired_records(retention_days) + deleted = await self.monitoring_service.cleanup_expired_records( + retention_days, + batch_size=delete_batch_size, + ) total_deleted = sum(deleted.values()) if total_deleted > 0: self.logger.info( @@ -225,6 +244,33 @@ class Application: scopes=[core_entities.LifecycleControlScope.APPLICATION], ) + # Start storage/log maintenance task if enabled + storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {}) + if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None: + check_interval_hours = self._get_positive_float_config( + storage_cleanup_cfg.get('check_interval_hours', 1), + default=1, + name='storage.cleanup.check_interval_hours', + ) + + async def storage_cleanup_loop(): + check_interval_seconds = check_interval_hours * 3600 + while True: + try: + deleted = await self.maintenance_service.cleanup_expired_files() + total_deleted = sum(deleted.values()) + if total_deleted > 0: + self.logger.info(f'Storage maintenance: deleted expired files: {deleted}') + except Exception as e: + self.logger.warning(f'Storage maintenance error: {e}') + await asyncio.sleep(check_interval_seconds) + + self.task_mgr.create_task( + storage_cleanup_loop(), + name='storage-maintenance', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) + self.task_mgr.create_task( never_ending(), name='never-ending-task', @@ -239,6 +285,28 @@ class Application: self.logger.error(f'Application runtime fatal exception: {e}') self.logger.debug(f'Traceback: {traceback.format_exc()}') + def _get_positive_int_config(self, value, default: int, name: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + if parsed < 1: + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + return parsed + + def _get_positive_float_config(self, value, default: float, name: str) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + if parsed <= 0: + self.logger.warning(f'Invalid {name}: {value!r}, using {default}') + return default + return parsed + def dispose(self): if self.plugin_connector is not None: self.plugin_connector.dispose() diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 6c39627e..a8d53b7b 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -31,6 +31,7 @@ from ...api.http.service import webhook as webhook_service from ...api.http.service import monitoring as monitoring_service from ...api.http.service import skill as skill_service from ...skill import manager as skill_mgr +from ...api.http.service import maintenance as maintenance_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache @@ -182,6 +183,9 @@ class BuildAppStage(stage.BootingStage): monitoring_service_inst = monitoring_service.MonitoringService(ap) ap.monitoring_service = monitoring_service_inst + maintenance_service_inst = maintenance_service.MaintenanceService(ap) + ap.maintenance_service = maintenance_service_inst + async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: await asyncio.sleep(3) await plugin_connector_inst.initialize() diff --git a/src/langbot/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py index c6846594..8bf8784a 100644 --- a/src/langbot/pkg/core/taskmgr.py +++ b/src/langbot/pkg/core/taskmgr.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import typing import datetime +import time from . import app from . import entities as core_entities @@ -119,6 +120,7 @@ class TaskWrapper: self.label = label if label != '' else name self.task.set_name(name) self.scopes = scopes + self.created_at = time.time() def assume_exception(self): try: @@ -154,6 +156,7 @@ class TaskWrapper: 'name': self.name, 'label': self.label, 'scopes': [scope.value for scope in self.scopes], + 'created_at': self.created_at, 'task_context': self.task_context.to_dict(), 'runtime': { 'done': self.task.done(), @@ -193,6 +196,8 @@ class AsyncTaskManager: ) -> TaskWrapper: wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes) self.tasks.append(wrapper) + wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks()) + self._prune_completed_tasks() return wrapper def create_user_task( @@ -226,6 +231,15 @@ class AsyncTaskManager: 'id_index': TaskWrapper._id_index, } + def get_stats(self) -> dict: + completed = sum(1 for t in self.tasks if t.task.done()) + return { + 'total': len(self.tasks), + 'running': len(self.tasks) - completed, + 'completed': completed, + 'id_index': TaskWrapper._id_index, + } + def get_task_by_id(self, id: int) -> TaskWrapper | None: for t in self.tasks: if t.id == id: @@ -243,3 +257,27 @@ class AsyncTaskManager: if not wrapper.task.done(): wrapper.task.cancel() return + + def _prune_completed_tasks(self): + completed_limit = ( + self.ap.instance_config.data.get('system', {}) + .get('task_retention', {}) + .get( + 'completed_limit', + 200, + ) + ) + try: + completed_limit = int(completed_limit) + except (TypeError, ValueError): + completed_limit = 200 + if completed_limit < 1: + completed_limit = 1 + + completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()] + overflow = len(completed_tasks) - completed_limit + if overflow <= 0: + return + + remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]} + self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids] diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 148ccb71..5ff2add6 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -78,6 +78,27 @@ class PreProcessor(stage.PipelineStage): query.bot_uuid, ) + # Expire externally managed conversation ids after the conversation has + # been idle for longer than the configured conversation expire time. + # The idle window is measured from the last preprocess/update time, not + # from the conversation creation time. + conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None) + now = datetime.datetime.now() + if conversation_expire_time is not None and conversation_expire_time > 0: + last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None) + if last_update_time is not None: + conversation_idle_time = now.timestamp() - last_update_time.timestamp() + if conversation_idle_time > conversation_expire_time: + self.ap.logger.info( + f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation' + ) + conversation.uuid = None + + # Treat every preprocess pass as a conversation activity update. This + # makes future expiry checks use the latest incoming message/preprocess + # time instead of the first message/creation time. + conversation.update_time = now + # 设置query query.session = session query.prompt = conversation.prompt.copy() @@ -171,7 +192,10 @@ class PreProcessor(stage.PipelineStage): elif me.url: content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice')) elif isinstance(me, platform_message.File): - content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name)) + if me.base64: + content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name)) + elif me.url: + content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name)) elif isinstance(me, platform_message.Quote) and quote_msg: for msg in me.origin: if isinstance(msg, platform_message.Plain): @@ -183,7 +207,10 @@ class PreProcessor(stage.PipelineStage): if msg.base64 is not None: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) elif isinstance(msg, platform_message.File): - content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name)) + if msg.base64: + content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name)) + elif msg.url: + content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name)) elif isinstance(msg, platform_message.Voice): if msg.base64: content_list.append( diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 6ae0f4c2..8e99618c 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -523,7 +523,7 @@ class PlatformManager: return None async def remove_bot(self, bot_uuid: str): - for bot in self.bots: + for bot in self.bots[:]: if bot.bot_entity.uuid == bot_uuid: if bot.enable: await bot.shutdown() diff --git a/src/langbot/pkg/platform/sources/matrix.png b/src/langbot/pkg/platform/sources/matrix.png new file mode 100644 index 00000000..56eb45ee Binary files /dev/null and b/src/langbot/pkg/platform/sources/matrix.png differ diff --git a/src/langbot/pkg/platform/sources/matrix.py b/src/langbot/pkg/platform/sources/matrix.py new file mode 100644 index 00000000..da159223 --- /dev/null +++ b/src/langbot/pkg/platform/sources/matrix.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +import typing +import asyncio +import traceback +import base64 +import json + +import nio + +from langbot.pkg.utils import httpclient +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + + +class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter): + @staticmethod + async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]: + components = [] + for component in message_chain: + if isinstance(component, platform_message.Plain): + components.append({'type': 'text', 'text': component.text}) + elif isinstance(component, platform_message.Image): + image_bytes = None + if component.base64: + b64_data = component.base64 + if ';base64,' in b64_data: + b64_data = b64_data.split(';base64,', 1)[1] + image_bytes = base64.b64decode(b64_data) + elif component.url: + session = httpclient.get_session() + async with session.get(component.url) as response: + image_bytes = await response.read() + elif component.path: + with open(component.path, 'rb') as f: + image_bytes = f.read() + if image_bytes: + resp = await client.upload(image_bytes, content_type='image/png') + if isinstance(resp, nio.UploadResponse): + components.append({'type': 'image', 'mxc_url': resp.content_uri}) + elif isinstance(component, platform_message.File): + file_bytes = None + if component.base64: + b64_data = component.base64 + if ';base64,' in b64_data: + b64_data = b64_data.split(';base64,', 1)[1] + file_bytes = base64.b64decode(b64_data) + elif component.url: + session = httpclient.get_session() + async with session.get(component.url) as response: + file_bytes = await response.read() + elif component.path: + with open(component.path, 'rb') as f: + file_bytes = f.read() + if file_bytes: + file_name = getattr(component, 'name', None) or 'file' + resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name) + if isinstance(resp, nio.UploadResponse): + components.append( + { + 'type': 'file', + 'mxc_url': resp.content_uri, + 'filename': file_name, + 'size': len(file_bytes), + } + ) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client)) + return components + + @staticmethod + async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str): + message_components = [] + + if isinstance(event, nio.RoomMessageText): + text = event.body + if bot_user_id and bot_user_id in text: + message_components.append(platform_message.At(target=bot_user_id)) + text = text.replace(bot_user_id, '').strip() + message_components.append(platform_message.Plain(text=text)) + + elif isinstance(event, nio.RoomMessageImage): + mxc_url = event.url + if mxc_url: + resp = await client.download(mxc_url) + if isinstance(resp, nio.DownloadResponse): + b64 = base64.b64encode(resp.body).decode('utf-8') + content_type = resp.content_type or 'image/png' + message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}')) + if event.body: + message_components.append(platform_message.Plain(text=event.body)) + + return platform_message.MessageChain(message_components) + + +class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter): + @staticmethod + async def yiri2target(event: platform_events.MessageEvent): + return event.source_platform_object + + @staticmethod + async def target2yiri( + event: nio.RoomMessageText | nio.RoomMessageImage, + room: nio.MatrixRoom, + client: nio.AsyncClient, + bot_user_id: str, + bridge_user_ids: list[str] | None = None, + ): + lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id) + + # Determine if this is a direct/private chat or a group chat. + # Exclude bot itself and bridge bots, count remaining real users. + exclude_ids = {bot_user_id} + if bridge_user_ids: + exclude_ids.update(bridge_user_ids) + real_users = [uid for uid in room.users if uid not in exclude_ids] + is_direct = len(real_users) <= 1 + + if is_direct: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.sender, + nickname=room.user_name(event.sender) or event.sender, + remark='', + ), + message_chain=lb_message, + time=event.server_timestamp / 1000.0, + source_platform_object={'event': event, 'room': room}, + ) + else: + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=event.sender, + member_name=room.user_name(event.sender) or event.sender, + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=room.room_id, + name=room.display_name or room.room_id, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=lb_message, + time=event.server_timestamp / 1000.0, + source_platform_object={'event': event, 'room': room}, + ) + + +class BridgeState: + """Per-bridge runtime state.""" + + def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str): + self.user_id = user_id + self.login_command = login_command + self.logout_command = logout_command + self.success_keyword = success_keyword + self.check_command = check_command or login_command + self.logged_in = False + self.dm_room_id: str | None = None + self.login_task: asyncio.Task | None = None + self.check_task: asyncio.Task | None = None + self.check_responded = False + + +class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + client: typing.Any = None + message_converter: MatrixMessageConverter = MatrixMessageConverter() + event_converter: MatrixEventConverter = MatrixEventConverter() + config: dict + listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {} + _running: bool = False + _initial_sync_done: bool = False + _bridges: list = [] + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): + homeserver_url = config.get('homeserver_url', '') + access_token = config.get('access_token', '') + user_id = config.get('user_id', '') + + if not homeserver_url or not access_token or not user_id: + raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)') + + client = nio.AsyncClient(homeserver_url, user_id) + client.access_token = access_token + client.user_id = user_id + + super().__init__( + config=config, + logger=logger, + bot_account_id=user_id, + client=client, + listeners={}, + ) + + # Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges + self._bridges = [] + bridges_raw = config.get('bridges', '') + if bridges_raw: + if isinstance(bridges_raw, str): + try: + bridges_list = json.loads(bridges_raw) + except (json.JSONDecodeError, TypeError) as e: + raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}') + else: + bridges_list = bridges_raw + for b in bridges_list: + if isinstance(b, dict) and b.get('user_id', '').strip(): + self._bridges.append( + BridgeState( + user_id=b['user_id'].strip(), + login_command=b.get('login_command', '').strip(), + logout_command=b.get('logout_command', '').strip(), + success_keyword=b.get('success_keyword', 'Successfully logged in').strip(), + check_command=b.get('check_command', '').strip(), + ) + ) + # Backward compatibility: old single-bridge config + if not self._bridges: + old_user_id = config.get('bridge_user_id', '').strip() + old_command = config.get('bridge_login_command', '').strip() + old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip() + old_check = config.get('bridge_check_command', '').strip() + old_logout = config.get('bridge_logout_command', '').strip() + if old_user_id: + self._bridges.append( + BridgeState( + user_id=old_user_id, + login_command=old_command, + logout_command=old_logout, + success_keyword=old_keyword, + check_command=old_check, + ) + ) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + components = await self.message_converter.yiri2target(message, self.client) + for component in components: + await self._send_component(target_id, component) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + source_obj = message_source.source_platform_object + room_id = source_obj['room'].room_id + components = await self.message_converter.yiri2target(message, self.client) + + for component in components: + if quote_origin: + original_event = source_obj['event'] + await self._send_component(room_id, component, reply_to=original_event.event_id) + else: + await self._send_component(room_id, component) + + async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None): + content = {} + if component['type'] == 'text': + content = { + 'msgtype': 'm.text', + 'body': component['text'], + } + elif component['type'] == 'image': + content = { + 'msgtype': 'm.image', + 'body': 'image.png', + 'url': component['mxc_url'], + } + elif component['type'] == 'file': + content = { + 'msgtype': 'm.file', + 'body': component.get('filename', 'file'), + 'url': component['mxc_url'], + 'info': {'size': component.get('size', 0)}, + } + + if reply_to and content: + content['m.relates_to'] = { + 'm.in_reply_to': {'event_id': reply_to}, + } + + if content: + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content=content, + ) + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + self.listeners[event_type] = callback + + async def run_async(self): + self._running = True + await self.logger.info('Matrix adapter starting...') + + # Debug: log bridge parsing result + bridges_raw = self.config.get('bridges', '') + await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}') + await self.logger.debug( + f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}' + ) + + # Collect all bridge bot user IDs for filtering + _bridge_user_ids = [b.user_id for b in self._bridges] + _bridge_user_id_set = set(_bridge_user_ids) + + # Auto-join invited rooms + async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent): + if event.membership == 'invite' and event.state_key == self.client.user_id: + await self.client.join(room.room_id) + await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}') + + self.client.add_event_callback(on_invite, nio.InviteMemberEvent) + + # Handle text messages + async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText): + if not self._initial_sync_done: + return + if event.sender == self.client.user_id: + return + + # Admin commands (from any non-bridge user) + if event.sender not in _bridge_user_id_set: + body = (event.body or '').strip() + if body == '!relogin': + await self._handle_relogin_command(room.room_id) + return + if body == '!status': + await self._handle_status_command(room.room_id) + return + + if event.sender in _bridge_user_id_set: + return + try: + lb_event = await self.event_converter.target2yiri( + event, room, self.client, self.bot_account_id, _bridge_user_ids + ) + if type(lb_event) in self.listeners: + result = self.listeners[type(lb_event)](lb_event, self) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}') + + self.client.add_event_callback(on_message, nio.RoomMessageText) + + # Handle image messages + async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage): + if not self._initial_sync_done: + return + if event.sender == self.client.user_id: + return + if event.sender in _bridge_user_id_set: + return + try: + lb_event = await self.event_converter.target2yiri( + event, room, self.client, self.bot_account_id, _bridge_user_ids + ) + if type(lb_event) in self.listeners: + result = self.listeners[type(lb_event)](lb_event, self) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}') + + self.client.add_event_callback(on_image, nio.RoomMessageImage) + + # Set up bridge-specific callbacks for each bridge + _disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired'] + + for bridge in self._bridges: + # Login success detection (notice) + async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge): + if not self._initial_sync_done: + return + if event.sender != _b.user_id: + return + _b.check_responded = True + if _b.success_keyword in (event.body or ''): + _b.logged_in = True + await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.') + # Disconnect detection + body_lower = (event.body or '').lower() + for kw in _disconnect_keywords: + if kw in body_lower and _b.logged_in: + _b.logged_in = False + await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...') + self._restart_bridge_login(_b) + break + + self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice) + + # Login success + disconnect detection (text) + async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge): + if not self._initial_sync_done: + return + if event.sender != _b.user_id: + return + _b.check_responded = True + if _b.success_keyword in (event.body or ''): + _b.logged_in = True + await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.') + body_lower = (event.body or '').lower() + for kw in _disconnect_keywords: + if kw in body_lower and _b.logged_in: + _b.logged_in = False + await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...') + self._restart_bridge_login(_b) + break + + self.client.add_event_callback(on_bridge_text, nio.RoomMessageText) + + # QR code image forwarding + async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge): + if not self._initial_sync_done: + return + if event.sender != _b.user_id: + return + mxc_url = event.url + if not mxc_url: + return + try: + resp = await self.client.download(mxc_url) + if isinstance(resp, nio.DownloadResponse): + b64 = base64.b64encode(resp.body).decode('utf-8') + content_type = resp.content_type or 'image/png' + await self.logger.info( + f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:', + images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')], + ) + except Exception: + await self.logger.error( + f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}' + ) + + self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage) + + await self.logger.debug('Matrix adapter running, starting sync...') + + # Initial sync to skip old messages + resp = await self.client.sync(timeout=10000) + if isinstance(resp, nio.SyncResponse): + await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}') + self._initial_sync_done = True + + # Display account info + display_name = self.client.user_id + try: + profile_resp = await self.client.get_displayname(self.client.user_id) + if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname: + display_name = profile_resp.displayname + except Exception: + pass + joined_rooms = len(self.client.rooms) + homeserver = self.config.get('homeserver_url', '') + bridge_info = '' + if self._bridges: + bridge_names = ', '.join(b.user_id for b in self._bridges) + bridge_info = f' | 桥接: [{bridge_names}]' + await self.logger.info( + f'Matrix 账号: {display_name} ({self.client.user_id}) | ' + f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}' + ) + + # Start bridge login and status check tasks for each bridge + for bridge in self._bridges: + if bridge.login_command: + await self.logger.info( + f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", ' + f'关键词: "{bridge.success_keyword}")' + ) + bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge)) + bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge)) + else: + await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)') + + # Main sync loop + while self._running: + try: + await self.client.sync(timeout=30000) + except Exception: + await self.logger.error(f'Matrix sync error: {traceback.format_exc()}') + await asyncio.sleep(5) + + async def _periodic_bridge_login(self, bridge: BridgeState): + """Periodically send login command to a bridge bot until login succeeds.""" + try: + await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...') + dm_room_id = None + for room_id, room in self.client.rooms.items(): + if room.member_count == 2 and bridge.user_id in [m for m in room.users]: + dm_room_id = room_id + break + + if not dm_room_id: + resp = await self.client.room_create( + is_direct=True, + invite=[bridge.user_id], + ) + if isinstance(resp, nio.RoomCreateResponse): + dm_room_id = resp.room_id + await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}') + else: + await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}') + return + + bridge.dm_room_id = dm_room_id + + # Force logout first on every adapter start + logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') + await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"') + await self.client.room_send( + room_id=dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': logout_cmd}, + ) + await asyncio.sleep(3) + + while self._running and not bridge.logged_in: + await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}') + await self.client.room_send( + room_id=dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': bridge.login_command}, + ) + for _ in range(60): + if not self._running or bridge.logged_in: + break + await asyncio.sleep(1) + + if bridge.logged_in: + await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.') + except asyncio.CancelledError: + pass + except Exception: + await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}') + + def _restart_bridge_login(self, bridge: BridgeState): + """Cancel existing login task and start a new one.""" + if bridge.login_task and not bridge.login_task.done(): + bridge.login_task.cancel() + bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge)) + + async def _periodic_bridge_check(self, bridge: BridgeState): + """Periodically check a bridge's login status.""" + try: + while self._running and not bridge.logged_in: + await asyncio.sleep(5) + + check_interval = 300 # 5 minutes + response_timeout = 30 + await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)') + + while self._running: + for _ in range(check_interval): + if not self._running: + return + await asyncio.sleep(1) + + if not bridge.logged_in or not bridge.dm_room_id: + continue + + try: + bridge.check_responded = False + await self.client.room_send( + room_id=bridge.dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': bridge.check_command}, + ) + await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"') + + for _ in range(response_timeout): + if bridge.check_responded or not self._running: + break + await asyncio.sleep(1) + + if bridge.check_responded: + await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK') + else: + await self.logger.info( + f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...' + ) + bridge.logged_in = False + self._restart_bridge_login(bridge) + except Exception: + await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}') + except asyncio.CancelledError: + pass + except Exception: + await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}') + + async def _handle_relogin_command(self, room_id: str): + """Handle !relogin command: logout then re-login all bridges.""" + if not self._bridges: + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + ) + return + + lines = ['开始重新登录所有桥...'] + for bridge in self._bridges: + if not bridge.login_command or not bridge.dm_room_id: + lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)') + continue + + # Use configured logout command, fallback to deriving from login command + logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') + lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...') + + # Cancel existing tasks + if bridge.login_task and not bridge.login_task.done(): + bridge.login_task.cancel() + if bridge.check_task and not bridge.check_task.done(): + bridge.check_task.cancel() + + # Send logout + try: + await self.client.room_send( + room_id=bridge.dm_room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': logout_cmd}, + ) + except Exception as e: + lines.append(f'[{bridge.user_id}] logout 发送失败: {e}') + + await asyncio.sleep(2) + + # Reset state and restart login + bridge.logged_in = False + self._restart_bridge_login(bridge) + lines.append(f'[{bridge.user_id}] 已触发重新登录') + + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '\n'.join(lines)}, + ) + + async def _handle_status_command(self, room_id: str): + """Handle !status command: show bridge states.""" + if not self._bridges: + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + ) + return + + lines = ['桥状态:'] + for bridge in self._bridges: + status = '已登录 ✓' if bridge.logged_in else '未登录 ✗' + dm = bridge.dm_room_id or '无' + lines.append(f'• {bridge.user_id}: {status} (DM: {dm})') + await self.client.room_send( + room_id=room_id, + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': '\n'.join(lines)}, + ) + + async def kill(self) -> bool: + self._running = False + for bridge in self._bridges: + if bridge.login_task and not bridge.login_task.done(): + bridge.login_task.cancel() + if bridge.check_task and not bridge.check_task.done(): + bridge.check_task.cancel() + if self.client: + await self.client.close() + await self.logger.debug('Matrix adapter stopped') + return True + + async def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None + ], + ): + if event_type in self.listeners: + del self.listeners[event_type] diff --git a/src/langbot/pkg/platform/sources/matrix.yaml b/src/langbot/pkg/platform/sources/matrix.yaml new file mode 100644 index 00000000..d69b605f --- /dev/null +++ b/src/langbot/pkg/platform/sources/matrix.yaml @@ -0,0 +1,123 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: matrix + label: + en_US: Matrix + zh_Hans: Matrix + zh_Hant: Matrix + ja_JP: Matrix + th_TH: Matrix + vi_VN: Matrix + es_ES: Matrix + description: + en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver + zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver + zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver + ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート + th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix + vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào + es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix + icon: matrix.png +spec: + categories: + - global + - protocol + config: + - name: homeserver_url + label: + en_US: Homeserver URL + zh_Hans: Homeserver 地址 + zh_Hant: Homeserver 地址 + ja_JP: Homeserver URL + th_TH: URL ของ Homeserver + vi_VN: URL Homeserver + es_ES: URL del Homeserver + description: + en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008" + zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008" + type: string + required: true + default: "http://localhost:8008" + - name: user_id + label: + en_US: Bot User ID + zh_Hans: 机器人用户 ID + zh_Hant: 機器人用戶 ID + ja_JP: ボットユーザー ID + th_TH: ID ผู้ใช้บอท + vi_VN: ID người dùng bot + es_ES: ID de usuario del bot + description: + en_US: "The full Matrix user ID, e.g. @bot:localhost" + zh_Hans: "完整的 Matrix 用户 ID,例如 @bot:localhost" + type: string + required: true + default: "@langbot:localhost" + - name: access_token + label: + en_US: Access Token + zh_Hans: 访问令牌 + zh_Hant: 訪問令牌 + ja_JP: アクセストークン + th_TH: โทเค็นการเข้าถึง + vi_VN: Mã truy cập + es_ES: Token de acceso + description: + en_US: "Access token obtained by logging in via the Matrix client API" + zh_Hans: "通过 Matrix Client API 登录获取的访问令牌" + type: string + required: true + default: "" + - name: bridge_user_id + label: + en_US: Bridge Bot User ID (single bridge, legacy) + zh_Hans: 桥机器人用户 ID(单桥兼容) + description: + en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost" + zh_Hans: "单桥机器人用户 ID(旧格式兼容)。推荐使用 bridges 配置多桥。例如 @discordbot:localhost" + type: string + required: false + default: "" + - name: bridge_login_command + label: + en_US: Bridge Login Command (single bridge, legacy) + zh_Hans: 桥登录命令(单桥兼容) + description: + en_US: "Login command for single bridge (legacy). e.g. !discord login" + zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login" + type: string + required: false + default: "" + - name: bridge_login_success_keyword + label: + en_US: Bridge Login Success Keyword (single bridge, legacy) + zh_Hans: 桥登录成功关键词(单桥兼容) + description: + en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in" + zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in" + type: string + required: false + default: "Successfully logged in" + - name: bridges + label: + en_US: Bridges Config (Multi-bridge) + zh_Hans: 桥配置(多桥) + description: + en_US: > + JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login", + "success_keyword": "logged in", "check_command": "!xx ping"}. + Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"}, + {"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}] + zh_Hans: > + JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login", + "success_keyword": "logged in", "check_command": "!xx ping"}。 + 示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"}, + {"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}] + type: string + required: false + default: "" +execution: + python: + path: ./matrix.py + attr: MatrixAdapter diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index 354afc41..8af40697 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -1,9 +1,11 @@ from __future__ import annotations import typing +import re import asyncio import traceback import datetime +import time import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.entities.builtin.platform.message as platform_message @@ -15,11 +17,25 @@ from ...utils import image from ..logger import EventLogger +def _is_base64_data(value: str) -> bool: + """Check if a string contains base64-encoded data rather than a URL.""" + if not value: + return False + # data: URI scheme (e.g. data:image/png;base64,xxx) + if value.startswith('data:'): + return True + # Only treat as base64 if it doesn't look like a URL/path and has valid base64 chars + if value.startswith(('http://', 'https://', '/', './', '../')): + return False + # Check if it looks like base64 (only valid chars, reasonable length) + return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value)) + + class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain): + """将 LangBot 消息链转换为 QQ Official 消息格式列表。""" content_list = [] - # 只实现了发文字 for msg in message_chain: if type(msg) is platform_message.Plain: content_list.append( @@ -28,6 +44,49 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver 'content': msg.text, } ) + elif type(msg) is platform_message.Image: + url = msg.url if hasattr(msg, 'url') and msg.url else None + b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None + # Some plugins (e.g. MimoTTS) store base64 data in the url field + if url and not b64 and _is_base64_data(url): + b64 = url + url = None + content_list.append( + { + 'type': 'image', + 'url': url, + 'base64': b64, + } + ) + elif type(msg) is platform_message.Voice: + url = msg.url if hasattr(msg, 'url') and msg.url else None + b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None + # Some plugins (e.g. MimoTTS) store base64 data in the url field + if url and not b64 and _is_base64_data(url): + b64 = url + url = None + content_list.append( + { + 'type': 'voice', + 'url': url, + 'base64': b64, + } + ) + elif type(msg) is platform_message.File: + url = msg.url if hasattr(msg, 'url') and msg.url else None + b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None + # Some plugins store base64 data in the url field + if url and not b64 and _is_base64_data(url): + b64 = url + url = None + content_list.append( + { + 'type': 'file', + 'url': url, + 'base64': b64, + 'name': msg.name if hasattr(msg, 'name') else 'file', + } + ) return content_list @@ -129,12 +188,19 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter config: dict bot_account_id: str bot_uuid: str = None + enable_webhook: bool = False message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter() def __init__(self, config: dict, logger: EventLogger): + enable_webhook = config.get('enable-webhook', False) + bot = QQOfficialClient( - app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True + app_id=config['appid'], + secret=config['secret'], + token=config['token'], + logger=logger, + unified_mode=enable_webhook, ) super().__init__( @@ -144,6 +210,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter bot_account_id=config['appid'], ) + self.enable_webhook = enable_webhook + self._ws_task: asyncio.Task = None + self._stream_ctx: dict = {} + self._stream_ctx_ts: dict[str, float] = {} + self._fallback_text: dict[str, str] = {} + self._fallback_text_ts: dict[str, float] = {} + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -156,28 +229,18 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter content_list = await QQOfficialMessageConverter.yiri2target(message) - # 私聊消息 + # 确定 target_type 和 target_id + target_type = None + target_id = None + if qq_official_event.t == 'C2C_MESSAGE_CREATE': - for content in content_list: - if content['type'] == 'text': - await self.bot.send_private_text_msg( - qq_official_event.user_openid, - content['content'], - qq_official_event.d_id, - ) - - # 群聊消息 - if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE': - for content in content_list: - if content['type'] == 'text': - await self.bot.send_group_text_msg( - qq_official_event.group_openid, - content['content'], - qq_official_event.d_id, - ) - - # 频道群聊 - if qq_official_event.t == 'AT_MESSAGE_CREATE': + target_type = 'c2c' + target_id = qq_official_event.user_openid + elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE': + target_type = 'group' + target_id = qq_official_event.group_openid + elif qq_official_event.t == 'AT_MESSAGE_CREATE': + # 频道群聊使用频道 API,暂不支持富媒体 for content in content_list: if content['type'] == 'text': await self.bot.send_channle_group_text_msg( @@ -185,9 +248,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter content['content'], qq_official_event.d_id, ) - - # 频道私聊 - if qq_official_event.t == 'DIRECT_MESSAGE_CREATE': + return + elif qq_official_event.t == 'DIRECT_MESSAGE_CREATE': + # 频道私聊使用频道 API,暂不支持富媒体 for content in content_list: if content['type'] == 'text': await self.bot.send_channle_private_text_msg( @@ -195,6 +258,63 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter content['content'], qq_official_event.d_id, ) + return + + # C2C 和群聊:支持文字 + 富媒体 + for content in content_list: + content_type = content.get('type', 'text') + + if content_type == 'text': + if target_type == 'c2c': + await self.bot.send_private_text_msg( + target_id, + content['content'], + qq_official_event.d_id, + ) + elif target_type == 'group': + await self.bot.send_group_text_msg( + target_id, + content['content'], + qq_official_event.d_id, + ) + + elif content_type == 'image': + file_url = content.get('url') + file_data = content.get('base64') + if file_url or file_data: + await self.bot.send_image_msg( + target_type, + target_id, + file_url=file_url, + file_data=file_data, + msg_id=qq_official_event.d_id, + ) + + elif content_type == 'voice': + file_url = content.get('url') + file_data = content.get('base64') + if file_url or file_data: + await self.bot.send_voice_msg( + target_type, + target_id, + file_url=file_url, + file_data=file_data, + msg_id=qq_official_event.d_id, + ) + + elif content_type == 'file': + file_url = content.get('url') + file_data = content.get('base64') + file_name = content.get('name', 'file') + if file_url or file_data: + await self.bot.send_file_msg( + target_type, + target_id, + file_url=file_url, + file_data=file_data, + file_name=file_name, + msg_id=qq_official_event.d_id, + ) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass @@ -238,17 +358,196 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter return await self.bot.handle_unified_webhook(request) async def run_async(self): - # 统一 webhook 模式下,不启动独立的 Quart 应用 - # 保持运行但不启动独立端口 + if not self.enable_webhook: + await self._run_websocket() + else: + # 统一 webhook 模式下,不启动独立的 Quart 应用 + async def keep_alive(): + while True: + await asyncio.sleep(1) - async def keep_alive(): - while True: - await asyncio.sleep(1) + await keep_alive() - await keep_alive() + async def _run_websocket(self): + """以 WebSocket 模式运行网关连接""" + await self.logger.info('QQ Official adapter starting in WebSocket mode') + + async def on_ready(): + await self.logger.info('QQ Official WebSocket connected and ready') + + async def on_event(event_type: str, event_data: dict): + # 只处理消息事件,忽略 READY/RESUMED 等系统事件 + message_event_types = { + 'C2C_MESSAGE_CREATE', + 'DIRECT_MESSAGE_CREATE', + 'GROUP_AT_MESSAGE_CREATE', + 'AT_MESSAGE_CREATE', + } + if event_type not in message_event_types: + return + if not isinstance(event_data, dict): + await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}') + return + await self.logger.info(f'Processing message event: {event_type}') + # 构造与 webhook 模式相同的 payload 结构 + payload = {'t': event_type, 'd': event_data} + message_data = await self.bot.get_message(payload) + if message_data: + event = QQOfficialEvent.from_payload(message_data) + await self.bot._handle_message(event) + + async def on_error(error: Exception): + await self.logger.error(f'WebSocket error: {error}') + await self.logger.error(f'QQ Official WebSocket error: {error}') + + self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error)) + try: + await self._ws_task + except asyncio.CancelledError: + pass async def kill(self) -> bool: - return False + if self._ws_task: + self._ws_task.cancel() + try: + await self._ws_task + except asyncio.CancelledError: + pass + self._ws_task = None + return True + + # --------------- 流式输出 --------------- + + _STREAM_CTX_TTL = 300 # seconds + + async def _cleanup_stale_streams(self): + """Remove stream contexts that have not been updated for more than _STREAM_CTX_TTL seconds.""" + now = time.time() + stale_ids = [mid for mid, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL] + for mid in stale_ids: + self._stream_ctx.pop(mid, None) + self._stream_ctx_ts.pop(mid, None) + stale_fb = [mid for mid, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL] + for mid in stale_fb: + self._fallback_text.pop(mid, None) + self._fallback_text_ts.pop(mid, None) + if stale_ids or stale_fb: + await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts') + + async def is_stream_output_supported(self) -> bool: + return self.config.get('enable-stream-reply', False) + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + source = event.source_platform_object + # Streaming API only supports C2C private chat + if source.t != 'C2C_MESSAGE_CREATE': + return False + + ctx = { + 'user_openid': source.user_openid, + 'msg_id': source.d_id, + 'stream_msg_id': None, + 'msg_seq': 1, + 'index': 0, + 'last_update_ts': 0, + 'accumulated_text': '', + 'sent_length': 0, + 'session_started': False, + } + + self._stream_ctx[message_id] = ctx + self._stream_ctx_ts[message_id] = time.time() + return True + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message: dict, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + # Periodically clean up stale stream contexts + await self._cleanup_stale_streams() + # 提取纯文本内容(当前 chunk 的文本) + text_parts = [] + for msg in message: + if type(msg) is platform_message.Plain: + text_parts.append(msg.text) + chunk_text = '\n\n'.join(text_parts) + + message_id = ( + bot_message.get('resp_message_id') + if isinstance(bot_message, dict) + else getattr(bot_message, 'resp_message_id', None) + ) + if not message_id or message_id not in self._stream_ctx: + # 非流式场景(如群聊不支持流式),累积文本后一次性回复 + if chunk_text: + self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text + self._fallback_text_ts[message_id] = time.time() + if is_final: + full_text = self._fallback_text.pop(message_id, '') + if full_text: + fallback_msg = platform_message.MessageChain([platform_message.Plain(text=full_text)]) + await self.reply_message(message_source, fallback_msg, quote_origin) + return + + ctx = self._stream_ctx[message_id] + + # 累积文本 + if chunk_text: + ctx['accumulated_text'] += chunk_text + + # 未启动会话时,等第一个有内容的 chunk 来建立会话 + if not ctx['session_started']: + if not ctx['accumulated_text']: + return + # 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀) + ctx['session_started'] = True + + # 发送内容 = 全量累积文本 + # QQ API 的 replace 模式不允许修改已下发前缀,所以: + # - 首次:发送全部文本,建立会话 + # - 后续:只能发送新增部分(append 行为) + content_to_send = ctx['accumulated_text'][ctx['sent_length'] :] + if not content_to_send and not is_final: + return + + input_state = 10 if is_final else 1 + + # Rate limiting: skip non-final updates if last update was <0.5s ago + now = time.time() + if not is_final and (now - ctx['last_update_ts']) < 0.5: + return + ctx['last_update_ts'] = now + + try: + resp = await self.bot.send_stream_msg( + user_openid=ctx['user_openid'], + content=content_to_send, + event_id=ctx['msg_id'], + msg_id=ctx['msg_id'], + msg_seq=ctx['msg_seq'], + index=ctx['index'], + stream_msg_id=ctx['stream_msg_id'], + input_state=input_state, + ) + if resp and isinstance(resp, dict): + new_stream_id = resp.get('id') + if new_stream_id: + ctx['stream_msg_id'] = new_stream_id + ctx['sent_length'] = len(ctx['accumulated_text']) + ctx['index'] += 1 + await self.logger.debug( + f'[QQ Official] 流式 chunk 已发送, index={ctx["index"]}, ' + f'sent_len={ctx["sent_length"]}, is_final={is_final}' + ) + except Exception as e: + await self.logger.error(f'Failed to send stream message: {e}') + + if is_final: + self._stream_ctx.pop(message_id, None) def unregister_listener( self, diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index 48ad7ffd..f6afdabc 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -7,9 +7,9 @@ metadata: zh_Hans: QQ 官方 API zh_Hant: QQ 官方 API description: - en_US: QQ Official API (Webhook) - zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式 - zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式 + en_US: QQ Official API (Webhook / WebSocket) + zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模式 + zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式 icon: qqofficial.svg spec: categories: @@ -19,18 +19,6 @@ spec: en: https://link.langbot.app/en/platforms/qqofficial ja: https://link.langbot.app/ja/platforms/qqofficial config: - - name: webhook_url - label: - en_US: Webhook Callback URL - zh_Hans: Webhook 回调地址 - zh_Hant: Webhook 回調地址 - description: - en_US: Copy this URL and paste it into your QQ Official API webhook configuration - zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中 - zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中 - type: webhook-url - required: false - default: "" - name: appid label: en_US: App ID @@ -55,6 +43,46 @@ spec: type: string required: true default: "" + - name: enable-webhook + label: + en_US: Enable Webhook Mode + zh_Hans: 启用Webhook模式 + zh_Hant: 啟用 Webhook 模式 + description: + en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WebSocket mode + zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WebSocket 模式 + zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WebSocket 模式 + type: boolean + required: true + default: false + - name: enable-stream-reply + label: + en_US: Enable Stream Reply Mode + zh_Hans: 启用流式回复模式 + zh_Hant: 啟用串流回覆模式 + description: + en_US: If enabled, the bot will use streaming mode to reply messages (C2C only) + zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊) + zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊) + type: boolean + required: true + default: false + - name: webhook_url + label: + en_US: Webhook Callback URL + zh_Hans: Webhook 回调地址 + zh_Hant: Webhook 回調地址 + description: + en_US: Copy this URL and paste it into your QQ Official API webhook configuration + zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中 + zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中 + type: webhook-url + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true execution: python: path: ./qqofficial.py diff --git a/src/langbot/pkg/platform/sources/web_page_bot.yaml b/src/langbot/pkg/platform/sources/web_page_bot.yaml new file mode 100644 index 00000000..d4f4d9a5 --- /dev/null +++ b/src/langbot/pkg/platform/sources/web_page_bot.yaml @@ -0,0 +1,177 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: web_page_bot + label: + en_US: "Page Bot" + zh_Hans: "页面机器人" + zh_Hant: "頁面機器人" + ja_JP: "ページボット" + th_TH: "บอทหน้าเว็บ" + vi_VN: "Bot trang web" + es_ES: "Bot de página" + description: + en_US: "Embed a chat widget on any website with a simple script tag" + zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站" + zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站" + ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます" + th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์" + vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script" + es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script" + icon: "webpage.webp" +spec: + categories: + - popular + config: + - name: title + label: + en_US: Widget Title + zh_Hans: 组件标题 + zh_Hant: 元件標題 + ja_JP: ウィジェットタイトル + th_TH: ชื่อวิดเจ็ต + vi_VN: Tiêu đề widget + es_ES: Título del widget + description: + en_US: The title displayed in the chat widget header + zh_Hans: 显示在聊天组件顶部的标题 + zh_Hant: 顯示在聊天元件頂部的標題 + ja_JP: チャットウィジェットのヘッダーに表示されるタイトル + th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท + vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện + es_ES: El título que se muestra en el encabezado del widget de chat + type: string + required: false + default: "LangBot" + - name: bubble_icon + label: + en_US: Bubble Icon + zh_Hans: 气泡图标 + zh_Hant: 氣泡圖示 + ja_JP: バブルアイコン + th_TH: ไอคอนบับเบิล + vi_VN: Biểu tượng bong bóng + es_ES: Icono de burbuja + ru_RU: Иконка пузырька + description: + en_US: "Icon displayed on the floating chat bubble" + zh_Hans: "浮动聊天气泡上显示的图标" + type: select + required: false + default: "logo" + options: + - name: "logo" + label: + en_US: "LangBot Logo" + zh_Hans: "LangBot 图标" + - name: "chat" + label: + en_US: "Chat Bubble" + zh_Hans: "聊天气泡" + - name: "robot" + label: + en_US: "Robot" + zh_Hans: "机器人" + - name: "headset" + label: + en_US: "Headset" + zh_Hans: "客服耳机" + - name: "sparkle" + label: + en_US: "Sparkle" + zh_Hans: "星光" + - name: "message" + label: + en_US: "Message" + zh_Hans: "消息" + - name: language + label: + en_US: Widget Language + zh_Hans: 组件语言 + zh_Hant: 元件語言 + ja_JP: ウィジェット言語 + th_TH: ภาษาวิดเจ็ต + vi_VN: Ngôn ngữ widget + es_ES: Idioma del widget + ru_RU: Язык виджета + description: + en_US: "Display language of the chat widget" + zh_Hans: "聊天组件的显示语言" + zh_Hant: "聊天元件的顯示語言" + ja_JP: "チャットウィジェットの表示言語" + th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท" + vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện" + es_ES: "Idioma de visualización del widget de chat" + ru_RU: "Язык отображения виджета чата" + type: select + required: false + default: "en_US" + options: + - name: "en_US" + label: + en_US: "English" + - name: "zh_Hans" + label: + en_US: "简体中文" + - name: "zh_Hant" + label: + en_US: "繁體中文" + - name: "ja_JP" + label: + en_US: "日本語" + - name: "es_ES" + label: + en_US: "Español" + - name: "ru_RU" + label: + en_US: "Русский" + - name: "th_TH" + label: + en_US: "ไทย" + - name: "vi_VN" + label: + en_US: "Tiếng Việt" + - name: embed_code + label: + en_US: Embed Code + zh_Hans: 嵌入代码 + zh_Hant: 嵌入代碼 + ja_JP: 埋め込みコード + th_TH: โค้ดฝังตัว + vi_VN: Mã nhúng + es_ES: Código de incrustación + description: + en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving." + zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。" + zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。" + ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。" + th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก" + vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu." + es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar." + type: embed-code + required: false + default: "" + - name: turnstile_site_key + label: + en_US: Turnstile Site Key + zh_Hans: Turnstile 站点密钥 + description: + en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable." + zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台(Turnstile > 添加站点)中获取。留空则不启用。" + type: string + required: false + default: "" + - name: turnstile_secret_key + label: + en_US: Turnstile Secret Key + zh_Hans: Turnstile 服务端密钥 + description: + en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set." + zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。" + type: string + required: false + default: "" +execution: + python: + path: "web_page_bot_adapter.py" + attr: "WebPageBotAdapter" diff --git a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py new file mode 100644 index 00000000..9b892a10 --- /dev/null +++ b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py @@ -0,0 +1,97 @@ +"""Web Page Bot adapter - lightweight adapter for embeddable chat widget""" + +import typing + +import pydantic + +import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger + + +class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): + """Lightweight adapter for the embeddable page bot. + + This adapter does not handle messages itself. The actual WebSocket + communication is handled by the singleton websocket_proxy_bot. + This adapter stores event listeners so that RuntimeBot can register + its handlers, which are then called by the websocket adapter when + a message arrives for this bot's pipeline. + + Message sending/replying is delegated to the websocket_proxy_bot's + adapter so that replies are actually delivered over the WebSocket + connection while the dashboard correctly shows this adapter's name. + """ + + listeners: dict = pydantic.Field(default_factory=dict, exclude=True) + _ws_adapter: typing.Any = None + + class Config: + arbitrary_types_allowed = True + # Allow private attributes + underscore_attrs_are_private = True + + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): + super().__init__(config=config, logger=logger, **kwargs) + + def set_ws_adapter(self, ws_adapter) -> None: + """Set the underlying WebSocket adapter used for actual message delivery.""" + object.__setattr__(self, '_ws_adapter', ws_adapter) + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain, + ) -> dict: + if self._ws_adapter is not None: + return await self._ws_adapter.send_message(target_type, target_id, message) + return {} + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ) -> dict: + if self._ws_adapter is not None: + return await self._ws_adapter.reply_message(message_source, message, quote_origin) + return {} + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ) -> dict: + if self._ws_adapter is not None: + return await self._ws_adapter.reply_message_chunk( + message_source, bot_message, message, quote_origin, is_final + ) + return {} + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable, + ): + self.listeners[event_type] = func + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + func: typing.Callable, + ): + self.listeners.pop(event_type, None) + + async def is_muted(self, group_id: int) -> bool: + return False + + async def run_async(self): + pass + + async def kill(self): + pass diff --git a/src/langbot/pkg/platform/sources/webpage.webp b/src/langbot/pkg/platform/sources/webpage.webp new file mode 100644 index 00000000..09226adc Binary files /dev/null and b/src/langbot/pkg/platform/sources/webpage.webp differ diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 01da9f10..9ffcf04a 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) async def _process_image_components(self, message_chain_obj: list): """ - 处理消息链中的图片组件,将path转换为base64 + 处理消息链中的图片和文件组件,将path转换为base64 Args: message_chain_obj: 消息链对象列表 @@ -322,16 +322,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) storage_mgr = self.ap.storage_mgr for component in message_chain_obj: - if component.get('type') == 'Image' and component.get('path'): - try: - # 从storage读取文件 - file_content = await storage_mgr.storage_provider.load(component['path']) + comp_type = component.get('type', '') + comp_path = component.get('path', '') - # 转换为base64 + if not comp_path: + continue + + if comp_type == 'Image': + try: + file_content = await storage_mgr.storage_provider.load(comp_path) base64_str = base64.b64encode(file_content).decode('utf-8') - # 添加data URI前缀(根据文件扩展名判断MIME类型) - file_key = component['path'] + file_key = comp_path if file_key.lower().endswith(('.jpg', '.jpeg')): mime_type = 'image/jpeg' elif file_key.lower().endswith('.png'): @@ -341,19 +343,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) elif file_key.lower().endswith('.webp'): mime_type = 'image/webp' else: - mime_type = 'image/png' # 默认 + mime_type = 'image/png' component['base64'] = f'data:{mime_type};base64,{base64_str}' - await storage_mgr.storage_provider.delete(component['path']) + await storage_mgr.storage_provider.delete(comp_path) component['path'] = '' - # 保留path字段用于后端处理,前端使用base64显示 except Exception as e: - await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}') + await self.logger.error(f'Failed to load image file {comp_path}: {e}') async def handle_websocket_message( self, connection: WebSocketConnection, message_data: dict, + owner_bot=None, ): """ 处理从WebSocket接收的消息 @@ -366,6 +368,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) message_data: 消息数据,包含: - message: 消息链 - stream: 是否启用流式输出 (可选,默认True) + owner_bot: Optional RuntimeBot that owns this pipeline (e.g. a web_page_bot). + When provided, its identity is used for logging and session tracking. """ pipeline_uuid = connection.pipeline_uuid session_type = connection.session_type @@ -435,12 +439,26 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) sender=sender, message_chain=message_chain, time=datetime.now().timestamp() ) - # 设置流水线UUID + # 设置流水线UUID (proxy bot always needs it for reply_message routing) self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid + if owner_bot is not None: + owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid - # 异步触发事件处理(不等待结果) - if event.__class__ in self.listeners: - asyncio.create_task(self.listeners[event.__class__](event, self)) + # 异步触发事件处理 + # Use owner_bot's listeners if available, otherwise fall back to proxy bot + listeners = ( + owner_bot.adapter.listeners + if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners) + else self.listeners + ) + # Pass owner_bot's adapter so that downstream logging / dashboard + # attributes the message to the correct bot adapter name. + # Wire the ws adapter into the owner so replies are actually delivered. + if owner_bot and hasattr(owner_bot.adapter, 'set_ws_adapter'): + owner_bot.adapter.set_ws_adapter(self) + callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self + if event.__class__ in listeners: + asyncio.create_task(listeners[event.__class__](event, callback_adapter)) def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]: """获取消息历史""" diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 69afde77..964355ee 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -414,6 +414,17 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]: return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath) + async def handle_page_api( + self, + plugin_author: str, + plugin_name: str, + page_id: str, + endpoint: str, + method: str, + body: Any = None, + ) -> dict[str, Any]: + return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body) + async def get_debug_info(self) -> dict[str, Any]: """Get debug information including debug key and WS URL""" if not self.is_enable_plugin: diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 8236126b..60922003 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -367,6 +367,22 @@ class RuntimeConnectionHandler(handler.Handler): owner_type = data['owner_type'] owner = data['owner'] value = base64.b64decode(data['value_base64']) + max_value_bytes = ( + self.ap.instance_config.data.get('plugin', {}) + .get('binary_storage', {}) + .get( + 'max_value_bytes', + 10 * 1024 * 1024, + ) + ) + try: + max_value_bytes = int(max_value_bytes) + except (TypeError, ValueError): + max_value_bytes = 10 * 1024 * 1024 + if max_value_bytes >= 0 and len(value) > max_value_bytes: + return handler.ActionResponse.error( + message=f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)', + ) result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_bstorage.BinaryStorage) @@ -939,6 +955,11 @@ class RuntimeConnectionHandler(handler.Handler): timeout=20, ) asset_file_key = result['file_file_key'] + if not asset_file_key: + return { + 'asset_base64': '', + 'mime_type': '', + } mime_type = result['mime_type'] asset_bytes = await self.read_local_file(asset_file_key) await self.delete_local_file(asset_file_key) @@ -947,6 +968,30 @@ class RuntimeConnectionHandler(handler.Handler): 'mime_type': mime_type, } + async def handle_page_api( + self, + plugin_author: str, + plugin_name: str, + page_id: str, + endpoint: str, + method: str, + body: Any = None, + ) -> dict[str, Any]: + """Forward a page API call to the plugin via runtime.""" + result = await self.call_action( + LangBotToRuntimeAction.PAGE_API, + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'page_id': page_id, + 'endpoint': endpoint, + 'method': method, + 'body': body, + }, + timeout=30, + ) + return result + async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None: """Cleanup plugin settings and binary storage""" # Delete plugin settings diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qiniu.svg b/src/langbot/pkg/provider/modelmgr/requesters/qiniu.svg new file mode 100644 index 00000000..8057b68d --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/qiniu.svg @@ -0,0 +1 @@ +Qiniu \ No newline at end of file diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py new file mode 100644 index 00000000..0c7a940f --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import typing + +import openai + +from . import chatcmpl + + +class QiniuChatCompletions(chatcmpl.OpenAIChatCompletions): + """七牛云 ChatCompletion API 请求器""" + + client: openai.AsyncClient + + default_config: dict[str, typing.Any] = { + 'base_url': 'https://api.qnaigc.com/v1', + 'timeout': 120, + } + + async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: + try: + result = await super().scan_models(api_key) + except Exception: + return self._qiniu_fallback_scan_result() + models = result.get('models') or [] + if not models: + return self._qiniu_fallback_scan_result() + return result + + def _qiniu_fallback_scan_result(self) -> dict[str, typing.Any]: + mid = 'deepseek-v3' + return { + 'models': [ + { + 'id': mid, + 'name': mid, + 'type': 'llm', + 'abilities': [], + } + ], + 'debug': { + 'request': {'method': 'GET', 'url': '', 'headers': {}}, + 'response': {}, + }, + } diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.yaml b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.yaml new file mode 100644 index 00000000..5655d743 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: qiniu-chat-completions + label: + en_US: Qiniu + zh_Hans: 七牛云 + icon: qiniu.svg +spec: + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: https://api.qnaigc.com/v1 + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 + support_type: + - llm + provider_category: maas +execution: + python: + path: ./qiniuchatcmpl.py + attr: QiniuChatCompletions diff --git a/src/langbot/pkg/provider/runners/n8nsvapi.py b/src/langbot/pkg/provider/runners/n8nsvapi.py index f8238eb6..543fd7ef 100644 --- a/src/langbot/pkg/provider/runners/n8nsvapi.py +++ b/src/langbot/pkg/provider/runners/n8nsvapi.py @@ -187,6 +187,12 @@ class N8nServiceAPIRunner(runner.RequestRunner): if not query.session.using_conversation.uuid: query.session.using_conversation.uuid = str(uuid.uuid4()) + # Keep query variables in sync with the generated/new conversation id. + # query.variables is later merged into payload and would otherwise + # overwrite the generated conversation_id with the stale preprocessor + # value (usually None for a new conversation). + query.variables['conversation_id'] = query.session.using_conversation.uuid + # 预处理用户消息 plain_text = await self._preprocess_user_message(query) diff --git a/src/langbot/pkg/rag/knowledge/kbmgr.py b/src/langbot/pkg/rag/knowledge/kbmgr.py index 8fadc341..cd37994c 100644 --- a/src/langbot/pkg/rag/knowledge/kbmgr.py +++ b/src/langbot/pkg/rag/knowledge/kbmgr.py @@ -148,52 +148,60 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface): supported_extensions = {'txt', 'pdf', 'docx', 'md', 'html'} stored_file_tasks = [] - # use utf-8 encoding - with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref: - for file_info in zip_ref.filelist: - # skip directories and hidden files - if file_info.is_dir() or file_info.filename.startswith('.'): - continue - - _, file_ext = os.path.splitext(file_info.filename) - file_extension = file_ext.lstrip('.').lower() - if file_extension not in supported_extensions: - self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}') - continue - - try: - file_content = zip_ref.read(file_info.filename) - - base_name = file_info.filename.replace('/', '_').replace('\\', '_') - file_stem, file_ext = os.path.splitext(base_name) - extension = file_ext.lstrip('.') - - if file_stem.startswith('__MACOSX'): + try: + # use utf-8 encoding + with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref: + for file_info in zip_ref.filelist: + # skip directories and hidden files + if file_info.is_dir() or file_info.filename.startswith('.'): continue - extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension - # save file to storage + _, file_ext = os.path.splitext(file_info.filename) + file_extension = file_ext.lstrip('.').lower() + if file_extension not in supported_extensions: + self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}') + continue - await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content) + try: + file_content = zip_ref.read(file_info.filename) - task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id) - stored_file_tasks.append(task_id) + base_name = file_info.filename.replace('/', '_').replace('\\', '_') + file_stem, file_ext = os.path.splitext(base_name) + extension = file_ext.lstrip('.') - self.ap.logger.info( - f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}' - ) + if file_stem.startswith('__MACOSX'): + continue - except Exception as e: - self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}') - continue + extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension + # save file to storage - if not stored_file_tasks: - raise Exception('No supported files found in ZIP archive') + await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content) - self.ap.logger.info(f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files') - await self.ap.storage_mgr.storage_provider.delete(zip_file_id) + task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id) + stored_file_tasks.append(task_id) - return stored_file_tasks[0] if stored_file_tasks else '' + self.ap.logger.info( + f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}' + ) + + except Exception as e: + self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}') + continue + + if not stored_file_tasks: + raise Exception('No supported files found in ZIP archive') + + self.ap.logger.info( + f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files' + ) + return stored_file_tasks[0] if stored_file_tasks else '' + finally: + try: + await self.ap.storage_mgr.storage_provider.delete(zip_file_id) + except FileNotFoundError: + pass + except Exception as e: + self.ap.logger.warning(f'Failed to cleanup ZIP file {zip_file_id}: {e}') async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]: # Merge stored retrieval_settings with per-request overrides diff --git a/src/langbot/pkg/storage/providers/localstorage.py b/src/langbot/pkg/storage/providers/localstorage.py index 592c0be2..43566fc7 100644 --- a/src/langbot/pkg/storage/providers/localstorage.py +++ b/src/langbot/pkg/storage/providers/localstorage.py @@ -12,6 +12,23 @@ from .. import provider LOCAL_STORAGE_PATH = os.path.join('data', 'storage') +def _safe_resolve(base: str, key: str) -> str: + """Resolve *key* under *base* and ensure the result stays inside *base*. + + Raises ``ValueError`` if the resolved path escapes the storage root + (e.g. via absolute paths, ``..`` components, or symlinks). + """ + # os.path.realpath resolves symlinks and normalises the path. + resolved = os.path.realpath(os.path.join(base, key)) + canonical_base = os.path.realpath(base) + # The resolved path must be *strictly* inside the base directory (or equal + # to it only for directory operations). We append os.sep so that a base of + # "/data/storage" does not match "/data/storage_evil". + if not (resolved == canonical_base or resolved.startswith(canonical_base + os.sep)): + raise ValueError(f'Path traversal detected: key {key!r} resolves outside storage root') + return resolved + + class LocalStorageProvider(provider.StorageProvider): def __init__(self, ap: app.Application): super().__init__(ap) @@ -23,40 +40,47 @@ class LocalStorageProvider(provider.StorageProvider): key: str, value: bytes, ): - if not os.path.exists(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))): - os.makedirs(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))) - async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f: + resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) + parent = os.path.dirname(resolved) + if not os.path.exists(parent): + os.makedirs(parent) + async with aiofiles.open(resolved, 'wb') as f: await f.write(value) async def load( self, key: str, ) -> bytes: - async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'rb') as f: + resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) + async with aiofiles.open(resolved, 'rb') as f: return await f.read() async def exists( self, key: str, ) -> bool: - return os.path.exists(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) + resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) + return os.path.exists(resolved) async def delete( self, key: str, ): - os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) + resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) + os.remove(resolved) async def size( self, key: str, ) -> int: - return os.path.getsize(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) + resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) + return os.path.getsize(resolved) async def delete_dir_recursive( self, dir_path: str, ): + resolved = _safe_resolve(LOCAL_STORAGE_PATH, dir_path) # 直接删除整个目录 - if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)): - shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path)) + if os.path.exists(resolved): + shutil.rmtree(resolved) diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index c153df82..4363dd2f 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -25,6 +25,9 @@ system: max_bots: -1 max_pipelines: -1 max_extensions: -1 + task_retention: + # Keep at most this many completed async task records in memory + completed_limit: 200 jwt: expire: 604800 secret: '' @@ -68,6 +71,15 @@ vdb: password: 'postgres' storage: use: local + cleanup: + # Enable periodic cleanup of local/S3 uploaded files and old log files + enabled: true + # Cleanup check interval in hours + check_interval_hours: 1 + # Root-level uploaded files older than this will be deleted + uploaded_file_retention_days: 7 + # LangBot log files older than this many days will be deleted + log_retention_days: 3 s3: endpoint_url: '' access_key_id: '' @@ -79,6 +91,9 @@ plugin: runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' enable_marketplace: true display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' + binary_storage: + # Max bytes for a single plugin binary storage value + max_value_bytes: 10485760 monitoring: auto_cleanup: # Enable automatic cleanup of expired monitoring records @@ -87,6 +102,8 @@ monitoring: retention_days: 30 # Cleanup check interval in hours check_interval_hours: 1 + # Number of expired rows to delete per table batch + delete_batch_size: 1000 box: profile: 'default' image: '' # Custom sandbox container image. Leave empty to use the profile default (python:3.11-slim). diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index d90d31ed..8e06bce5 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -38,7 +38,8 @@ }, "ai": { "runner": { - "runner": "local-agent" + "runner": "local-agent", + "expire-time": 0 }, "local-agent": { "model": { diff --git a/src/langbot/templates/embed/logo.webp b/src/langbot/templates/embed/logo.webp new file mode 100644 index 00000000..09226adc Binary files /dev/null and b/src/langbot/templates/embed/logo.webp differ diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js new file mode 100644 index 00000000..72be5eeb --- /dev/null +++ b/src/langbot/templates/embed/widget.js @@ -0,0 +1,1308 @@ +(function () { + "use strict"; + + // Prevent duplicate initialization + if (document.getElementById("langbot-widget-root")) return; + + // Read config from script tag data attributes + var scriptEl = document.currentScript; + var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null; + + // ========== i18n ========== + var I18N = { + en_US: { + welcomeMessage: "Send a message to start the conversation", + inputPlaceholder: "Type a message...", + openChat: "Open chat", + resetConversation: "Reset conversation", + minimize: "Minimize", + uploadFile: "Upload file", + send: "Send", + failedToConnect: "Failed to connect", + imageTooLarge: "Image must be under 5MB", + onlyImages: "Only image files are supported", + botVerificationFailed: "Bot verification failed", + botVerificationNetworkError: "Bot verification network error", + botVerificationError: "Bot verification error", + poweredBy: + 'Powered by LangBot', + }, + zh_Hans: { + welcomeMessage: "发送消息开始对话", + inputPlaceholder: "输入消息...", + openChat: "打开聊天", + resetConversation: "重置对话", + minimize: "最小化", + uploadFile: "上传文件", + send: "发送", + failedToConnect: "连接失败", + imageTooLarge: "图片大小不能超过 5MB", + onlyImages: "仅支持图片文件", + botVerificationFailed: "机器人验证失败", + botVerificationNetworkError: "机器人验证网络错误", + botVerificationError: "机器人验证错误", + poweredBy: + '由 LangBot 提供支持', + }, + zh_Hant: { + welcomeMessage: "傳送訊息開始對話", + inputPlaceholder: "輸入訊息...", + openChat: "開啟聊天", + resetConversation: "重置對話", + minimize: "最小化", + uploadFile: "上傳檔案", + send: "傳送", + failedToConnect: "連線失敗", + imageTooLarge: "圖片大小不能超過 5MB", + onlyImages: "僅支援圖片檔案", + botVerificationFailed: "機器人驗證失敗", + botVerificationNetworkError: "機器人驗證網路錯誤", + botVerificationError: "機器人驗證錯誤", + poweredBy: + '由 LangBot 提供支持', + }, + ja_JP: { + welcomeMessage: "メッセージを送信して会話を始めましょう", + inputPlaceholder: "メッセージを入力...", + openChat: "チャットを開く", + resetConversation: "会話をリセット", + minimize: "最小化", + uploadFile: "ファイルをアップロード", + send: "送信", + failedToConnect: "接続に失敗しました", + imageTooLarge: "画像は5MB以下にしてください", + onlyImages: "画像ファイルのみ対応しています", + botVerificationFailed: "ボット認証に失敗しました", + botVerificationNetworkError: "ボット認証のネットワークエラー", + botVerificationError: "ボット認証エラー", + poweredBy: + 'LangBot で動作', + }, + es_ES: { + welcomeMessage: "Envía un mensaje para iniciar la conversación", + inputPlaceholder: "Escribe un mensaje...", + openChat: "Abrir chat", + resetConversation: "Reiniciar conversación", + minimize: "Minimizar", + uploadFile: "Subir archivo", + send: "Enviar", + failedToConnect: "Error de conexión", + imageTooLarge: "La imagen debe ser menor a 5MB", + onlyImages: "Solo se admiten archivos de imagen", + botVerificationFailed: "Verificación del bot fallida", + botVerificationNetworkError: "Error de red en verificación del bot", + botVerificationError: "Error de verificación del bot", + poweredBy: + 'Desarrollado con LangBot', + }, + ru_RU: { + welcomeMessage: "Отправьте сообщение, чтобы начать разговор", + inputPlaceholder: "Введите сообщение...", + openChat: "Открыть чат", + resetConversation: "Сбросить разговор", + minimize: "Свернуть", + uploadFile: "Загрузить файл", + send: "Отправить", + failedToConnect: "Ошибка подключения", + imageTooLarge: "Изображение должно быть менее 5МБ", + onlyImages: "Поддерживаются только изображения", + botVerificationFailed: "Проверка бота не пройдена", + botVerificationNetworkError: "Ошибка сети при проверке бота", + botVerificationError: "Ошибка проверки бота", + poweredBy: + 'Работает на LangBot', + }, + th_TH: { + welcomeMessage: "ส่งข้อความเพื่อเริ่มการสนทนา", + inputPlaceholder: "พิมพ์ข้อความ...", + openChat: "เปิดแชท", + resetConversation: "รีเซ็ตการสนทนา", + minimize: "ย่อ", + uploadFile: "อัปโหลดไฟล์", + send: "ส่ง", + failedToConnect: "เชื่อมต่อไม่สำเร็จ", + imageTooLarge: "รูปภาพต้องมีขนาดไม่เกิน 5MB", + onlyImages: "รองรับเฉพาะไฟล์รูปภาพเท่านั้น", + botVerificationFailed: "การยืนยันบอทล้มเหลว", + botVerificationNetworkError: "เกิดข้อผิดพลาดเครือข่ายในการยืนยันบอท", + botVerificationError: "เกิดข้อผิดพลาดในการยืนยันบอท", + poweredBy: + 'ขับเคลื่อนโดย LangBot', + }, + vi_VN: { + welcomeMessage: "Gửi tin nhắn để bắt đầu cuộc trò chuyện", + inputPlaceholder: "Nhập tin nhắn...", + openChat: "Mở trò chuyện", + resetConversation: "Đặt lại cuộc trò chuyện", + minimize: "Thu nhỏ", + uploadFile: "Tải lên tệp", + send: "Gửi", + failedToConnect: "Kết nối thất bại", + imageTooLarge: "Hình ảnh phải nhỏ hơn 5MB", + onlyImages: "Chỉ hỗ trợ tệp hình ảnh", + botVerificationFailed: "Xác minh bot thất bại", + botVerificationNetworkError: "Lỗi mạng khi xác minh bot", + botVerificationError: "Lỗi xác minh bot", + poweredBy: + 'Được hỗ trợ bởi LangBot', + }, + }; + + var _locale = "__LANGBOT_LOCALE__"; + var _strings = I18N[_locale] || I18N.en_US; + function t(key) { + return _strings[key] || I18N.en_US[key] || key; + } + + // ========== Configuration (injected by backend) ========== + var CONFIG = { + botUuid: "__LANGBOT_BOT_UUID__", + baseUrl: "__LANGBOT_BASE_URL__", + sessionType: "person", + title: scriptTitle || "LangBot", + logoUrl: "__LANGBOT_BASE_URL__" + "/api/v1/embed/logo", + maxReconnectAttempts: 5, + reconnectDelay: 3000, + heartbeatInterval: 30000, + turnstileSiteKey: "__LANGBOT_TURNSTILE_SITE_KEY__", + bubbleIcon: "__LANGBOT_BUBBLE_ICON__", + }; + + // ========== Styles ========== + var STYLES = + '\ + :host { all: initial; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #1a1a1a; }\ + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\ + .lb-bubble { position: fixed; bottom: 20px; right: 20px; width: 56px; height: 56px; border-radius: 50%; background: #2563eb; color: #fff; border: none; cursor: pointer; box-shadow: 0 4px 12px rgba(37,99,235,0.4); display: flex; align-items: center; justify-content: center; z-index: 2147483646; transition: transform 0.2s ease, box-shadow 0.2s ease; overflow: hidden; }\ + .lb-bubble:hover { transform: scale(1.08); box-shadow: 0 6px 20px rgba(37,99,235,0.5); }\ + .lb-bubble svg { width: 28px; height: 28px; fill: currentColor; }\ + .lb-chat-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }\ + .lb-bubble .lb-close-icon { display: none; }\ + .lb-bubble.lb-open .lb-chat-icon { display: none; }\ + .lb-bubble.lb-open .lb-close-icon { display: block; }\ + .lb-panel { position: fixed; bottom: 88px; right: 20px; width: 400px; height: 600px; max-height: calc(100vh - 108px); background: #fff; border-radius: 16px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); display: flex; flex-direction: column; z-index: 2147483646; overflow: hidden; opacity: 0; transform: translateY(16px) scale(0.95); pointer-events: none; transition: opacity 0.25s ease, transform 0.25s ease; }\ + .lb-panel.lb-visible { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }\ + .lb-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: #2563eb; color: #fff; flex-shrink: 0; }\ + .lb-header-left { display: flex; align-items: center; gap: 10px; }\ + .lb-header-logo { width: 28px; height: 28px; border-radius: 6px; object-fit: cover; }\ + .lb-header-title { font-size: 16px; font-weight: 600; }\ + .lb-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #fbbf24; flex-shrink: 0; }\ + .lb-status-dot.lb-connected { background: #34d399; }\ + .lb-header-actions { display: flex; align-items: center; gap: 8px; }\ + .lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\ + .lb-header-btn:hover { opacity: 1; }\ + .lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\ + .lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\ + .lb-messages::-webkit-scrollbar { width: 6px; }\ + .lb-messages::-webkit-scrollbar-track { background: transparent; }\ + .lb-messages::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }\ + .lb-msg { display: flex; gap: 10px; animation: lb-fade-in 0.2s ease; max-width: 100%; }\ + @keyframes lb-fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }\ + .lb-msg-user { flex-direction: row-reverse; }\ + .lb-msg-assistant { flex-direction: row; }\ + .lb-avatar { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; overflow: hidden; }\ + .lb-avatar svg { width: 18px; height: 18px; fill: #fff; }\ + .lb-avatar img { width: 100%; height: 100%; object-fit: cover; }\ + .lb-avatar-user { background: #6366f1; color: #fff; }\ + .lb-avatar-bot { background: #5b9bd5; color: #fff; }\ + .lb-msg-body { display: flex; flex-direction: column; max-width: calc(100% - 42px); min-width: 0; }\ + .lb-msg-user .lb-msg-body { align-items: flex-end; }\ + .lb-msg-assistant .lb-msg-body { align-items: flex-start; }\ + .lb-msg-bubble { padding: 10px 14px; border-radius: 12px; word-break: break-word; white-space: pre-wrap; font-size: 14px; line-height: 1.6; max-width: 100%; }\ + .lb-msg-user .lb-msg-bubble { background: #2563eb; color: #fff; border-bottom-right-radius: 4px; }\ + .lb-msg-assistant .lb-msg-bubble { background: #f3f4f6; color: #1a1a1a; border-bottom-left-radius: 4px; }\ + .lb-msg-bubble code { font-family: "SF Mono", Monaco, Consolas, monospace; font-size: 0.9em; background: rgba(0,0,0,0.06); padding: 1px 4px; border-radius: 3px; }\ + .lb-msg-user .lb-msg-bubble code { background: rgba(255,255,255,0.2); }\ + .lb-msg-bubble pre { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; font-size: 13px; }\ + .lb-msg-bubble pre code { background: none; padding: 0; color: inherit; }\ + .lb-msg-bubble a { color: #2563eb; text-decoration: underline; }\ + .lb-msg-user .lb-msg-bubble a { color: #bfdbfe; }\ + .lb-msg-bubble h3 { font-size: 15px; font-weight: 600; margin: 8px 0 4px; }\ + .lb-msg-bubble h4 { font-size: 14px; font-weight: 600; margin: 6px 0 3px; }\ + .lb-msg-bubble blockquote { border-left: 3px solid #d1d5db; padding-left: 10px; margin: 6px 0; color: #6b7280; }\ + .lb-msg-bubble ul, .lb-msg-bubble ol { padding-left: 20px; margin: 4px 0; }\ + .lb-msg-bubble li { margin: 2px 0; }\ + .lb-msg-bubble table { border-collapse: collapse; margin: 8px 0; font-size: 13px; width: 100%; }\ + .lb-msg-bubble th, .lb-msg-bubble td { border: 1px solid #d1d5db; padding: 4px 8px; text-align: left; }\ + .lb-msg-bubble th { background: #f3f4f6; font-weight: 600; }\ + .lb-msg-bubble hr { border: none; border-top: 1px solid #d1d5db; margin: 8px 0; }\ + .lb-msg-bubble del { text-decoration: line-through; opacity: 0.7; }\ + .lb-msg-bubble img { max-width: 100%; border-radius: 8px; margin: 4px 0; cursor: pointer; }\ + .lb-msg-actions { display: flex; align-items: center; gap: 4px; margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(0,0,0,0.06); }\ + .lb-msg-actions-hidden { display: none; }\ + .lb-act-btn { background: none; border: 1px solid #e5e7eb; color: #9ca3af; cursor: pointer; padding: 3px 6px; border-radius: 6px; display: flex; align-items: center; gap: 3px; font-size: 11px; transition: all 0.15s; }\ + .lb-act-btn:hover { background: #f3f4f6; color: #6b7280; border-color: #d1d5db; }\ + .lb-act-btn.lb-active { color: #2563eb; border-color: #93c5fd; background: #eff6ff; }\ + .lb-act-btn svg { width: 14px; height: 14px; fill: currentColor; }\ + .lb-img-upload-btn { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: color 0.15s; }\ + .lb-img-upload-btn:hover { color: #6b7280; }\ + .lb-img-upload-btn svg { width: 20px; height: 20px; fill: currentColor; }\ + .lb-img-preview { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-top: 1px solid #e5e7eb; background: #fafafa; flex-shrink: 0; }\ + .lb-img-preview img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; }\ + .lb-img-preview-remove { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 18px; padding: 0 4px; }\ + .lb-img-preview-remove:hover { color: #ef4444; }\ + .lb-msg-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; padding: 0 2px; }\ + .lb-msg-time { font-size: 11px; color: #9ca3af; }\ + .lb-footer { text-align: right; padding: 6px 12px; font-size: 9px; color: #d1d5db; font-style: italic; flex-shrink: 0; }\ + .lb-footer a { color: #d1d5db; text-decoration: none; }\ + .lb-footer a:hover { color: #9ca3af; }\ + .lb-typing { display: inline-flex; gap: 4px; padding: 10px 14px; background: #f3f4f6; border-radius: 12px; border-bottom-left-radius: 4px; margin-left: 42px; }\ + .lb-typing span { width: 6px; height: 6px; background: #9ca3af; border-radius: 50%; animation: lb-bounce 1.4s infinite both; }\ + .lb-typing span:nth-child(2) { animation-delay: 0.16s; }\ + .lb-typing span:nth-child(3) { animation-delay: 0.32s; }\ + @keyframes lb-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }\ + .lb-welcome { text-align: center; color: #9ca3af; padding: 40px 20px; font-size: 14px; }\ + .lb-welcome-logo { width: 48px; height: 48px; border-radius: 12px; margin: 0 auto 12px; }\ + .lb-input-area { display: flex; align-items: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #e5e7eb; background: #fff; flex-shrink: 0; }\ + .lb-input { flex: 1; border: 1px solid #d1d5db; border-radius: 10px; padding: 10px 14px; font-size: 14px; font-family: inherit; line-height: 1.4; resize: none; outline: none; max-height: 120px; min-height: 40px; transition: border-color 0.15s; overflow-y: auto; }\ + .lb-input:focus { border-color: #2563eb; }\ + .lb-input::placeholder { color: #9ca3af; }\ + .lb-send-btn { width: 40px; height: 40px; border-radius: 10px; background: #2563eb; color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.15s, opacity 0.15s; }\ + .lb-send-btn:hover { background: #1d4ed8; }\ + .lb-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }\ + .lb-send-btn svg { width: 20px; height: 20px; fill: currentColor; }\ + .lb-error { text-align: center; color: #ef4444; padding: 8px; font-size: 12px; background: #fef2f2; border-radius: 8px; margin: 4px 16px; }\ + @media (max-width: 480px) {\ + .lb-panel { bottom: 0; right: 0; width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }\ + .lb-bubble { bottom: 16px; right: 16px; }\ + }\ + '; + + // ========== Bubble Icon Presets ========== + var BUBBLE_ICONS = { + logo: null, + chat: '', + robot: + '', + headset: + '', + sparkle: + '', + message: + '', + }; + + // ========== SVG Icons ========== + var ICON_CLOSE = + ''; + var ICON_SEND = + ''; + var ICON_RESET = + ''; + var ICON_USER = + ''; + var ICON_THUMB_UP = + ''; + var ICON_THUMB_DOWN = + ''; + var ICON_COPY = + ''; + var ICON_CHECK = + ''; + var ICON_IMAGE = + ''; + + // ========== State ========== + var state = { + isOpen: false, + isConnected: false, + ws: null, + connectionId: null, + reconnectAttempts: 0, + heartbeatTimer: null, + messages: [], + nextLocalId: 1, + isStreaming: false, + streamingMsgId: null, + historyLoaded: false, + pendingImage: null, + feedbackState: {}, + }; + + // ========== DOM References ========== + var els = {}; + + // ========== Utility Functions ========== + function esc(str) { + var div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } + + function formatTime(ts) { + try { + var d = new Date(ts); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } catch (e) { + return ""; + } + } + + function renderMarkdown(text) { + if (!text) return ""; + // Preserve code blocks first + var codeBlocks = []; + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, function (m, lang, code) { + codeBlocks.push("
" + esc(code.trim()) + "
"); + return "\x00CB" + (codeBlocks.length - 1) + "\x00"; + }); + var html = esc(text); + // Restore code blocks + html = html.replace(/\x00CB(\d+)\x00/g, function (m, i) { + return codeBlocks[parseInt(i)]; + }); + // Inline code + html = html.replace(/`([^`]+)`/g, "$1"); + // Headings + html = html.replace(/^### (.+)$/gm, "

$1

"); + html = html.replace(/^## (.+)$/gm, "

$1

"); + html = html.replace(/^# (.+)$/gm, "

$1

"); + // Horizontal rules + html = html.replace(/^---$/gm, "
"); + // Blockquotes + html = html.replace(/^> (.+)$/gm, "
$1
"); + // Tables + html = html.replace(/((?:\|.+\|\n?)+)/g, function (table) { + var rows = table.trim().split("\n"); + if (rows.length < 2) return table; + var out = ""; + for (var r = 0; r < rows.length; r++) { + if (r === 1 && /^\|[\s\-:|]+\|$/.test(rows[r])) continue; + var cells = rows[r].split("|").filter(function (c, i, a) { + return i > 0 && i < a.length - 1; + }); + var tag = r === 0 ? "th" : "td"; + out += + "" + + cells + .map(function (c) { + return "<" + tag + ">" + c.trim() + ""; + }) + .join("") + + ""; + } + return out + "
"; + }); + // Strikethrough + html = html.replace(/~~([^~]+)~~/g, "$1"); + // Bold + html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); + // Italic + html = html.replace(/\*([^*]+)\*/g, "$1"); + // Links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, p1, p2) { + if (/^https?:\/\//i.test(p2)) { + return ( + '' + + p1 + + "" + ); + } + return p1; + }); + // Unordered lists + html = html.replace(/((?:^[\-\*] .+(?:
)?)+)/gm, function (block) { + var items = block.split(/
|\\n/).filter(function (l) { + return /^[\-\*] /.test(l.trim()); + }); + return ( + "
    " + + items + .map(function (l) { + return "
  • " + l.replace(/^[\-\*] /, "") + "
  • "; + }) + .join("") + + "
" + ); + }); + // Ordered lists + html = html.replace(/((?:^\d+\. .+(?:
)?)+)/gm, function (block) { + var items = block.split(/
|\\n/).filter(function (l) { + return /^\d+\. /.test(l.trim()); + }); + return ( + "
    " + + items + .map(function (l) { + return "
  1. " + l.replace(/^\d+\. /, "") + "
  2. "; + }) + .join("") + + "
" + ); + }); + // Line breaks (but not inside block elements) + html = html.replace(/\n/g, "
"); + // Clean up excessive
around block elements + html = html.replace( + /
\s*(<(?:h[34]|pre|table|ul|ol|blockquote|hr))/g, + "$1", + ); + html = html.replace( + /(<\/(?:h[34]|pre|table|ul|ol|blockquote)>)\s*
/g, + "$1", + ); + return html; + } + + function scrollToBottom() { + if (els.messages) { + requestAnimationFrame(function () { + els.messages.scrollTop = els.messages.scrollHeight; + }); + } + } + + // ========== WebSocket Client ========== + function wsConnect() { + if ( + state.ws && + (state.ws.readyState === WebSocket.OPEN || + state.ws.readyState === WebSocket.CONNECTING) + ) { + return; + } + + var protocol = CONFIG.baseUrl.indexOf("https") === 0 ? "wss:" : "ws:"; + var host = CONFIG.baseUrl.replace(/^https?:\/\//, ""); + var url = + protocol + + "//" + + host + + "/api/v1/embed/" + + CONFIG.botUuid + + "/ws/connect?session_type=" + + CONFIG.sessionType; + + try { + state.ws = new WebSocket(url); + } catch (e) { + showError(t("failedToConnect")); + return; + } + + state.ws.onopen = function () { + state.reconnectAttempts = 0; + startHeartbeat(); + }; + + state.ws.onmessage = function (event) { + try { + var data = JSON.parse(event.data); + handleWsMessage(data); + } catch (e) { + // ignore parse errors + } + }; + + state.ws.onclose = function () { + state.isConnected = false; + updateStatusDot(); + updateSendBtn(); + stopHeartbeat(); + + if (state.reconnectAttempts < CONFIG.maxReconnectAttempts) { + state.reconnectAttempts++; + setTimeout(wsConnect, CONFIG.reconnectDelay * state.reconnectAttempts); + } + }; + + state.ws.onerror = function () { + state.isConnected = false; + updateStatusDot(); + updateSendBtn(); + }; + } + + function handleWsMessage(data) { + switch (data.type) { + case "connected": + state.isConnected = true; + state.connectionId = data.connection_id; + updateStatusDot(); + updateSendBtn(); + break; + + case "response": + if (data.session_type && data.session_type !== CONFIG.sessionType) + break; + if (data.data) handleAssistantMessage(data.data); + break; + + case "user_message": + if (data.session_type && data.session_type !== CONFIG.sessionType) + break; + // Only show messages from OTHER connections (own messages are added locally) + if (data.data && data.data.connection_id !== state.connectionId) { + addMessage(data.data); + } + break; + + case "pong": + break; + + case "error": + showError(data.message || "Unknown error"); + break; + } + } + + function handleAssistantMessage(msg) { + // Streaming: update existing message with same id + var existingIdx = -1; + for (var i = state.messages.length - 1; i >= 0; i--) { + if ( + state.messages[i].id === msg.id && + state.messages[i].role === "assistant" + ) { + existingIdx = i; + break; + } + } + + // Deduplicate: if any assistant message since last user message has the same content, skip + if (existingIdx < 0) { + var content = (msg.content || extractText(msg)) + .replace(/\s+/g, " ") + .trim(); + if (content) { + for (var j = state.messages.length - 1; j >= 0; j--) { + var prev = state.messages[j]; + if (prev.role === "user") break; + if (prev.role === "assistant") { + var prevContent = (prev.content || extractText(prev)) + .replace(/\s+/g, " ") + .trim(); + if ( + prevContent === content || + prevContent.indexOf(content) >= 0 || + content.indexOf(prevContent) >= 0 + ) + return; + } + } + } + } + + if (existingIdx >= 0) { + state.messages[existingIdx] = msg; + updateMessageEl(existingIdx, msg); + } else { + addMessage(msg); + } + + state.isStreaming = !msg.is_final; + state.streamingMsgId = msg.is_final ? null : msg.id; + + if (msg.is_final) { + removeTypingIndicator(); + } + + scrollToBottom(); + } + + function sendMessage(text, imageBase64) { + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return; + if (!text.trim() && !imageBase64) return; + + var chain = []; + if (text.trim()) chain.push({ type: "Plain", text: text.trim() }); + if (imageBase64) chain.push({ type: "Image", base64: imageBase64 }); + + var localMsg = { + id: "local_" + state.nextLocalId++, + role: "user", + content: text.trim(), + message_chain: chain, + timestamp: new Date().toISOString(), + is_final: true, + }; + addMessage(localMsg); + + state.ws.send( + JSON.stringify({ type: "message", message: chain, stream: true }), + ); + } + + function startHeartbeat() { + stopHeartbeat(); + state.heartbeatTimer = setInterval(function () { + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: "ping" })); + } + }, CONFIG.heartbeatInterval); + } + + function stopHeartbeat() { + if (state.heartbeatTimer) { + clearInterval(state.heartbeatTimer); + state.heartbeatTimer = null; + } + } + + function wsDisconnect() { + stopHeartbeat(); + state.reconnectAttempts = CONFIG.maxReconnectAttempts; + if (state.ws) { + if (state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: "disconnect" })); + } + state.ws.close(); + state.ws = null; + } + state.isConnected = false; + state.connectionId = null; + } + + // ========== Message History ========== + function loadHistory() { + if (state.historyLoaded) return; + state.historyLoaded = true; + + var url = + CONFIG.baseUrl + + "/api/v1/embed/" + + CONFIG.botUuid + + "/messages/" + + CONFIG.sessionType; + var headers = {}; + if (state.sessionToken) + headers["Authorization"] = "Bearer " + state.sessionToken; + fetch(url, { headers: headers }) + .then(function (res) { + return res.json(); + }) + .then(function (json) { + if (json.code === 0 && json.data && json.data.messages) { + var msgs = json.data.messages; + for (var i = 0; i < msgs.length; i++) { + addMessage(msgs[i], true); + } + scrollToBottom(); + } + }) + .catch(function () { + // silently ignore history load errors + }); + } + + function resetSession() { + var url = + CONFIG.baseUrl + + "/api/v1/embed/" + + CONFIG.botUuid + + "/reset/" + + CONFIG.sessionType; + var headers = {}; + if (state.sessionToken) + headers["Authorization"] = "Bearer " + state.sessionToken; + fetch(url, { method: "POST", headers: headers }) + .then(function () { + state.messages = []; + state.isStreaming = false; + state.streamingMsgId = null; + state.historyLoaded = true; + renderMessages(); + }) + .catch(function () { + // ignore + }); + } + + // ========== UI Rendering ========== + function addMessage(msg, silent) { + state.messages.push(msg); + var el = createMessageEl(msg); + if (els.welcome) { + els.welcome.style.display = "none"; + } + els.messages.appendChild(el); + if (!silent) scrollToBottom(); + } + + function createMessageEl(msg) { + var isUser = msg.role === "user"; + var div = document.createElement("div"); + div.className = "lb-msg " + (isUser ? "lb-msg-user" : "lb-msg-assistant"); + div.dataset.msgId = msg.id; + + // Avatar + var avatar = document.createElement("div"); + avatar.className = + "lb-avatar " + (isUser ? "lb-avatar-user" : "lb-avatar-bot"); + if (isUser) { + avatar.innerHTML = ICON_USER; + } else { + var logoImg = document.createElement("img"); + logoImg.src = CONFIG.logoUrl; + logoImg.alt = "Bot"; + avatar.appendChild(logoImg); + } + + // Message body (bubble + meta) + var body = document.createElement("div"); + body.className = "lb-msg-body"; + + var bubble = document.createElement("div"); + bubble.className = "lb-msg-bubble"; + var textContent = msg.content || extractText(msg); + bubble.innerHTML = isUser ? esc(textContent) : renderMarkdown(textContent); + + // Render images from message chain + var images = extractImages(msg); + for (var ii = 0; ii < images.length; ii++) { + var img = document.createElement("img"); + img.src = images[ii]; + img.alt = "Image"; + bubble.appendChild(img); + } + + // Meta row: time + var meta = document.createElement("div"); + meta.className = "lb-msg-meta"; + + var time = document.createElement("span"); + time.className = "lb-msg-time"; + time.textContent = formatTime(msg.timestamp); + meta.appendChild(time); + + body.appendChild(bubble); + body.appendChild(meta); + + // Action buttons for assistant messages (copy, like, dislike) — inside bubble, hidden during streaming + if (!isUser) { + var actions = document.createElement("div"); + actions.className = + "lb-msg-actions" + + (msg.is_final === false ? " lb-msg-actions-hidden" : ""); + + // Copy button + var copyBtn = document.createElement("button"); + copyBtn.className = "lb-act-btn"; + copyBtn.innerHTML = ICON_COPY; + copyBtn.addEventListener( + "click", + (function (t) { + return function () { + var currentText = bubble.textContent || t; + navigator.clipboard.writeText(currentText).then(function () { + copyBtn.innerHTML = ICON_CHECK; + setTimeout(function () { + copyBtn.innerHTML = ICON_COPY; + }, 1500); + }); + }; + })(textContent), + ); + actions.appendChild(copyBtn); + + // Like & Dislike buttons + var likeBtn = document.createElement("button"); + var dislikeBtn = document.createElement("button"); + + likeBtn.className = + "lb-act-btn" + (state.feedbackState[msg.id] === 1 ? " lb-active" : ""); + likeBtn.innerHTML = ICON_THUMB_UP; + dislikeBtn.className = + "lb-act-btn" + (state.feedbackState[msg.id] === 2 ? " lb-active" : ""); + dislikeBtn.innerHTML = ICON_THUMB_DOWN; + + (function (id, lBtn, dBtn) { + lBtn.addEventListener("click", function () { + submitFeedback(id, 1); + lBtn.classList.toggle("lb-active", state.feedbackState[id] === 1); + dBtn.classList.remove("lb-active"); + }); + dBtn.addEventListener("click", function () { + submitFeedback(id, 2); + dBtn.classList.toggle("lb-active", state.feedbackState[id] === 2); + lBtn.classList.remove("lb-active"); + }); + })(msg.id, likeBtn, dislikeBtn); + + actions.appendChild(likeBtn); + actions.appendChild(dislikeBtn); + bubble.appendChild(actions); + } + + div.appendChild(avatar); + div.appendChild(body); + return div; + } + + function extractText(msg) { + if (msg.content) return msg.content; + if (msg.message_chain) { + var texts = []; + for (var i = 0; i < msg.message_chain.length; i++) { + if (msg.message_chain[i].text) texts.push(msg.message_chain[i].text); + } + return texts.join(""); + } + return ""; + } + + function extractImages(msg) { + var images = []; + if (msg.message_chain) { + for (var i = 0; i < msg.message_chain.length; i++) { + var c = msg.message_chain[i]; + if (c.type === "Image" && (c.base64 || c.url)) { + var imgUrl = c.base64 || c.url; + if (/^(https?:\/\/|data:)/i.test(imgUrl)) { + images.push(imgUrl); + } + } + } + } + return images; + } + + function submitFeedback(msgId, feedbackType) { + var prev = state.feedbackState[msgId]; + var actualType = prev === feedbackType ? 3 : feedbackType; // toggle = cancel + state.feedbackState[msgId] = actualType === 3 ? 0 : actualType; + + var headers = { "Content-Type": "application/json" }; + if (state.sessionToken) + headers["Authorization"] = "Bearer " + state.sessionToken; + + fetch(CONFIG.baseUrl + "/api/v1/embed/" + CONFIG.botUuid + "/feedback", { + method: "POST", + headers: headers, + body: JSON.stringify({ message_id: msgId, feedback_type: actualType }), + }).catch(function () {}); + } + + function updateMessageEl(idx, msg) { + var allMsgs = els.messages.querySelectorAll(".lb-msg"); + if (allMsgs[idx]) { + var bubble = allMsgs[idx].querySelector(".lb-msg-bubble"); + if (bubble) { + // Preserve action buttons if present + var actionsEl = bubble.querySelector(".lb-msg-actions"); + bubble.innerHTML = renderMarkdown(msg.content || extractText(msg)); + // Re-append or show action buttons when streaming finishes + if (actionsEl) { + if (msg.is_final) actionsEl.classList.remove("lb-msg-actions-hidden"); + bubble.appendChild(actionsEl); + } + } + } + } + + function renderMessages() { + // Clear all messages from DOM + while (els.messages.firstChild) { + els.messages.removeChild(els.messages.firstChild); + } + + // Re-add welcome if no messages + if (state.messages.length === 0) { + els.messages.appendChild(createWelcomeEl()); + return; + } + + for (var i = 0; i < state.messages.length; i++) { + els.messages.appendChild(createMessageEl(state.messages[i])); + } + scrollToBottom(); + } + + function createWelcomeEl() { + var div = document.createElement("div"); + div.className = "lb-welcome"; + els.welcome = div; + + var logo = document.createElement("img"); + logo.className = "lb-welcome-logo"; + logo.src = CONFIG.logoUrl; + logo.alt = "LangBot"; + + var text = document.createElement("div"); + text.textContent = t("welcomeMessage"); + + div.appendChild(logo); + div.appendChild(text); + return div; + } + + function showTypingIndicator() { + if (els.messages.querySelector(".lb-typing")) return; + var div = document.createElement("div"); + div.className = "lb-typing"; + div.innerHTML = ""; + els.messages.appendChild(div); + scrollToBottom(); + } + + function removeTypingIndicator() { + var el = els.messages.querySelector(".lb-typing"); + if (el) el.remove(); + } + + function showError(msg) { + var div = document.createElement("div"); + div.className = "lb-error"; + div.textContent = msg; + els.messages.appendChild(div); + setTimeout(function () { + if (div.parentNode) div.remove(); + }, 5000); + scrollToBottom(); + } + + function updateStatusDot() { + if (els.statusDot) { + if (state.isConnected) { + els.statusDot.classList.add("lb-connected"); + } else { + els.statusDot.classList.remove("lb-connected"); + } + } + } + + function updateSendBtn() { + if (els.sendBtn) { + els.sendBtn.disabled = !state.isConnected; + } + } + + function togglePanel() { + state.isOpen = !state.isOpen; + + if (state.isOpen) { + els.panel.classList.add("lb-visible"); + els.bubble.classList.add("lb-open"); + ensureTurnstileVerified(function () { + loadHistory(); + wsConnect(); + }); + setTimeout(function () { + if (els.input) els.input.focus(); + }, 300); + } else { + els.panel.classList.remove("lb-visible"); + els.bubble.classList.remove("lb-open"); + } + } + + function ensureTurnstileVerified(callback) { + if ( + state.sessionToken || + !CONFIG.turnstileSiteKey || + CONFIG.turnstileSiteKey.indexOf("__LANGBOT") === 0 + ) { + return callback(); + } + if (state.turnstileQueue) { + state.turnstileQueue.push(callback); + return; + } + state.turnstileQueue = [callback]; + + var flushQueue = function (success) { + var q = state.turnstileQueue; + state.turnstileQueue = null; + if (success && q) { + for (var i = 0; i < q.length; i++) q[i](); + } + }; + + var doRender = function () { + var container = document.createElement("div"); + document.body.appendChild(container); + turnstile.render(container, { + sitekey: CONFIG.turnstileSiteKey, + size: "invisible", + callback: function (token) { + fetch( + CONFIG.baseUrl + + "/api/v1/embed/" + + CONFIG.botUuid + + "/turnstile/verify", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: token }), + }, + ) + .then(function (res) { + return res.json(); + }) + .then(function (data) { + if (data && data.data && data.data.token) { + state.sessionToken = data.data.token; + flushQueue(true); + } else { + showError(t("botVerificationFailed")); + flushQueue(false); + } + }) + .catch(function () { + showError(t("botVerificationNetworkError")); + flushQueue(false); + }); + }, + "error-callback": function () { + showError(t("botVerificationError")); + flushQueue(false); + }, + }); + }; + + if (window.turnstile) { + doRender(); + } else { + window.onloadTurnstileCallback = doRender; + var script = document.createElement("script"); + script.src = + "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onloadTurnstileCallback"; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + } + + function handleSend() { + var text = els.input.value; + var img = state.pendingImage; + if ((!text.trim() && !img) || !state.isConnected) return; + + sendMessage(text, img); + els.input.value = ""; + els.input.style.height = "auto"; + clearPendingAttachment(); + els.input.focus(); + } + + function handleInputKeydown(e) { + if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + handleSend(); + } + } + + function autoResizeInput() { + els.input.style.height = "auto"; + els.input.style.height = Math.min(els.input.scrollHeight, 120) + "px"; + } + + function handleImageSelect(e) { + var file = e.target.files && e.target.files[0]; + if (!file) return; + if (file.size > 5 * 1024 * 1024) { + showError(t("imageTooLarge")); + return; + } + if (!/^image\//.test(file.type)) { + showError(t("onlyImages")); + return; + } + var reader = new FileReader(); + reader.onload = function (ev) { + showImagePreview(ev.target.result); + state.pendingImage = ev.target.result; + }; + reader.readAsDataURL(file); + e.target.value = ""; + } + + function showImagePreview(src) { + removePreviewDom(); + var preview = document.createElement("div"); + preview.className = "lb-img-preview"; + preview.id = "lb-img-preview"; + + var img = document.createElement("img"); + img.src = src; + + var removeBtn = document.createElement("button"); + removeBtn.className = "lb-img-preview-remove"; + removeBtn.textContent = "\u00d7"; + removeBtn.addEventListener("click", clearPendingAttachment); + + preview.appendChild(img); + preview.appendChild(removeBtn); + + // Insert before footer + var footer = els.panel.querySelector(".lb-footer"); + if (footer) { + footer.parentNode.insertBefore(preview, footer); + } + } + + function removePreviewDom() { + var existing = els.panel + ? els.panel.querySelector("#lb-img-preview") + : null; + if (existing) existing.remove(); + } + + function clearPendingAttachment() { + state.pendingImage = null; + state.pendingFile = null; + removePreviewDom(); + } + + // ========== Build DOM ========== + function buildWidget() { + // Root container + var root = document.createElement("div"); + root.id = "langbot-widget-root"; + document.body.appendChild(root); + + var shadow = root.attachShadow({ mode: "open" }); + + // Styles + var style = document.createElement("style"); + style.textContent = STYLES; + shadow.appendChild(style); + + // Chat bubble button + var bubble = document.createElement("button"); + bubble.className = "lb-bubble"; + bubble.setAttribute("aria-label", t("openChat")); + + var chatIcon = document.createElement("span"); + chatIcon.className = "lb-chat-icon"; + var selectedBubbleSvg = BUBBLE_ICONS[CONFIG.bubbleIcon]; + if (selectedBubbleSvg) { + chatIcon.innerHTML = selectedBubbleSvg; + } else { + var bubbleLogo = document.createElement("img"); + bubbleLogo.src = CONFIG.logoUrl; + bubbleLogo.alt = CONFIG.title; + bubbleLogo.style.cssText = "width:100%;height:100%;object-fit:cover;"; + chatIcon.appendChild(bubbleLogo); + } + + var closeIcon = document.createElement("span"); + closeIcon.className = "lb-close-icon"; + closeIcon.innerHTML = ICON_CLOSE; + + bubble.appendChild(chatIcon); + bubble.appendChild(closeIcon); + bubble.addEventListener("click", togglePanel); + els.bubble = bubble; + shadow.appendChild(bubble); + + // Chat panel + var panel = document.createElement("div"); + panel.className = "lb-panel"; + els.panel = panel; + + // Header + var header = document.createElement("div"); + header.className = "lb-header"; + + var headerLeft = document.createElement("div"); + headerLeft.className = "lb-header-left"; + + var headerLogo = document.createElement("img"); + headerLogo.className = "lb-header-logo"; + headerLogo.src = CONFIG.logoUrl; + headerLogo.alt = CONFIG.title; + + var title = document.createElement("span"); + title.className = "lb-header-title"; + title.textContent = CONFIG.title; + + var statusDot = document.createElement("span"); + statusDot.className = "lb-status-dot"; + els.statusDot = statusDot; + + headerLeft.appendChild(headerLogo); + headerLeft.appendChild(title); + headerLeft.appendChild(statusDot); + + var headerActions = document.createElement("div"); + headerActions.className = "lb-header-actions"; + + var resetBtn = document.createElement("button"); + resetBtn.className = "lb-header-btn"; + resetBtn.setAttribute("aria-label", t("resetConversation")); + resetBtn.innerHTML = ICON_RESET; + resetBtn.addEventListener("click", resetSession); + + var minimizeBtn = document.createElement("button"); + minimizeBtn.className = "lb-header-btn"; + minimizeBtn.setAttribute("aria-label", t("minimize")); + minimizeBtn.innerHTML = ICON_CLOSE; + minimizeBtn.addEventListener("click", togglePanel); + + headerActions.appendChild(resetBtn); + headerActions.appendChild(minimizeBtn); + + header.appendChild(headerLeft); + header.appendChild(headerActions); + panel.appendChild(header); + + // Messages area + var messages = document.createElement("div"); + messages.className = "lb-messages"; + els.messages = messages; + messages.appendChild(createWelcomeEl()); + panel.appendChild(messages); + + // Input area + var inputArea = document.createElement("div"); + inputArea.className = "lb-input-area"; + + // Hidden file input + var fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "image/*"; + fileInput.style.cssText = + "position:absolute;width:0;height:0;overflow:hidden;opacity:0;"; + fileInput.addEventListener("change", handleImageSelect); + + // Image upload button + var imgBtn = document.createElement("button"); + imgBtn.className = "lb-img-upload-btn"; + imgBtn.setAttribute("aria-label", t("uploadFile")); + imgBtn.innerHTML = ICON_IMAGE; + imgBtn.addEventListener("click", function () { + fileInput.click(); + }); + + var input = document.createElement("textarea"); + input.className = "lb-input"; + input.placeholder = t("inputPlaceholder"); + input.rows = 1; + input.addEventListener("keydown", handleInputKeydown); + input.addEventListener("input", autoResizeInput); + els.input = input; + + var sendBtn = document.createElement("button"); + sendBtn.className = "lb-send-btn"; + sendBtn.disabled = true; + sendBtn.setAttribute("aria-label", t("send")); + sendBtn.innerHTML = ICON_SEND; + sendBtn.addEventListener("click", handleSend); + els.sendBtn = sendBtn; + + inputArea.appendChild(fileInput); + inputArea.appendChild(imgBtn); + inputArea.appendChild(input); + inputArea.appendChild(sendBtn); + + // Footer: Powered by LangBot (above input area) + var footer = document.createElement("div"); + footer.className = "lb-footer"; + footer.innerHTML = t("poweredBy"); + panel.appendChild(footer); + + panel.appendChild(inputArea); + + shadow.appendChild(panel); + } + + // ========== Initialize ========== + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", buildWidget); + } else { + buildWidget(); + } +})(); diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index a944c217..fd68fb47 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -47,6 +47,26 @@ stages: label: en_US: Langflow API zh_Hans: Langflow API + - name: expire-time + label: + en_US: Conversation expire time (seconds) + zh_Hans: 单个对话过期时间(秒) + description: + en_US: >- + Maximum idle time of a single conversation, measured from the last + message/preprocess update time. When a new message arrives and the + current conversation has been idle for longer than this value, the + existing external conversation id is discarded and a new one is + started automatically — the user does not need to trigger a reset + manually. Set to 0 to disable expiry (conversations live until the + user or another mechanism resets them). + zh_Hans: >- + 单个对话的最长空闲时间,从最后一条消息/preprocess 更新时间开始计算。 + 当新消息到达且当前对话空闲时间已超过该值时,旧的外部对话 ID 会被自动丢弃并开启新对话, + 用户无需手动重置。设置为 0 表示不限制过期时间(对话会一直保留,直到用户或其他机制主动重置)。 + type: integer + required: true + default: 0 - name: local-agent label: en_US: Local Agent @@ -539,4 +559,4 @@ stages: zh_Hans: 可选的流程调整参数 type: json required: false - default: '{}' \ No newline at end of file + default: '{}' diff --git a/src/langbot/templates/metadata/pipeline/safety.yaml b/src/langbot/templates/metadata/pipeline/safety.yaml index 32edf9f0..12cb5604 100644 --- a/src/langbot/templates/metadata/pipeline/safety.yaml +++ b/src/langbot/templates/metadata/pipeline/safety.yaml @@ -72,4 +72,4 @@ stages: - name: wait label: en_US: Wait - zh_Hans: 等待 \ No newline at end of file + zh_Hans: 等待 diff --git a/tests/unit_tests/pipeline/test_chat_session_limit.py b/tests/unit_tests/pipeline/test_chat_session_limit.py new file mode 100644 index 00000000..15cfd10b --- /dev/null +++ b/tests/unit_tests/pipeline/test_chat_session_limit.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from importlib import import_module +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import yaml + + +def _preproc_module(): + # Import pipelinemgr first so pipeline stages are registered without tripping + # the stage <-> core.app circular import during isolated test collection. + import_module('langbot.pkg.pipeline.pipelinemgr') + return import_module('langbot.pkg.pipeline.preproc.preproc') + + +def _entities_module(): + return import_module('langbot.pkg.pipeline.entities') + + +def _conversation(created_at: datetime, updated_at: datetime | None = None): + prompt = Mock() + prompt.messages = [] + prompt.copy = Mock(return_value=Mock(messages=[])) + + return SimpleNamespace( + uuid='existing-conversation-uuid', + create_time=created_at, + update_time=updated_at, + prompt=prompt, + messages=[], + ) + + +def _prompt_preprocessing_context(default_prompt=None, prompt=None): + ctx = Mock() + ctx.event.default_prompt = default_prompt or [] + ctx.event.prompt = prompt or [] + return ctx + + +async def _run_preprocessor(mock_app, sample_query, conversation): + session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id) + mock_app.sess_mgr.get_session = AsyncMock(return_value=session) + mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation) + mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context()) + + sample_query.pipeline_config = { + 'ai': { + 'runner': {'runner': 'local-agent', 'expire-time': 60}, + 'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []}, + }, + 'trigger': {'misc': {'combine-quote-message': False}}, + 'output': {'misc': {'exception-handling': 'show-hint'}}, + } + + return await _preproc_module().PreProcessor(mock_app).process(sample_query, 'PreProcessor') + + +@pytest.mark.asyncio +async def test_preprocessor_expires_conversation_from_last_update_time(mock_app, sample_query): + conversation = _conversation( + created_at=datetime.now() - timedelta(seconds=10), + updated_at=datetime.now() - timedelta(seconds=120), + ) + + result = await _run_preprocessor(mock_app, sample_query, conversation) + + assert result.result_type == _entities_module().ResultType.CONTINUE + assert conversation.uuid is None + assert conversation.update_time > datetime.now() - timedelta(seconds=5) + assert result.new_query.variables['conversation_id'] is None + + +@pytest.mark.asyncio +async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(mock_app, sample_query): + conversation = _conversation( + created_at=datetime.now() - timedelta(seconds=120), + updated_at=datetime.now() - timedelta(seconds=30), + ) + + result = await _run_preprocessor(mock_app, sample_query, conversation) + + assert result.result_type == _entities_module().ResultType.CONTINUE + assert conversation.uuid == 'existing-conversation-uuid' + assert conversation.update_time > datetime.now() - timedelta(seconds=5) + assert result.new_query.variables['conversation_id'] == 'existing-conversation-uuid' + + +def test_expire_time_metadata_lives_under_ai_runner_not_safety(): + metadata_dir = Path('src/langbot/templates/metadata/pipeline') + + ai_meta = yaml.safe_load((metadata_dir / 'ai.yaml').read_text()) + safety_meta = yaml.safe_load((metadata_dir / 'safety.yaml').read_text()) + + ai_stage_names = [stage['name'] for stage in ai_meta['stages']] + assert 'session-limit' not in ai_stage_names + assert 'session-limit' not in [stage['name'] for stage in safety_meta['stages']] + + runner_stage = next(stage for stage in ai_meta['stages'] if stage['name'] == 'runner') + expire_time = next(item for item in runner_stage['config'] if item['name'] == 'expire-time') + assert 'Conversation expire time' in expire_time['label']['en_US'] + assert 'Session validity' not in expire_time['label']['en_US'] diff --git a/tests/unit_tests/provider/__init__.py b/tests/unit_tests/provider/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/unit_tests/provider/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit_tests/provider/test_model_service.py b/tests/unit_tests/provider/test_model_service.py new file mode 100644 index 00000000..8fac8278 --- /dev/null +++ b/tests/unit_tests/provider/test_model_service.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message +import langbot_plugin.api.entities.builtin.provider.session as provider_session + +from langbot.pkg.api.http.service.model import _runtime_model_data +from langbot.pkg.entity.persistence import model as persistence_model +from langbot.pkg.pipeline.preproc.preproc import PreProcessor +from langbot.pkg.provider.modelmgr import requester +from langbot.pkg.provider.modelmgr.modelmgr import ModelManager +from langbot.pkg.provider.runners.localagent import LocalAgentRunner + + +def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed(): + update_payload = { + 'name': 'Qwen3.5-27B', + 'provider_uuid': 'provider-uuid', + 'abilities': [], + 'extra_args': {}, + } + + runtime_entity = persistence_model.LLMModel(**_runtime_model_data('model-uuid', update_payload)) + + assert runtime_entity.uuid == 'model-uuid' + assert runtime_entity.name == 'Qwen3.5-27B' + + +def test_runtime_embedding_model_data_preserves_uuid_after_update_payload_uuid_removed(): + update_payload = { + 'name': 'embedding-model', + 'provider_uuid': 'provider-uuid', + 'extra_args': {}, + } + + runtime_entity = persistence_model.EmbeddingModel(**_runtime_model_data('embedding-uuid', update_payload)) + + assert runtime_entity.uuid == 'embedding-uuid' + assert runtime_entity.name == 'embedding-model' + + +def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_removed(): + update_payload = { + 'name': 'rerank-model', + 'provider_uuid': 'provider-uuid', + 'extra_args': {}, + } + + runtime_entity = persistence_model.RerankModel(**_runtime_model_data('rerank-uuid', update_payload)) + + assert runtime_entity.uuid == 'rerank-uuid' + assert runtime_entity.name == 'rerank-model' + + +@pytest.mark.asyncio +async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline(): + from langbot.pkg.api.http.service.model import LLMModelsService + + model_uuid = 'qwen-model-uuid' + provider_uuid = 'ollama-provider-uuid' + + ap = SimpleNamespace() + ap.logger = Mock() + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock()) + ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[])) + ap.plugin_connector = SimpleNamespace( + emit_event=AsyncMock(return_value=SimpleNamespace(event=SimpleNamespace(default_prompt=[], prompt=[]))) + ) + + ap.model_mgr = ModelManager(ap) + runtime_provider = Mock() + ap.model_mgr.provider_dict = {provider_uuid: runtime_provider} + ap.model_mgr.llm_models = [ + requester.RuntimeLLMModel( + model_entity=persistence_model.LLMModel( + uuid=model_uuid, + name='old-qwen-name', + provider_uuid=provider_uuid, + abilities=[], + extra_args={}, + ), + provider=runtime_provider, + ) + ] + + await LLMModelsService(ap).update_llm_model( + model_uuid, + { + 'name': 'Qwen3.5-27B', + 'provider_uuid': provider_uuid, + 'abilities': [], + 'extra_args': {}, + }, + ) + + runtime_model = await ap.model_mgr.get_model_by_uuid(model_uuid) + assert runtime_model.model_entity.uuid == model_uuid + assert runtime_model.model_entity.name == 'Qwen3.5-27B' + + session = SimpleNamespace( + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + ) + conversation = SimpleNamespace( + uuid='conversation-uuid', + create_time=None, + update_time=None, + prompt=SimpleNamespace(messages=[], copy=Mock(return_value=SimpleNamespace(messages=[]))), + messages=[], + ) + ap.sess_mgr = SimpleNamespace( + get_session=AsyncMock(return_value=session), + get_conversation=AsyncMock(return_value=conversation), + ) + + message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')]) + sender = platform_entities.Friend(id=12345, nickname='Tester', remark=None) + message_event = platform_events.FriendMessage( + type='FriendMessage', + sender=sender, + message_chain=message_chain, + time=1710000000, + ) + pipeline_config = { + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': { + 'model': {'primary': model_uuid, 'fallbacks': []}, + 'prompt': [], + 'knowledge-bases': [], + }, + }, + 'trigger': {'misc': {'combine-quote-message': False}}, + 'output': {'misc': {'remove-think': False}}, + } + query = pipeline_query.Query.model_construct( + query_id='query-id', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + message_chain=message_chain, + message_event=message_event, + adapter=AsyncMock(), + pipeline_uuid='pipeline-uuid', + bot_uuid='bot-uuid', + pipeline_config=pipeline_config, + session=None, + prompt=None, + messages=[], + user_message=None, + use_funcs=[], + use_llm_model_uuid=None, + variables={}, + resp_messages=[], + resp_message_chain=None, + current_stage_name=None, + ) + + result = await PreProcessor(ap).process(query, 'PreProcessor') + processed_query = result.new_query + + assert processed_query.use_llm_model_uuid == model_uuid + + runner = SimpleNamespace(ap=ap, pipeline_config=pipeline_config) + candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query) + + assert [model.model_entity.uuid for model in candidates] == [model_uuid] diff --git a/tests/unit_tests/storage/test_localstorage_path_traversal.py b/tests/unit_tests/storage/test_localstorage_path_traversal.py new file mode 100644 index 00000000..1afc276e --- /dev/null +++ b/tests/unit_tests/storage/test_localstorage_path_traversal.py @@ -0,0 +1,181 @@ +""" +PoC test for CWE-22 path traversal in LocalStorageProvider. + +The LocalStorageProvider uses os.path.join(LOCAL_STORAGE_PATH, key) without +validating that the resulting path stays inside LOCAL_STORAGE_PATH. + +When `key` is an absolute path (e.g. '/etc/passwd'), os.path.join discards +all previous components and returns the absolute path directly, allowing +arbitrary file reads, writes, and deletes. + +This test must FAIL before the fix and PASS after. +""" + +import os +import pytest +from unittest.mock import Mock, patch + +from langbot.pkg.storage.providers.localstorage import LocalStorageProvider + + +@pytest.fixture +def storage_provider(tmp_path): + """Create a LocalStorageProvider with a temporary storage path.""" + storage_path = str(tmp_path / "storage") + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + mock_app = Mock() + provider = LocalStorageProvider(mock_app) + yield provider, storage_path + + +class TestPathTraversalPrevention: + """Test that LocalStorageProvider rejects path traversal attempts.""" + + @pytest.mark.asyncio + async def test_absolute_path_save_rejected(self, storage_provider, tmp_path): + """Saving with an absolute path key must be blocked.""" + provider, storage_path = storage_provider + target_file = str(tmp_path / "pwned.txt") + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with pytest.raises((ValueError, PermissionError)): + await provider.save(target_file, b"malicious content") + + # The file must NOT exist outside the storage directory + assert not os.path.exists(target_file), ( + f"Path traversal succeeded: file was written outside storage to {target_file}" + ) + + @pytest.mark.asyncio + async def test_absolute_path_load_rejected(self, storage_provider, tmp_path): + """Loading with an absolute path key must be blocked.""" + provider, storage_path = storage_provider + + # Create a file outside the storage directory + target_file = str(tmp_path / "secret.txt") + with open(target_file, "wb") as f: + f.write(b"secret data") + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with pytest.raises((ValueError, PermissionError, FileNotFoundError)): + data = await provider.load(target_file) + assert data != b"secret data", ( + "Path traversal succeeded: read file outside storage" + ) + + @pytest.mark.asyncio + async def test_absolute_path_exists_rejected(self, storage_provider, tmp_path): + """Exists check with an absolute path key must be blocked or return False.""" + provider, storage_path = storage_provider + + target_file = str(tmp_path / "check_me.txt") + with open(target_file, "wb") as f: + f.write(b"data") + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + try: + result = await provider.exists(target_file) + assert result is False, ( + "Path traversal succeeded: exists() returned True for file outside storage" + ) + except (ValueError, PermissionError): + pass # Expected + + @pytest.mark.asyncio + async def test_absolute_path_delete_rejected(self, storage_provider, tmp_path): + """Deleting with an absolute path key must be blocked.""" + provider, storage_path = storage_provider + + target_file = str(tmp_path / "do_not_delete.txt") + with open(target_file, "wb") as f: + f.write(b"important data") + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with pytest.raises((ValueError, PermissionError, FileNotFoundError)): + await provider.delete(target_file) + + assert os.path.exists(target_file), ( + "Path traversal succeeded: file outside storage was deleted" + ) + + @pytest.mark.asyncio + async def test_absolute_path_size_rejected(self, storage_provider, tmp_path): + """Size check with an absolute path key must be blocked.""" + provider, storage_path = storage_provider + + target_file = str(tmp_path / "measure_me.txt") + with open(target_file, "wb") as f: + f.write(b"some data") + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with pytest.raises((ValueError, PermissionError, FileNotFoundError)): + await provider.size(target_file) + + @pytest.mark.asyncio + async def test_dot_dot_path_traversal_rejected(self, storage_provider, tmp_path): + """Relative path traversal with '..' must be blocked.""" + provider, storage_path = storage_provider + + target_file = str(tmp_path / "above_storage.txt") + with open(target_file, "wb") as f: + f.write(b"above storage secret") + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + relative_key = os.path.join("..", "above_storage.txt") + with pytest.raises((ValueError, PermissionError, FileNotFoundError)): + data = await provider.load(relative_key) + assert data != b"above storage secret" + + @pytest.mark.asyncio + async def test_delete_dir_recursive_traversal_rejected(self, storage_provider, tmp_path): + """delete_dir_recursive with traversal path must be blocked.""" + provider, storage_path = storage_provider + + outside_dir = tmp_path / "outside_dir" + outside_dir.mkdir() + (outside_dir / "file.txt").write_text("important") + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + with pytest.raises((ValueError, PermissionError)): + await provider.delete_dir_recursive(str(outside_dir)) + + assert outside_dir.exists(), ( + "Path traversal succeeded: directory outside storage was deleted" + ) + + @pytest.mark.asyncio + async def test_legitimate_key_works(self, storage_provider): + """Normal keys without traversal must still work.""" + provider, storage_path = storage_provider + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + key = "test_image_abc123.png" + content = b"PNG image data" + + await provider.save(key, content) + assert await provider.exists(key) is True + loaded = await provider.load(key) + assert loaded == content + size = await provider.size(key) + assert size == len(content) + await provider.delete(key) + assert await provider.exists(key) is False + + @pytest.mark.asyncio + async def test_legitimate_subdirectory_key_works(self, storage_provider): + """Keys with legitimate subdirectories must still work.""" + provider, storage_path = storage_provider + + with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path): + key = "bot_log_images/img_001.png" + content = b"PNG image data" + + await provider.save(key, content) + assert await provider.exists(key) is True + loaded = await provider.load(key) + assert loaded == content + await provider.delete(key) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/uv.lock b/uv.lock index 0c8250ed..1ee8ec85 100644 --- a/uv.lock +++ b/uv.lock @@ -37,11 +37,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/c2/91/e4bf8584695b065e2 [[package]] name = "aiofiles" -version = "25.1.0" +version = "24.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, ] [[package]] @@ -55,7 +55,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -66,93 +66,106 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aiohttp-socks" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "python-socks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" }, ] [[package]] @@ -547,6 +560,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/71/9a2c88abb5fe47b46168b262254d5b5d635de371eba4bd01ea5c8c109575/botocore-1.42.39-py3-none-any.whl", hash = "sha256:9e0d0fed9226449cc26fcf2bbffc0392ac698dd8378e8395ce54f3ec13f81d58", size = 14591958, upload-time = "2026-01-30T20:38:14.814Z" }, ] +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + [[package]] name = "build" version = "1.4.0" @@ -921,61 +943,61 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "47.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, ] [[package]] @@ -1073,6 +1095,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -1102,6 +1133,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] +[[package]] +name = "e2b" +version = "2.20.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/b0/a7d5347a5c2d0fe09bb5254c919b716f96217d6526235225f786f879dfbb/e2b-2.20.3.tar.gz", hash = "sha256:c6e91f71946755e1579b4ca1e175819d9f174b932b92e115cf36c2fd04674f3c", size = 157132, upload-time = "2026-04-30T15:15:21.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/12/326db5df4d3e79bc794b3662a454fe1f68cb02c33985e623ece6e5e21395/e2b-2.20.3-py3-none-any.whl", hash = "sha256:46c6b5ffc45c9ca6dc270dd4d29427cef6a2600c55a895565657ff2bedc06303", size = 297072, upload-time = "2026-04-30T15:15:19.46Z" }, +] + [[package]] name = "ebooklib" version = "0.20" @@ -1875,10 +1927,14 @@ dependencies = [ { name = "html2text" }, { name = "langbot-plugin" }, { name = "langchain" }, + { name = "langchain-core" }, { name = "langchain-text-splitters" }, + { name = "langsmith" }, { name = "lark-oapi" }, { name = "line-bot-sdk" }, + { name = "mako" }, { name = "markdown" }, + { name = "matrix-nio" }, { name = "mcp" }, { name = "mypy" }, { name = "nakuru-project-idk" }, @@ -1898,6 +1954,7 @@ dependencies = [ { name = "pypdf2" }, { name = "pyseekdb" }, { name = "python-docx" }, + { name = "python-multipart" }, { name = "python-socks" }, { name = "python-telegram-bot" }, { name = "pyyaml" }, @@ -1931,7 +1988,7 @@ dev = [ requires-dist = [ { name = "aiocqhttp", specifier = ">=1.4.4" }, { name = "aiofiles", specifier = ">=24.1.0" }, - { name = "aiohttp", specifier = ">=3.11.18" }, + { name = "aiohttp", specifier = ">=3.13.4" }, { name = "aioshutil", specifier = ">=1.5" }, { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.15.0" }, @@ -1946,7 +2003,7 @@ requires-dist = [ { name = "chardet", specifier = ">=5.2.0" }, { name = "chromadb", specifier = ">=1.0.0,<2.0.0" }, { name = "colorlog", specifier = "~=6.6.0" }, - { name = "cryptography", specifier = ">=44.0.3" }, + { name = "cryptography", specifier = ">=46.0.7" }, { name = "dashscope", specifier = ">=1.25.10" }, { name = "dingtalk-stream", specifier = ">=0.24.0" }, { name = "discord-py", specifier = ">=2.5.2" }, @@ -1955,10 +2012,14 @@ requires-dist = [ { name = "html2text", specifier = ">=2024.2.26" }, { name = "langbot-plugin", editable = "../langbot-plugin-sdk" }, { name = "langchain", specifier = ">=0.2.0" }, - { name = "langchain-text-splitters", specifier = ">=0.0.1" }, + { name = "langchain-core", specifier = ">=1.2.28" }, + { name = "langchain-text-splitters", specifier = ">=1.1.2" }, + { name = "langsmith", specifier = ">=0.7.31" }, { name = "lark-oapi", specifier = ">=1.4.15" }, { name = "line-bot-sdk", specifier = ">=3.19.0" }, + { name = "mako", specifier = ">=1.3.11" }, { name = "markdown", specifier = ">=3.6" }, + { name = "matrix-nio", specifier = ">=0.25.2" }, { name = "mcp", specifier = ">=1.25.0" }, { name = "mypy", specifier = ">=1.16.0" }, { name = "nakuru-project-idk", specifier = ">=0.0.2.1" }, @@ -1966,7 +2027,7 @@ requires-dist = [ { name = "openai", specifier = ">1.0.0" }, { name = "pandas", specifier = ">=2.2.2" }, { name = "pgvector", specifier = ">=0.4.1" }, - { name = "pillow", specifier = ">=11.2.1" }, + { name = "pillow", specifier = ">=12.2.0" }, { name = "pip", specifier = ">=25.1.1" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "psutil", specifier = ">=7.0.0" }, @@ -1978,6 +2039,7 @@ requires-dist = [ { name = "pypdf2", specifier = ">=3.0.1" }, { name = "pyseekdb", specifier = "==1.1.0.post3" }, { name = "python-docx", specifier = ">=1.1.0" }, + { name = "python-multipart", specifier = ">=0.0.26" }, { name = "python-socks", specifier = ">=2.7.1" }, { name = "python-telegram-bot", specifier = ">=22.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, @@ -1994,14 +2056,14 @@ requires-dist = [ { name = "telegramify-markdown", specifier = ">=0.5.1" }, { name = "tiktoken", specifier = ">=0.9.0" }, { name = "urllib3", specifier = ">=2.4.0" }, - { name = "uv", specifier = ">=0.7.11" }, + { name = "uv", specifier = ">=0.11.6" }, { name = "websockets", specifier = ">=15.0.1" }, ] [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = ">=4.2.0" }, - { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.11.9" }, @@ -2009,12 +2071,13 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.3.7" +version = "0.3.10" source = { editable = "../langbot-plugin-sdk" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, { name = "dotenv" }, + { name = "e2b" }, { name = "httpx" }, { name = "jinja2" }, { name = "pip" }, @@ -2034,6 +2097,7 @@ requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiohttp", specifier = ">=3.9.0" }, { name = "dotenv", specifier = ">=0.9.9" }, + { name = "e2b", specifier = ">=2.15" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "pip", specifier = ">=25.2" }, @@ -2070,10 +2134,11 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.18" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, + { name = "langchain-protocol" }, { name = "langsmith" }, { name = "packaging" }, { name = "pydantic" }, @@ -2082,21 +2147,33 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/b7/8bbd0d99a6441b35d891e4b79e7d24c67722cdd363893ae650f24808cf5a/langchain_core-1.2.18.tar.gz", hash = "sha256:ffe53eec44636d092895b9fe25d28af3aaf79060e293fa7cda2a5aaa50c80d21", size = 836725, upload-time = "2026-03-09T20:40:07.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/d8/9418564ed4ab4f150668b25cf8c188266267d829362e9c9106946afa628b/langchain_core-1.2.18-py3-none-any.whl", hash = "sha256:cccb79523e0045174ab826054e555fddc973266770e427588c8f1ec9d9d6212b", size = 503048, upload-time = "2026-03-09T20:40:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/51/1157009b6f94e6e58be58fa8b620187d657909a8b36a6bf5b0c52a2711f6/langchain_protocol-0.0.12.tar.gz", hash = "sha256:5e14c434290a705c9510fdb1a83ecf7561a5e6e0dfd053930ade80dba069269f", size = 6408, upload-time = "2026-04-25T01:05:01.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/82/3431e3061c917439589fa88a6b23c9bc0e154cba0f05d2e895a68c76ff74/langchain_protocol-0.0.12-py3-none-any.whl", hash = "sha256:402b61f42d4139692528cf37226c367bb6efc8ff8165b29380accb0abfece7b2", size = 6639, upload-time = "2026-04-25T01:05:00.487Z" }, ] [[package]] name = "langchain-text-splitters" -version = "1.1.0" +version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/42/c178dcdc157b473330eb7cc30883ea69b8ec60078c7b85e2d521054c4831/langchain_text_splitters-1.1.0.tar.gz", hash = "sha256:75e58acb7585dc9508f3cd9d9809cb14751283226c2d6e21fb3a9ae57582ca22", size = 272230, upload-time = "2025-12-14T01:15:38.659Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl", hash = "sha256:f00341fe883358786104a5f881375ac830a4dd40253ecd42b4c10536c6e4693f", size = 34182, upload-time = "2025-12-14T01:15:37.382Z" }, + { url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903, upload-time = "2026-04-16T14:20:38.243Z" }, ] [[package]] @@ -2157,7 +2234,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.6.7" +version = "0.7.36" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2170,9 +2247,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/bb/b8a196c9b9a7ca8b8845eaec7dbae35bcfcb0da3068794e76b29211eae2b/langsmith-0.6.7.tar.gz", hash = "sha256:d89c604a18fc606b7835d8e7924f7cdbe130ca2207bdff8f989590e50d65b802", size = 963940, upload-time = "2026-01-31T02:10:34.069Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/4c/5f20508000ee0559bfa713b85c431b1cdc95d2913247ff9eb318e7fdff7b/langsmith-0.7.36.tar.gz", hash = "sha256:d18ef34819e0a252cf52c74ce6e9bd5de6deea4f85a3aef50abc9f48d8c5f8b8", size = 4402322, upload-time = "2026-04-24T16:58:06.681Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/9a/5bc17ea3c746363e73c11df5e89068fea1ed175ca9c00fdb6886efc35b21/langsmith-0.6.7-py3-none-any.whl", hash = "sha256:4bd4372b8bf724b86314f64644562b5598407614e04e74b536c09490d153bd61", size = 309369, upload-time = "2026-01-31T02:10:32.6Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/3ca31ae3a4a437191243ad6d9061ede9367440bb7dc9a0da1ecc2c2a4865/langsmith-0.7.36-py3-none-any.whl", hash = "sha256:e1657a795f3f1982bb8d34c98b143b630ca3eee9de2c10e670c9105233b54654", size = 381808, upload-time = "2026-04-24T16:58:04.572Z" }, ] [[package]] @@ -2449,14 +2526,14 @@ wheels = [ [[package]] name = "mako" -version = "1.3.10" +version = "1.3.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" }, ] [[package]] @@ -2559,6 +2636,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "matrix-nio" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "aiohttp-socks" }, + { name = "h11" }, + { name = "h2" }, + { name = "jsonschema" }, + { name = "pycryptodome" }, + { name = "unpaddedbase64" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/50/c20129fd6f0e1aad3510feefd3229427fc8163a111f3911ed834e414116b/matrix_nio-0.25.2.tar.gz", hash = "sha256:8ef8180c374e12368e5c83a692abfb3bab8d71efcd17c5560b5c40c9b6f2f600", size = 155480, upload-time = "2024-10-04T07:51:41.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0f/8b958d46e23ed4f69d2cffd63b46bb097a1155524e2e7f5c4279c8691c4a/matrix_nio-0.25.2-py3-none-any.whl", hash = "sha256:9c2880004b0e475db874456c0f79b7dd2b6285073a7663bcaca29e0754a67495", size = 181982, upload-time = "2024-10-04T07:51:39.451Z" }, +] + [[package]] name = "mcp" version = "1.26.0" @@ -3502,89 +3598,89 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, - { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, - { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, - { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, - { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, - { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, - { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, - { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, - { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, - { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, - { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, - { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, - { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, - { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] @@ -4292,7 +4388,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -4301,9 +4397,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -4369,11 +4465,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, ] [[package]] @@ -5598,6 +5694,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, ] +[[package]] +name = "unpaddedbase64" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/114266b21a7a9e3d09b352bb63c9d61d918bb7aa35d08c722793bfbfd28f/unpaddedbase64-2.1.0.tar.gz", hash = "sha256:7273c60c089de39d90f5d6d4a7883a79e319dc9d9b1c8924a7fab96178a5f005", size = 5621, upload-time = "2021-03-09T11:35:47.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl", hash = "sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6", size = 6083, upload-time = "2021-03-09T11:35:46.7Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -5638,28 +5743,28 @@ wheels = [ [[package]] name = "uv" -version = "0.9.28" +version = "0.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/7d/005ab1cab03ca928cef75b424284d14d62c5f18775cf8114a63f210a0c9c/uv-0.9.28.tar.gz", hash = "sha256:253c04b26fb40f74c56ead12ce83db3c018bdefde1fcd1a542bcb88fdca4189c", size = 3834456, upload-time = "2026-01-29T20:15:49.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/7d/17750123a8c8e324627534fe1ae2e7a46689db8492f1a834ab4fd229a7d8/uv-0.11.7.tar.gz", hash = "sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405", size = 4083385, upload-time = "2026-04-15T21:42:55.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/dc/e70698756f1bb74c88bf1eaea63a114a580a38f296ea1567a01db9007490/uv-0.9.28-py3-none-linux_armv6l.whl", hash = "sha256:aede961243bb2c0ca09d0e04ea0bf580d7128dd3b14661b79d133be9a5b69894", size = 22040477, upload-time = "2026-01-29T20:16:11.24Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ed/77294752bf722e1d6b666bd6592b6ac975dabcf1fde49e98a75cac23d45c/uv-0.9.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fe9aa2822d24f6ecec035a06dfdd1fbed570ed40b83a864e71714bad37ddfd3", size = 21025194, upload-time = "2026-01-29T20:15:36.504Z" }, - { url = "https://files.pythonhosted.org/packages/b1/a9/78f2da6217c1bbae3371d68515fe747e1160bab049d6898a03e517802573/uv-0.9.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:58a36bf623c6d36b3d60d3c76eeb7275199d607938786e927d40ce213980059d", size = 19783994, upload-time = "2026-01-29T20:16:19.451Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/55639c444e91b96c81c326d39a0a06551d2e611be0cc917b89010ba9ba88/uv-0.9.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4d479a1d387b1464ad2c1f960b0b26a9ac1dfba67ea2c6789e9643fe6d1e7b9a", size = 21568230, upload-time = "2026-01-29T20:15:39.35Z" }, - { url = "https://files.pythonhosted.org/packages/14/2e/95d7992c0a39981cfbcf56ff8f069c09e0567feb0e70cb8b52bc8a2947a0/uv-0.9.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:29eefd4642f55954a2b9a40619cde3d02856300f59b8cf63ed1a161ca0ca9b77", size = 21633679, upload-time = "2026-01-29T20:15:52.363Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/b6778e03714b1f9da095c8bf0f8e5007f4867d9196c1ae8053504ddf2877/uv-0.9.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4155496f624deb753f5ddd80fbe3797587c8480d1250e83c9fd816b4b02e3a41", size = 21632238, upload-time = "2026-01-29T20:15:55.003Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f8/0db6ea9fd8f2752a8723a637e3ed881eb212516665ccb2e8066bbea62a52/uv-0.9.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dc98e2d6db0dc9a2f65ce4cda6a34283fa80f3fbfff129befdf40ad7a3d1615", size = 22779474, upload-time = "2026-01-29T20:15:33.513Z" }, - { url = "https://files.pythonhosted.org/packages/54/88/ef70e04113393f4e19e67281cae9f83c82030d14eb4eb811bda83fcd8f44/uv-0.9.28-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d267280b3878aa6ef8e00bff1f11bf61580d0a8bbb69fa95b5d3526d00f77485", size = 24124596, upload-time = "2026-01-29T20:16:05.062Z" }, - { url = "https://files.pythonhosted.org/packages/81/07/9fda9149bc57e79bde5f00cabcef323a68817c1cca9d44e2aa08d18c6b52/uv-0.9.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba2a320ff77996468789f4b2c573fd766f9330717c440335af8790043b2b3703", size = 23655701, upload-time = "2026-01-29T20:16:07.735Z" }, - { url = "https://files.pythonhosted.org/packages/18/b5/1f1e910ca1a0aca0d0ede3ba0eaca867fd3c575f44b2fe103a5c9511f071/uv-0.9.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c8fd93c5bee89ed88908215f81a3baa0d2a98e35caf995b97e9c226c1c29340", size = 22856456, upload-time = "2026-01-29T20:16:16.582Z" }, - { url = "https://files.pythonhosted.org/packages/9a/fd/82561751105ed232f1781747bc336b20e8d57ee07b4d2ed3fa6cf2718d71/uv-0.9.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b8460a2b624d8ab27cb293a2c9f2393f9efc4e36e0fb886a6c2360e23fb48be", size = 22685296, upload-time = "2026-01-29T20:16:13.857Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e4/b905daff0bfde347c49b9c9ba31d09d504c4b84f2749a07db77a9da16dba/uv-0.9.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3798c486ec627bbd7ca41fa219e997ad403b1f803371edf5c8e75893e46161ba", size = 21669854, upload-time = "2026-01-29T20:15:30.277Z" }, - { url = "https://files.pythonhosted.org/packages/9a/01/9a90574fe7290c775332e54f163cba58c767445b655e97646708f9c66050/uv-0.9.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e479cc5cbfd72ebdbea3c909d0ab997162e0dfa1ee622b50e2f9dc8d07d4eee3", size = 22388944, upload-time = "2026-01-29T20:15:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/cc35014bab3c17b4fe8f6bae84e640ce64d9bb4c8a24694a935e0c0af538/uv-0.9.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:97d61cdf2436e83a0f188d55d1974e46679d9a787c3a54cb0a40de717c6bf435", size = 22073327, upload-time = "2026-01-29T20:15:58.119Z" }, - { url = "https://files.pythonhosted.org/packages/26/cd/e848570be5c5be4e139b90237cc64f68d5d51e8e92c40a5ac7cf0c34ad4a/uv-0.9.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cbfa56c833caa37b1f14166327fcaf8aa87290451406921eb07296ffef17fef1", size = 22915580, upload-time = "2026-01-29T20:15:42.468Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/6c3d839ea289bf8509da32f703a47accd63ab409b33627728aebcd2a1b65/uv-0.9.28-py3-none-win32.whl", hash = "sha256:d5cb780d5b821f837f63e7fd14e2bf75f01824b4575a1e89639888771bfd9efd", size = 20856809, upload-time = "2026-01-29T20:15:45.141Z" }, - { url = "https://files.pythonhosted.org/packages/06/a8/d72229dd90d1e5a3c8368d51a70219018d579380945e67c8dcffbe8e53c0/uv-0.9.28-py3-none-win_amd64.whl", hash = "sha256:203ab59710c0c1b3c5ecc684f9cfc9264340a69c8706aaa8aea75415779f0d74", size = 23447461, upload-time = "2026-01-29T20:16:22.563Z" }, - { url = "https://files.pythonhosted.org/packages/23/df/5852eb0c59e5224f4cb0323906efae348f782f8a7f1069197e7cf6ec9b74/uv-0.9.28-py3-none-win_arm64.whl", hash = "sha256:c29406e1dc6b1b312c478c76b42b9f94b684855a4c001901b5488bab6ccf4ec7", size = 21860859, upload-time = "2026-01-29T20:16:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/2bb2ab6fe6c78c2be10852482ef0cae5f3171460a6e5e24c32c9a0843163/uv-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c", size = 23757265, upload-time = "2026-04-15T21:43:14.494Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/36ff27b01e60a88712628c8a5a6003b8e418883c24e084e506095844a797/uv-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a", size = 23184529, upload-time = "2026-04-15T21:42:30.225Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fa/f379be661316698f877e78f4c51e5044be0b6f390803387237ad92c4057f/uv-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9", size = 21780167, upload-time = "2026-04-15T21:42:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/fbed29775b0612f4f5679d3226268f1a347161abc1727b4080fb41d9f46f/uv-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71", size = 23609640, upload-time = "2026-04-15T21:42:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/ad/de/989a69634a869a22322770120557c2d8cbba5b77ec7cfad326b4ec0f0547/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b", size = 23322484, upload-time = "2026-04-15T21:43:26.52Z" }, + { url = "https://files.pythonhosted.org/packages/24/08/c1af05ea602eb4eb75d86badb6b0594cc104c3ca83ccf06d9ed4dd2186ad/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924", size = 23326385, upload-time = "2026-04-15T21:42:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/68/99/e246962da06383e992ecab55000c62a50fb36efef855ea7264fad4816bf4/uv-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0", size = 24985751, upload-time = "2026-04-15T21:42:37.777Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/b0b68083859579ce811996c1480765ec6a2442b44c451eaef53e6218fbae/uv-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d", size = 25724160, upload-time = "2026-04-15T21:43:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/4e/19/5970e89d9e458fd3c4966bbc586a685a1c0ab0a8bf334503f63fa20b925b/uv-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c", size = 25028512, upload-time = "2026-04-15T21:43:02.721Z" }, + { url = "https://files.pythonhosted.org/packages/83/eb/4e1557daf6693cb446ed28185664ad6682fd98c6dbac9e433cbc35df450a/uv-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d", size = 24933975, upload-time = "2026-04-15T21:42:18.828Z" }, + { url = "https://files.pythonhosted.org/packages/68/55/3b517ec8297f110d6981f525cccf26f86e30883fbb9c282769cffbcdcfca/uv-0.11.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605", size = 23706403, upload-time = "2026-04-15T21:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/dc/30/7d93a0312d60e147722967036dc8ea37baab4802784bddc22464cb707deb/uv-0.11.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9", size = 24495797, upload-time = "2026-04-15T21:42:26.538Z" }, + { url = "https://files.pythonhosted.org/packages/8c/89/d49480bdab7725d36982793857e461d471bde8e1b7f438ffccee677a7bf8/uv-0.11.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9", size = 24580471, upload-time = "2026-04-15T21:42:52.871Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9f/c57dc03b48be17b564e304eb9ff982890c12dfb888b1ce370788733329ab/uv-0.11.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f", size = 24113637, upload-time = "2026-04-15T21:42:34.094Z" }, + { url = "https://files.pythonhosted.org/packages/13/ba/b87e358b629a68258527e3490e73b7b148770f4d2257842dea3b7981d4e8/uv-0.11.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce", size = 25119850, upload-time = "2026-04-15T21:43:22.529Z" }, + { url = "https://files.pythonhosted.org/packages/4b/74/16d229e1d8574bcbafa6dc643ac20b70c3e581f42ac31a6f4fd53035ffe3/uv-0.11.7-py3-none-win32.whl", hash = "sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4", size = 22918454, upload-time = "2026-04-15T21:42:58.702Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1d/b73e473da616ac758b8918fb218febcc46ddf64cba9e03894dfa226b28bd/uv-0.11.7-py3-none-win_amd64.whl", hash = "sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d", size = 25447744, upload-time = "2026-04-15T21:42:48.813Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/e6bfdea92ed270f3445a5a3c17599d041b3f2dbc5026c09e02830a03bbaf/uv-0.11.7-py3-none-win_arm64.whl", hash = "sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1", size = 23941816, upload-time = "2026-04-15T21:43:06.732Z" }, ] [[package]] @@ -5852,6 +5957,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0" diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index e0543baa..8440e47a 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -174,11 +174,14 @@ export default function BotDetailContent({ id }: { id: string }) { )} - {activeTab === 'config' && ( - - )} + {/* Horizontal Tabs */} diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 2c97e5be..3b0ec3de 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -618,6 +618,8 @@ export default function BotForm({ systemContext={{ webhook_url: webhookUrl, extra_webhook_url: extraWebhookUrl, + bot_uuid: initBotId || '', + adapter_config: form.getValues('adapter_config') || {}, }} /> )} diff --git a/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx b/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx index 11aff19e..86c9d3db 100644 --- a/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx +++ b/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; +import { copyToClipboard } from '@/app/utils/clipboard'; const LEVEL_STYLES: Record = { error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', @@ -31,36 +32,19 @@ export function BotLogCard({ function copySessionId() { const text = botLog.message_session_id; - if (navigator.clipboard?.writeText) { - navigator.clipboard - .writeText(text) - .then(() => { + copyToClipboard(text) + .then((ok) => { + if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success(t('common.copySuccess')); - }) - .catch(() => fallbackCopy(text)); - } else { - fallbackCopy(text); - } - } - - function fallbackCopy(text: string) { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - try { - document.execCommand('copy'); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - toast.success(t('common.copySuccess')); - } catch { - toast.error(t('common.copyFailed')); - } - document.body.removeChild(ta); + } else { + toast.error(t('common.copyFailed')); + } + }) + .catch(() => { + toast.error(t('common.copyFailed')); + }); } function formatTime(timestamp: number) { diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index 9b7d8928..e38da389 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -19,6 +19,7 @@ import { ThumbsUp, ThumbsDown, } from 'lucide-react'; +import { copyToClipboard } from '@/app/utils/clipboard'; import { MessageChainComponent, Plain, @@ -108,10 +109,9 @@ const BotSessionMonitor = forwardRef< }; const copyUserId = (userId: string) => { - navigator.clipboard.writeText(userId).then(() => { - setCopiedUserId(true); - setTimeout(() => setCopiedUserId(false), 2000); - }); + copyToClipboard(userId).catch(() => {}); + setCopiedUserId(true); + setTimeout(() => setCopiedUserId(false), 2000); }; const loadSessions = useCallback(async () => { diff --git a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx index bc9bf67a..8ac3f496 100644 --- a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx +++ b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Copy, Check, Trash2, Plus } from 'lucide-react'; @@ -86,6 +86,9 @@ export default function ApiIntegrationDialog({ const [newWebhookDescription, setNewWebhookDescription] = useState(''); const [newWebhookEnabled, setNewWebhookEnabled] = useState(true); const [deleteWebhookId, setDeleteWebhookId] = useState(null); + const copiedTimerRef = useRef | undefined>( + undefined, + ); const [copiedKey, setCopiedKey] = useState(null); // Sync URL with dialog state @@ -182,10 +185,29 @@ export default function ApiIntegrationDialog({ } }; + const copyToClipboard = (text: string) => { + const el = document.createElement('span'); + el.textContent = text; + el.style.cssText = + 'position:fixed;opacity:0;pointer-events:none;white-space:pre;'; + document.body.appendChild(el); + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + document.execCommand('copy'); + sel?.removeAllRanges(); + document.body.removeChild(el); + }; + const handleCopyKey = (key: string) => { - navigator.clipboard.writeText(key); + try { + copyToClipboard(key); + } catch {} + clearTimeout(copiedTimerRef.current); setCopiedKey(key); - setTimeout(() => setCopiedKey(null), 2000); + copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000); }; const maskApiKey = (key: string) => { @@ -330,21 +352,21 @@ export default function ApiIntegrationDialog({ - {apiKeys.map((key) => ( - + {apiKeys.map((item) => ( +
-
{key.name}
- {key.description && ( +
{item.name}
+ {item.description && (
- {key.description} + {item.description}
)}
- {maskApiKey(key.key)} + {maskApiKey(item.key)} @@ -352,10 +374,11 @@ export default function ApiIntegrationDialog({ - - - - - - - - - {/* Delete Confirmation Dialog */} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index e714d85b..75c9f47f 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -18,6 +18,7 @@ import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Copy, Check, Globe } from 'lucide-react'; +import { copyToClipboard } from '@/app/utils/clipboard'; import { systemInfo } from '@/app/infra/http'; /** @@ -44,6 +45,54 @@ function resolveShowIfValue( return externalDependentValues?.[field]; } +/** + * Display-only component for embed code fields with copy animation. + */ +function EmbedCodeField({ + label, + description, + snippet, +}: { + label: string; + description?: string; + snippet: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + copyToClipboard(snippet).catch(() => {}); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ + {description && ( +

{description}

+ )} +
+
+          {snippet}
+        
+ +
+
+ ); +} + /** * Display-only component for webhook URL fields. * Rendered outside of react-hook-form binding since the value is @@ -65,15 +114,9 @@ function WebhookUrlField({ const { t } = useTranslation(); const handleCopy = (text: string, setter: (v: boolean) => void) => { - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard - .writeText(text) - .then(() => { - setter(true); - setTimeout(() => setter(false), 2000); - }) - .catch(() => {}); - } + copyToClipboard(text).catch(() => {}); + setter(true); + setTimeout(() => setter(false), 2000); }; return ( @@ -203,10 +246,13 @@ export default function DynamicFormComponent({ return value; }; - // Filter out display-only field types (e.g. webhook-url) that should not + // Filter out display-only field types (e.g. webhook-url, embed-code) that should not // participate in form state, validation, or value emission. const editableItems = useMemo( - () => itemConfigList.filter((item) => item.type !== 'webhook-url'), + () => + itemConfigList.filter( + (item) => item.type !== 'webhook-url' && item.type !== 'embed-code', + ), [itemConfigList], ); @@ -447,6 +493,36 @@ export default function DynamicFormComponent({ ); } + if (config.type === 'embed-code') { + const botUuid = (systemContext?.bot_uuid as string) || ''; + if (!botUuid) return null; + + const baseUrl = + import.meta.env.VITE_API_BASE_URL || window.location.origin; + const widgetTitle = + ((systemContext?.adapter_config as Record) + ?.title as string) || 'LangBot'; + const safeTitle = widgetTitle + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + const embedSnippet = `