From 547006cb4a56f9ee50238a703a189c56367f8478 Mon Sep 17 00:00:00 2001 From: Haoxuan Xing Date: Sat, 2 May 2026 21:04:49 +0800 Subject: [PATCH] feat: add supports for Matrix protocol(#2110) * Optimize the plugin system * feat: enhance plugin installation process and improve task management * fix: linter err * feat: add Matrix adapter with multi-bridge support - MatrixAdapter with text/image/file message support - Multi-bridge architecture (BridgeState) for Discord, Telegram, etc. - Auto-login, QR forwarding, disconnect detection - Force logout+login on adapter start - Group/private chat detection excluding bridge bots - matrix-nio dependency added * docs: sync platform tables across all READMEs with Matrix bridge support - Add Matrix/Satori compatibility notes to all platforms - Add 21 Matrix-only platforms (Signal, WhatsApp, Messenger, etc.) - Keep international market ordering (Discord first) for non-CN READMEs * Update API base URL to localhost * fix: remove unused datetime import (ruff) * style: ruff format matrix.py * docs: collapse matrix platform list * docs: simplify platform compatibility notes --------- Co-authored-by: Junyan Qin --- README.md | 28 +- README_CN.md | 17 +- README_ES.md | 18 +- README_FR.md | 18 +- README_JP.md | 20 +- README_KO.md | 18 +- README_RU.md | 18 +- README_TW.md | 18 +- README_VI.md | 18 +- pyproject.toml | 1 + src/langbot/pkg/platform/sources/matrix.png | Bin 0 -> 3462 bytes src/langbot/pkg/platform/sources/matrix.py | 693 +++++++++++++++++++ src/langbot/pkg/platform/sources/matrix.yaml | 123 ++++ uv.lock | 49 +- 14 files changed, 959 insertions(+), 80 deletions(-) create mode 100644 src/langbot/pkg/platform/sources/matrix.png create mode 100644 src/langbot/pkg/platform/sources/matrix.py create mode 100644 src/langbot/pkg/platform/sources/matrix.yaml diff --git a/README.md b/README.md index ed4674ba..4018e9b9 100644 --- a/README.md +++ b/README.md @@ -82,19 +82,21 @@ docker compose up -d ## Supported Platforms -| Platform | Status | Notes | -| -------- | ------ | -------------------------------------- | -| Discord | ✅ | | -| Telegram | ✅ | | -| Slack | ✅ | | -| LINE | ✅ | | -| QQ | ✅ | Personal & Official API | -| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot | -| WeChat | ✅ | Personal & Official Account | -| Lark | ✅ | | -| DingTalk | ✅ | | -| KOOK | ✅ | | -| Satori | ✅ | | +| Platform | Status | Notes | +|----------|--------|-------| +| 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 | ✅ | 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 | --- diff --git a/README_CN.md b/README_CN.md index 1a1bec12..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 等 | --- diff --git a/README_ES.md b/README_ES.md index 1e1c456e..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 | --- diff --git a/README_FR.md b/README_FR.md index 678bc9c5..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. | --- diff --git a/README_JP.md b/README_JP.md index 14b96f20..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 など複数のブリッジ先プラットフォームに対応 | --- diff --git a/README_KO.md b/README_KO.md index 77d2c37a..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 등 여러 브리지 플랫폼 지원 | --- diff --git a/README_RU.md b/README_RU.md index b4ca7869..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 и другие | --- diff --git a/README_TW.md b/README_TW.md index 6dc7bba1..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 等 | --- diff --git a/README_VI.md b/README_VI.md index d5f1b347..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 | --- diff --git a/pyproject.toml b/pyproject.toml index a4170ae9..62cdd473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dependencies = [ "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/pkg/platform/sources/matrix.png b/src/langbot/pkg/platform/sources/matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..56eb45ee629d6775e1b99c638ca8a539a381cec7 GIT binary patch literal 3462 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 literal 0 HcmV?d00001 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/uv.lock b/uv.lock index 624e06ce..b3c130d9 100644 --- a/uv.lock +++ b/uv.lock @@ -36,11 +36,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 } +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 }, + { 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]] @@ -154,6 +154,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441 }, ] +[[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]] name = "aioshutil" version = "1.6" @@ -1873,6 +1886,7 @@ dependencies = [ { name = "line-bot-sdk" }, { name = "mako" }, { name = "markdown" }, + { name = "matrix-nio" }, { name = "mcp" }, { name = "mypy" }, { name = "nakuru-project-idk" }, @@ -1957,6 +1971,7 @@ requires-dist = [ { 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" }, @@ -2549,6 +2564,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 }, ] +[[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" @@ -5570,6 +5604,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 }, ] +[[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"