mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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 <rockchinq@gmail.com>
This commit is contained in:
28
README.md
28
README.md
@@ -82,19 +82,21 @@ docker compose up -d
|
|||||||
|
|
||||||
## Supported Platforms
|
## Supported Platforms
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
| -------- | ------ | -------------------------------------- |
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Official |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Official |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Official |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Official |
|
||||||
| QQ | ✅ | Personal & Official API |
|
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
||||||
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||||
| WeChat | ✅ | Personal & Official Account |
|
| WeChat | ✅ | Personal & Official Account |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Official |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Official |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Official |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
17
README_CN.md
17
README_CN.md
@@ -87,13 +87,16 @@ docker compose up -d
|
|||||||
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 个人微信、微信公众号 |
|
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||||
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||||
| 飞书 | ✅ | |
|
| 飞书 | ✅ | 官方 |
|
||||||
| 钉钉 | ✅ | |
|
| 钉钉 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| Satori | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Discord | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
| Telegram | ✅ | 官方 |
|
||||||
| LINE | ✅ | |
|
| Slack | ✅ | 官方 |
|
||||||
| KOOK | ✅ | |
|
| LINE | ✅ | 官方 |
|
||||||
|
| KOOK | ✅ | 官方 |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_ES.md
18
README_ES.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plataforma | Estado | Notas |
|
| Plataforma | Estado | Notas |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Oficial |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Oficial |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Oficial |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Oficial |
|
||||||
| QQ | ✅ | Personal y API Oficial |
|
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
||||||
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
||||||
| WeChat | ✅ | Personal y Cuenta Oficial |
|
| WeChat | ✅ | Personal y Cuenta Oficial |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Oficial |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Oficial |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Oficial |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_FR.md
18
README_FR.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Plateforme | Statut | Notes |
|
| Plateforme | Statut | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Officiel |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Officiel |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Officiel |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Officiel |
|
||||||
| QQ | ✅ | Personnel & API Officielle |
|
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
||||||
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||||
| WeChat | ✅ | Personnel & Compte Officiel |
|
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Officiel |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Officiel |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Officiel |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
20
README_JP.md
20
README_JP.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 公式 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 公式 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 公式 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 公式 |
|
||||||
| QQ | ✅ | 個人 & 公式API |
|
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
||||||
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
||||||
| WeChat | ✅ | 個人 & 公式アカウント |
|
| WeChat | ✅ | 個人・公式アカウント |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 公式 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 公式 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 公式 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix、Satori |
|
||||||
|
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_KO.md
18
README_KO.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 플랫폼 | 상태 | 비고 |
|
| 플랫폼 | 상태 | 비고 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | 공식 |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | 공식 |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | 공식 |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | 공식 |
|
||||||
| QQ | ✅ | 개인 및 공식 API |
|
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
||||||
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
||||||
| WeChat | ✅ | 개인 및 공식 계정 |
|
| WeChat | ✅ | 개인 및 공식 계정 |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | 공식 |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | 공식 |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | 공식 |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_RU.md
18
README_RU.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Платформа | Статус | Примечания |
|
| Платформа | Статус | Примечания |
|
||||||
|-----------|--------|------------|
|
|-----------|--------|------------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Официальный |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Официальный |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Официальный |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Официальный |
|
||||||
| QQ | ✅ | Личный и официальный API |
|
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
|
||||||
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
||||||
| WeChat | ✅ | Личный и официальный аккаунт |
|
| WeChat | ✅ | Личный и официальный аккаунт |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Официальный |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Официальный |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Официальный |
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | Matrix, Satori |
|
||||||
|
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_TW.md
18
README_TW.md
@@ -85,17 +85,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| Discord | ✅ | 官方 |
|
||||||
|
| Telegram | ✅ | 官方 |
|
||||||
|
| Slack | ✅ | 官方 |
|
||||||
|
| LINE | ✅ | 官方 |
|
||||||
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||||
| 微信 | ✅ | 個人微信、微信公眾號 |
|
|
||||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||||
| 飛書 | ✅ | |
|
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||||
| 釘釘 | ✅ | |
|
| 飛書 | ✅ | 官方 |
|
||||||
| Discord | ✅ | |
|
| 釘釘 | ✅ | 官方 |
|
||||||
| Telegram | ✅ | |
|
| KOOK | ✅ | 官方 |
|
||||||
| Slack | ✅ | |
|
|
||||||
| LINE | ✅ | |
|
|
||||||
| KOOK | ✅ | |
|
|
||||||
| Satori | ✅ | |
|
| Satori | ✅ | |
|
||||||
|
| Email | ✅ | 只 Matrix、Satori |
|
||||||
|
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_VI.md
18
README_VI.md
@@ -83,17 +83,19 @@ docker compose up -d
|
|||||||
|
|
||||||
| Nền tảng | Trạng thái | Ghi chú |
|
| Nền tảng | Trạng thái | Ghi chú |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | Chính thức |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | Chính thức |
|
||||||
| Slack | ✅ | |
|
| Slack | ✅ | Chính thức |
|
||||||
| LINE | ✅ | |
|
| LINE | ✅ | Chính thức |
|
||||||
| QQ | ✅ | Cá nhân & API 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 |
|
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
||||||
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
||||||
| Lark | ✅ | |
|
| Lark | ✅ | Chính thức |
|
||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | Chính thức |
|
||||||
| KOOK | ✅ | |
|
| KOOK | ✅ | Chính thức |
|
||||||
| Satori | ✅ | |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ dependencies = [
|
|||||||
"langbot-plugin==0.3.10",
|
"langbot-plugin==0.3.10",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
|
"matrix-nio>=0.25.2",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"pymilvus>=2.6.4",
|
"pymilvus>=2.6.4",
|
||||||
|
|||||||
BIN
src/langbot/pkg/platform/sources/matrix.png
Normal file
BIN
src/langbot/pkg/platform/sources/matrix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
693
src/langbot/pkg/platform/sources/matrix.py
Normal file
693
src/langbot/pkg/platform/sources/matrix.py
Normal file
@@ -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]
|
||||||
123
src/langbot/pkg/platform/sources/matrix.yaml
Normal file
123
src/langbot/pkg/platform/sources/matrix.yaml
Normal file
@@ -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
|
||||||
49
uv.lock
generated
49
uv.lock
generated
@@ -36,11 +36,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/c2/91/e4bf8584695b065e2
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiofiles"
|
name = "aiofiles"
|
||||||
version = "25.1.0"
|
version = "24.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "aioshutil"
|
name = "aioshutil"
|
||||||
version = "1.6"
|
version = "1.6"
|
||||||
@@ -1873,6 +1886,7 @@ dependencies = [
|
|||||||
{ name = "line-bot-sdk" },
|
{ name = "line-bot-sdk" },
|
||||||
{ name = "mako" },
|
{ name = "mako" },
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
|
{ name = "matrix-nio" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "nakuru-project-idk" },
|
{ name = "nakuru-project-idk" },
|
||||||
@@ -1957,6 +1971,7 @@ requires-dist = [
|
|||||||
{ name = "line-bot-sdk", specifier = ">=3.19.0" },
|
{ name = "line-bot-sdk", specifier = ">=3.19.0" },
|
||||||
{ name = "mako", specifier = ">=1.3.11" },
|
{ name = "mako", specifier = ">=1.3.11" },
|
||||||
{ name = "markdown", specifier = ">=3.6" },
|
{ name = "markdown", specifier = ">=3.6" },
|
||||||
|
{ name = "matrix-nio", specifier = ">=0.25.2" },
|
||||||
{ name = "mcp", specifier = ">=1.25.0" },
|
{ name = "mcp", specifier = ">=1.25.0" },
|
||||||
{ name = "mypy", specifier = ">=1.16.0" },
|
{ name = "mypy", specifier = ">=1.16.0" },
|
||||||
{ name = "nakuru-project-idk", specifier = ">=0.0.2.1" },
|
{ 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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "1.26.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.6.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user