mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
1 Commits
v4.6.2
...
feat/teams
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3740eed9e |
@@ -84,7 +84,7 @@ docker compose up -d
|
||||
## ✨ 特性
|
||||
|
||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
|
||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
|
||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
|
||||
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
|
||||
- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
|
||||
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
|
||||
@@ -108,7 +108,6 @@ docker compose up -d
|
||||
| 微信公众号 | ✅ | |
|
||||
| 飞书 | ✅ | |
|
||||
| 钉钉 | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
|
||||
@@ -80,7 +80,7 @@ Click the Star and Watch button in the upper right corner of the repository to g
|
||||
## ✨ Features
|
||||
|
||||
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
|
||||
- 🧩 Plugin Extension, Active Community: High stability, high security production-level plugin system; Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
|
||||
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
|
||||
@@ -107,7 +107,6 @@ Or visit the demo environment: https://demo.langbot.dev/
|
||||
| Personal WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
|
||||
## ✨ Características
|
||||
|
||||
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios.
|
||||
- 🧩 Extensión de Plugin, Comunidad Activa: Sistema de plugin de alta estabilidad, alta seguridad de nivel de producción; Compatible con mecanismos de plugin impulsados por eventos, extensión de componentes, etc.; Integración del protocolo [MCP](https://modelcontextprotocol.io/) de Anthropic; Actualmente cuenta con cientos de plugins.
|
||||
- 😻 Interfaz Web: Admite la gestión de instancias de LangBot a través del navegador. No es necesario escribir archivos de configuración manualmente.
|
||||
@@ -107,7 +107,6 @@ O visite el entorno de demostración: https://demo.langbot.dev/
|
||||
| WeChat Personal | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios.
|
||||
- 🧩 Extension de Plugin, Communauté Active : Système de plugin de haute stabilité, haute sécurité de niveau production; Prend en charge les mécanismes de plugin pilotés par événements, l'extension de composants, etc. ; Intégration du protocole [MCP](https://modelcontextprotocol.io/) d'Anthropic ; Dispose actuellement de centaines de plugins.
|
||||
- 😻 Interface Web : Prend en charge la gestion des instances LangBot via le navigateur. Pas besoin d'écrire manuellement les fichiers de configuration.
|
||||
@@ -107,7 +107,6 @@ Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
|
||||
| WeChat Personnel | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
||||
## ✨ 機能
|
||||
|
||||
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG(知識ベース)を組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
|
||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。
|
||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
|
||||
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
|
||||
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
|
||||
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
|
||||
@@ -107,7 +107,6 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
||||
| 個人WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
|
||||
## ✨ 기능
|
||||
|
||||
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다.
|
||||
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
|
||||
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram 등을 지원합니다.
|
||||
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.
|
||||
- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
|
||||
- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
|
||||
@@ -105,7 +105,6 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| 개인 WeChat | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
|
||||
## ✨ Функции
|
||||
|
||||
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다.
|
||||
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д.
|
||||
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram и т.д.
|
||||
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.
|
||||
- 🧩 Расширение плагинов, активное сообщество: Высокая стабильность, высокая безопасность уровня производства; Поддержка механизмов плагинов, управляемых событиями, расширения компонентов и т.д.; Интеграция протокола [MCP](https://modelcontextprotocol.io/) от Anthropic; В настоящее время сотни плагинов.
|
||||
- 😻 Веб-интерфейс: Поддержка управления экземплярами LangBot через браузер. Нет необходимости вручную писать конфигурационные файлы.
|
||||
@@ -105,7 +105,6 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| Личный WeChat | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ docker compose up -d
|
||||
## ✨ 特性
|
||||
|
||||
- 💬 大模型對話、Agent:支援多種大模型,適配群聊和私聊;具有多輪對話、工具調用、多模態、流式輸出能力,自帶 RAG(知識庫)實現,並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
|
||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。
|
||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
|
||||
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
|
||||
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
|
||||
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
|
||||
@@ -105,7 +105,6 @@ docker compose up -d
|
||||
| 企微對外客服 | ✅ | |
|
||||
| 企微智能機器人 | ✅ | |
|
||||
| 微信公眾號 | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t
|
||||
## ✨ Tính năng
|
||||
|
||||
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms.
|
||||
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v.
|
||||
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, v.v.
|
||||
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau.
|
||||
- 🧩 Mở rộng Plugin, Cộng đồng Hoạt động: Hỗ trợ các cơ chế plugin hướng sự kiện, mở rộng thành phần, v.v.; Tích hợp giao thức [MCP](https://modelcontextprotocol.io/) của Anthropic; Hiện có hàng trăng plugin.
|
||||
- 😻 Giao diện Web: Hỗ trợ quản lý các phiên bản LangBot thông qua trình duyệt. Không cần viết tệp cấu hình thủ công.
|
||||
@@ -105,7 +105,6 @@ Hoặc truy cập môi trường demo: https://demo.langbot.dev/
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| WeChat Cá nhân | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
162
TEAMS_ADAPTER_IMPLEMENTATION.md
Normal file
162
TEAMS_ADAPTER_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Microsoft Teams Adapter Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
A new Microsoft Teams platform adapter has been added to LangBot, enabling support for both personal chats and channel/group chats on Microsoft Teams.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
|
||||
1. **`src/langbot/pkg/platform/sources/teams.py`** - Main adapter implementation
|
||||
- `TeamsMessageConverter`: Converts between LangBot message format and Teams Activity format
|
||||
- `TeamsEventConverter`: Converts between Teams Activity events and LangBot platform events
|
||||
- `TeamsAdapter`: Main adapter class with webhook handling
|
||||
|
||||
2. **`src/langbot/pkg/platform/sources/teams.yaml`** - Adapter manifest
|
||||
- Defines adapter metadata, configuration schema, and execution details
|
||||
- Configuration fields: `app_id` and `app_password`
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. **`pyproject.toml`** - Added dependencies:
|
||||
- `botbuilder-core>=4.15.0`
|
||||
- `botbuilder-schema>=4.15.0`
|
||||
- `botframework-connector>=4.15.0`
|
||||
- Added "teams" keyword
|
||||
|
||||
## Features
|
||||
|
||||
### Supported Message Types
|
||||
|
||||
- ✅ Plain text messages
|
||||
- ✅ Image attachments (base64, URL, and file path)
|
||||
- ✅ @mentions (converted to At components)
|
||||
- ✅ Message replies with quote support
|
||||
|
||||
### Supported Chat Types
|
||||
|
||||
- ✅ Personal chats (1-on-1 conversations)
|
||||
- ✅ Channel chats (group/team conversations)
|
||||
|
||||
### Message Handling
|
||||
|
||||
- **Incoming Messages**: Received via webhook at the unified webhook endpoint
|
||||
- **Outgoing Messages**: Sent via Bot Framework SDK using conversation references
|
||||
- **Event Types**: FriendMessage (personal) and GroupMessage (channel/group)
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
### Azure Setup
|
||||
|
||||
1. **Register an Azure AD Application**:
|
||||
- Go to Azure Portal → Azure Active Directory → App registrations
|
||||
- Create a new registration
|
||||
- Note the **Application (client) ID** - this is your `app_id`
|
||||
- Create a **Client Secret** - this is your `app_password`
|
||||
|
||||
2. **Create Azure Bot Resource**:
|
||||
- Go to Azure Portal → Create a resource → Azure Bot
|
||||
- Link it to your Azure AD application
|
||||
- Enable the Microsoft Teams channel
|
||||
- Set the messaging endpoint to: `https://<your-domain>/bots/<bot-uuid>`
|
||||
|
||||
### LangBot Configuration
|
||||
|
||||
When creating a Teams bot in LangBot, provide:
|
||||
|
||||
```yaml
|
||||
adapter: teams
|
||||
adapter_config:
|
||||
app_id: "<your-microsoft-app-id>"
|
||||
app_password: "<your-microsoft-app-password>"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Webhook Mode
|
||||
|
||||
The Teams adapter operates in webhook mode, similar to the Slack adapter:
|
||||
- Integrates with LangBot's unified webhook system
|
||||
- Receives messages at `/bots/<bot-uuid>` endpoint
|
||||
- No independent server process required
|
||||
|
||||
### Message Flow
|
||||
|
||||
1. **Incoming**:
|
||||
- Teams → Azure Bot Service → LangBot Webhook
|
||||
- Bot Framework validates the request
|
||||
- Activity converted to LangBot event format
|
||||
- Event dispatched to registered listeners
|
||||
|
||||
2. **Outgoing**:
|
||||
- LangBot message chain → Teams Activity format
|
||||
- Reply sent via Bot Framework adapter
|
||||
- Uses conversation reference for proper routing
|
||||
|
||||
## Authentication
|
||||
|
||||
- Uses Bot Framework authentication with Microsoft App credentials
|
||||
- JWT token validation handled by `BotFrameworkAdapter`
|
||||
- Authorization header validated on each incoming request
|
||||
|
||||
## Limitations & Notes
|
||||
|
||||
1. **Direct Send**: The `send_message` method is limited - use `reply_message` for best results
|
||||
2. **Conversation References**: The adapter uses conversation references from incoming activities to send replies
|
||||
3. **Image Handling**: Images are converted to inline attachments using data URIs
|
||||
4. **Streaming**: Streaming replies are not yet implemented
|
||||
|
||||
## Testing
|
||||
|
||||
To test the adapter:
|
||||
|
||||
1. Install dependencies: `uv sync`
|
||||
2. Verify adapter initialization: `uv run python -c "from src.langbot.pkg.platform.sources.teams import TeamsAdapter; print('OK')"`
|
||||
3. Configure a Teams bot in LangBot with your Azure credentials
|
||||
4. Expose the LangBot API endpoint publicly (use ngrok or similar)
|
||||
5. Set the messaging endpoint in Azure Bot Service
|
||||
6. Add the bot to Teams and start chatting
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pydantic Validation Error on Initialization
|
||||
|
||||
**Fixed**: The adapter was updated to properly handle optional fields (`adapter`, `app`, `bot_uuid`) by:
|
||||
- Setting `default=None` in pydantic.Field definitions
|
||||
- Not passing these fields to `super().__init__()`
|
||||
- Setting the adapter instance after parent class initialization
|
||||
|
||||
This resolves the validation error: "Input should be an instance of Quart" / "Input should be a valid string"
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- Adaptive Cards support
|
||||
- Rich message formatting (markdown, cards)
|
||||
- File attachments (non-image)
|
||||
- Streaming message support
|
||||
- Proactive messaging
|
||||
- Team/channel member list retrieval
|
||||
- Reaction handling
|
||||
|
||||
## Dependencies
|
||||
|
||||
The following Python packages were added:
|
||||
- `botbuilder-core` - Core Bot Framework functionality
|
||||
- `botbuilder-schema` - Activity and entity schemas
|
||||
- `botframework-connector` - Bot Framework connector for authentication
|
||||
|
||||
## References
|
||||
|
||||
- [Microsoft Teams Bot Framework Documentation](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-features)
|
||||
- [Bot Framework SDK for Python](https://github.com/microsoft/botbuilder-python)
|
||||
- [Teams Conversation Bot Sample](https://learn.microsoft.com/en-us/samples/officedev/microsoft-teams-samples/officedev-microsoft-teams-samples-bot-conversation-python/)
|
||||
|
||||
## Sources
|
||||
|
||||
Research sources consulted during implementation:
|
||||
- [Create an Incoming Webhook - Teams | Microsoft Learn](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook)
|
||||
- [Teams Conversation Bot - Code Samples | Microsoft Learn](https://learn.microsoft.com/en-us/samples/officedev/microsoft-teams-samples/officedev-microsoft-teams-samples-bot-conversation-python/)
|
||||
- [GitHub - microsoft/botbuilder-python](https://github.com/microsoft/botbuilder-python)
|
||||
- [Tools and Bot Framework SDKs for Bots - Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-features)
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.6.2"
|
||||
version = "4.6.1"
|
||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -14,6 +14,9 @@ dependencies = [
|
||||
"anthropic>=0.51.0",
|
||||
"argon2-cffi>=23.1.0",
|
||||
"async-lru>=2.0.5",
|
||||
"botbuilder-core>=4.15.0",
|
||||
"botbuilder-schema>=4.15.0",
|
||||
"botframework-connector>=4.15.0",
|
||||
"certifi>=2025.4.26",
|
||||
"colorlog~=6.6.0",
|
||||
"cryptography>=44.0.3",
|
||||
@@ -73,6 +76,7 @@ keywords = [
|
||||
"bot",
|
||||
"agent",
|
||||
"telegram",
|
||||
"teams",
|
||||
"plugins",
|
||||
"openai",
|
||||
"instant-messaging",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
|
||||
|
||||
__version__ = '4.6.2'
|
||||
__version__ = '4.6.1'
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('webhook_mgmt', '/api/v1/webhooks')
|
||||
class WebhookManagementRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhooks = await self.ap.webhook_service.get_webhooks()
|
||||
return self.success(data={'webhooks': webhooks})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
url = json_data.get('url', '')
|
||||
description = json_data.get('description', '')
|
||||
enabled = json_data.get('enabled', True)
|
||||
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
if not url:
|
||||
return self.http_status(400, -1, 'URL is required')
|
||||
|
||||
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(webhook_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
return self.http_status(404, -1, 'Webhook not found')
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
url = json_data.get('url')
|
||||
description = json_data.get('description')
|
||||
enabled = json_data.get('enabled')
|
||||
|
||||
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.webhook_service.delete_webhook(webhook_id)
|
||||
return self.success()
|
||||
@@ -66,27 +66,22 @@ class RuntimeBot:
|
||||
message_session_id=f'person_{event.sender.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
# Push to webhooks
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
asyncio.create_task(
|
||||
self.ap.webhook_pusher.push_person_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
|
||||
)
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=event.sender.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
else:
|
||||
await self.logger.info(f'Pipeline skipped for person message due to webhook response')
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=event.sender.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
|
||||
async def on_group_message(
|
||||
event: platform_events.GroupMessage,
|
||||
@@ -102,27 +97,22 @@ class RuntimeBot:
|
||||
message_session_id=f'group_{event.group.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
# Push to webhooks
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
asyncio.create_task(
|
||||
self.ap.webhook_pusher.push_group_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
|
||||
)
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=event.group.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
else:
|
||||
await self.logger.info(f'Pipeline skipped for group message due to webhook response')
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=event.group.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,682 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
import zlib
|
||||
import traceback
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
import websockets
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Convert between LangBot MessageChain and KOOK message format"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:
|
||||
"""
|
||||
Convert LangBot MessageChain to KOOK message format
|
||||
|
||||
Returns:
|
||||
tuple: (content, message_type)
|
||||
- content: message content string
|
||||
- message_type: 1=text, 2=image, 4=file, 9=KMarkdown
|
||||
"""
|
||||
content_parts = []
|
||||
message_type = 1 # Default to text
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
content_parts.append(component.text)
|
||||
elif isinstance(component, platform_message.At):
|
||||
# KOOK mention format: (met)user_id(met)
|
||||
if component.target:
|
||||
content_parts.append(f'(met){component.target}(met)')
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
# KOOK @all format: (met)all(met)
|
||||
content_parts.append('(met)all(met)')
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# For images, we need to upload first via KOOK's asset API
|
||||
# For now, we'll send the image URL if available
|
||||
if component.url:
|
||||
content_parts.append(component.url)
|
||||
message_type = 2 # Image message type
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
# Handle forward messages by concatenating content
|
||||
for node in component.node_list:
|
||||
forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)
|
||||
content_parts.append(forward_content)
|
||||
# Ignore Source and other components
|
||||
|
||||
content = ''.join(content_parts)
|
||||
return content, message_type
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:
|
||||
"""
|
||||
Convert KOOK message format to LangBot MessageChain
|
||||
|
||||
Args:
|
||||
kook_message: KOOK message event data dict
|
||||
bot_account_id: Bot's account ID for handling role mentions
|
||||
"""
|
||||
components = []
|
||||
|
||||
msg_type = kook_message.get('type', 1)
|
||||
content = kook_message.get('content', '')
|
||||
extra = kook_message.get('extra', {})
|
||||
|
||||
# Handle mentions
|
||||
mentions = extra.get('mention', [])
|
||||
mention_all = extra.get('mention_all', False)
|
||||
mention_roles = extra.get('mention_roles', [])
|
||||
|
||||
if mention_all:
|
||||
components.append(platform_message.AtAll())
|
||||
|
||||
for mention_id in mentions:
|
||||
components.append(platform_message.At(target=str(mention_id)))
|
||||
|
||||
# Handle role mentions (when bot is mentioned via role)
|
||||
# In KOOK, when a role that the bot has is mentioned, we receive it as a role mention
|
||||
# We need to convert this to an At with the bot's account ID for the pipeline to recognize it
|
||||
if mention_roles and bot_account_id:
|
||||
# Add an At component with the bot's account ID when any role is mentioned
|
||||
# This is because KOOK bots are often assigned roles and @role mentions should trigger responses
|
||||
components.append(platform_message.At(target=bot_account_id))
|
||||
|
||||
# Strip mention patterns from content
|
||||
# Remove user mention patterns: (met)USER_ID(met)
|
||||
for mention_id in mentions:
|
||||
content = content.replace(f'(met){mention_id}(met)', '')
|
||||
|
||||
# Remove @all pattern
|
||||
if mention_all:
|
||||
content = content.replace('(met)all(met)', '')
|
||||
|
||||
# Remove role mention patterns: (rol)ROLE_ID(rol)
|
||||
for role_id in mention_roles:
|
||||
content = content.replace(f'(rol){role_id}(rol)', '')
|
||||
|
||||
# Clean up extra whitespace
|
||||
content = content.strip()
|
||||
|
||||
# Handle different message types
|
||||
if msg_type == 1: # Text message
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
elif msg_type == 2: # Image message
|
||||
# Image content is typically a URL
|
||||
if content:
|
||||
# Download image and convert to base64
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(content) as response:
|
||||
if response.status == 200:
|
||||
image_bytes = await response.read()
|
||||
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
||||
# Detect image format
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
components.append(
|
||||
platform_message.Image(base64=f'data:{content_type};base64,{image_base64}')
|
||||
)
|
||||
except Exception:
|
||||
# If download fails, just add as plain text
|
||||
components.append(platform_message.Plain(text=f'[Image: {content}]'))
|
||||
elif msg_type == 4: # File message
|
||||
# For file messages, content is typically the file URL
|
||||
attachments = extra.get('attachments', {})
|
||||
file_name = attachments.get('name', 'file')
|
||||
components.append(platform_message.Plain(text=f'[File: {file_name}]'))
|
||||
elif msg_type == 9: # KMarkdown message
|
||||
# Note: content is already stripped of mention patterns above
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
elif msg_type == 10: # Card message
|
||||
# Card messages are complex, for now just indicate it's a card
|
||||
components.append(platform_message.Plain(text='[Card Message]'))
|
||||
else:
|
||||
# Other message types, just use content as plain text
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Convert between LangBot events and KOOK events"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent):
|
||||
"""Convert LangBot event to KOOK event (not implemented)"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageEvent:
|
||||
"""
|
||||
Convert KOOK event to LangBot MessageEvent
|
||||
|
||||
Args:
|
||||
kook_event: KOOK event data dict containing channel_type, type, etc.
|
||||
bot_account_id: Bot's account ID for handling role mentions
|
||||
|
||||
Returns:
|
||||
FriendMessage or GroupMessage depending on channel_type
|
||||
"""
|
||||
channel_type = kook_event.get('channel_type')
|
||||
author_id = kook_event.get('author_id')
|
||||
target_id = kook_event.get('target_id')
|
||||
msg_timestamp = kook_event.get('msg_timestamp', int(time.time() * 1000))
|
||||
extra = kook_event.get('extra', {})
|
||||
|
||||
# Convert message to MessageChain
|
||||
message_chain = await KookMessageConverter.target2yiri(kook_event, bot_account_id)
|
||||
|
||||
# Convert timestamp from milliseconds to seconds
|
||||
event_time = msg_timestamp / 1000.0
|
||||
|
||||
if channel_type == 'PERSON':
|
||||
# Direct/Private message
|
||||
author = extra.get('author', {})
|
||||
author_name = author.get('nickname', author.get('username', str(author_id)))
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=str(author_id),
|
||||
nickname=author_name,
|
||||
remark=str(author_id),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
elif channel_type == 'GROUP':
|
||||
# Guild/Server channel message
|
||||
author = extra.get('author', {})
|
||||
author_name = author.get('nickname', author.get('username', str(author_id)))
|
||||
|
||||
# guild_id = extra.get('guild_id', '')
|
||||
channel_name = extra.get('channel_name', str(target_id))
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=str(author_id),
|
||||
member_name=author_name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=str(target_id), # Channel ID
|
||||
name=channel_name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
else:
|
||||
# Fallback to FriendMessage for unknown channel types
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=str(author_id),
|
||||
nickname=str(author_id),
|
||||
remark=str(author_id),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
|
||||
|
||||
class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""KOOK platform adapter for LangBot"""
|
||||
|
||||
config: dict
|
||||
message_converter: KookMessageConverter = KookMessageConverter()
|
||||
event_converter: KookEventConverter = KookEventConverter()
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
# WebSocket connection
|
||||
ws: typing.Optional[websockets.WebSocketClientProtocol] = pydantic.Field(exclude=True, default=None)
|
||||
ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
running: bool = pydantic.Field(exclude=True, default=False)
|
||||
|
||||
# Connection state
|
||||
session_id: str = pydantic.Field(exclude=True, default='')
|
||||
current_sn: int = pydantic.Field(exclude=True, default=0)
|
||||
gateway_url: str = pydantic.Field(exclude=True, default='')
|
||||
|
||||
# HTTP session
|
||||
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
# Debug: Track init
|
||||
with open('/tmp/kook_adapter_init.txt', 'w') as f:
|
||||
f.write(f'KOOK adapter __init__ called at {time.time()}\n')
|
||||
|
||||
# Validate required config
|
||||
if 'token' not in config:
|
||||
raise Exception('KOOK adapter requires "token" in config')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id='', # Will be set after connection
|
||||
listeners={},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _get_gateway_url(self) -> str:
|
||||
"""Get WebSocket gateway URL from KOOK API"""
|
||||
base_url = 'https://www.kookapp.cn/api/v3/gateway/index'
|
||||
|
||||
# Always use compression for better performance
|
||||
params = {'compress': 1}
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(base_url, params=params, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('code') == 0:
|
||||
gateway_url = data['data']['url']
|
||||
return gateway_url
|
||||
else:
|
||||
raise Exception(f'Failed to get gateway URL: {data.get("message")}')
|
||||
else:
|
||||
raise Exception(f'Failed to get gateway URL: HTTP {response.status}')
|
||||
|
||||
async def _get_bot_user_info(self) -> dict:
|
||||
"""Get bot's own user information from KOOK API"""
|
||||
base_url = 'https://www.kookapp.cn/api/v3/user/me'
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(base_url, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('code') == 0:
|
||||
user_info = data['data']
|
||||
await self.logger.info(
|
||||
f'Retrieved bot user info: {user_info.get("username")} (ID: {user_info.get("id")})'
|
||||
)
|
||||
return user_info
|
||||
else:
|
||||
raise Exception(f'Failed to get bot user info: {data.get("message")}')
|
||||
else:
|
||||
raise Exception(f'Failed to get bot user info: HTTP {response.status}')
|
||||
|
||||
async def _handle_hello(self, data: dict):
|
||||
"""Handle HELLO signal (signal 1)"""
|
||||
session_id = data.get('session_id', '')
|
||||
self.session_id = session_id
|
||||
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {session_id}')
|
||||
|
||||
async def _handle_event(self, data: dict, sn: int):
|
||||
"""Handle EVENT signal (signal 0)"""
|
||||
self.current_sn = max(self.current_sn, sn)
|
||||
|
||||
# Check if this is a message event
|
||||
event_type = data.get('type')
|
||||
channel_type = data.get('channel_type')
|
||||
author_id = data.get('author_id')
|
||||
|
||||
# Ignore messages from bot itself to prevent infinite loops
|
||||
if self.bot_account_id and str(author_id) == self.bot_account_id:
|
||||
await self.logger.debug(f'Ignoring message from bot itself (author_id: {author_id})')
|
||||
return
|
||||
|
||||
# Only process text messages (type 1, 2, 4, 9, 10) in GROUP or PERSON channels
|
||||
if event_type in [1, 2, 4, 9, 10] and channel_type in ['GROUP', 'PERSON']:
|
||||
try:
|
||||
# Convert to LangBot event
|
||||
lb_event = await self.event_converter.target2yiri(data, self.bot_account_id)
|
||||
|
||||
# Call registered listener
|
||||
event_class = type(lb_event)
|
||||
if event_class in self.listeners:
|
||||
await self.listeners[event_class](lb_event, self)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error handling KOOK event: {e}\n{traceback.format_exc()}')
|
||||
|
||||
async def _handle_pong(self, data: dict):
|
||||
"""Handle PONG signal (signal 3)"""
|
||||
# PONG received, connection is healthy
|
||||
pass
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""Send PING every 30 seconds"""
|
||||
try:
|
||||
while self.running and self.ws:
|
||||
await asyncio.sleep(30)
|
||||
|
||||
if self.ws:
|
||||
try:
|
||||
ping_msg = {
|
||||
's': 2, # PING signal
|
||||
'sn': self.current_sn,
|
||||
}
|
||||
await self.ws.send(json.dumps(ping_msg))
|
||||
await self.logger.debug(f'Sent PING with sn={self.current_sn}')
|
||||
except Exception:
|
||||
# Connection closed or send failed, exit loop
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Heartbeat error: {e}')
|
||||
|
||||
async def _websocket_loop(self):
|
||||
"""Main WebSocket event loop"""
|
||||
retry_count = 0
|
||||
max_retries = 3
|
||||
|
||||
while self.running and retry_count < max_retries:
|
||||
try:
|
||||
# Get gateway URL if not already retrieved
|
||||
if not self.gateway_url:
|
||||
self.gateway_url = await self._get_gateway_url()
|
||||
|
||||
# Connect to WebSocket
|
||||
await self.logger.info(f'Connecting to KOOK WebSocket: {self.gateway_url}')
|
||||
async with websockets.connect(self.gateway_url) as ws:
|
||||
self.ws = ws
|
||||
await self.logger.info('KOOK WebSocket connected')
|
||||
|
||||
# Start heartbeat
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
# Wait for HELLO within 6 seconds
|
||||
try:
|
||||
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
|
||||
|
||||
# Handle compressed messages (same as main message loop)
|
||||
if isinstance(hello_msg, bytes):
|
||||
# Decompress if compressed
|
||||
try:
|
||||
hello_msg = zlib.decompress(hello_msg).decode('utf-8')
|
||||
except Exception:
|
||||
# Not compressed or decompression failed
|
||||
hello_msg = hello_msg.decode('utf-8')
|
||||
|
||||
hello_data = json.loads(hello_msg)
|
||||
|
||||
if hello_data.get('s') == 1: # HELLO signal
|
||||
await self._handle_hello(hello_data['d'])
|
||||
else:
|
||||
raise Exception(f'Expected HELLO signal, got signal {hello_data.get("s")}')
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception('Did not receive HELLO within 6 seconds')
|
||||
|
||||
# Reset retry count on successful connection
|
||||
retry_count = 0
|
||||
|
||||
# Main message loop
|
||||
async for message in ws:
|
||||
if isinstance(message, bytes):
|
||||
# Decompress if compressed
|
||||
try:
|
||||
message = zlib.decompress(message).decode('utf-8')
|
||||
except Exception:
|
||||
# Not compressed or decompression failed
|
||||
message = message.decode('utf-8')
|
||||
|
||||
try:
|
||||
msg_data = json.loads(message)
|
||||
signal = msg_data.get('s')
|
||||
|
||||
if signal == 0: # EVENT
|
||||
data = msg_data.get('d', {})
|
||||
sn = msg_data.get('sn', 0)
|
||||
await self._handle_event(data, sn)
|
||||
elif signal == 3: # PONG
|
||||
await self._handle_pong(msg_data.get('d', {}))
|
||||
elif signal == 5: # RECONNECT
|
||||
await self.logger.info('Received RECONNECT signal')
|
||||
break # Break to reconnect
|
||||
elif signal == 6: # RESUME ACK
|
||||
await self.logger.info('Resume successful')
|
||||
except json.JSONDecodeError:
|
||||
await self.logger.error(f'Failed to parse message: {message}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error processing message: {e}\n{traceback.format_exc()}')
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
await self.logger.warning('KOOK WebSocket connection closed, reconnecting...')
|
||||
retry_count += 1
|
||||
await asyncio.sleep(2**retry_count) # Exponential backoff
|
||||
except Exception as e:
|
||||
await self.logger.error(f'KOOK WebSocket error: {e}\n{traceback.format_exc()}')
|
||||
retry_count += 1
|
||||
await asyncio.sleep(2**retry_count)
|
||||
finally:
|
||||
# Stop heartbeat
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
if retry_count >= max_retries:
|
||||
await self.logger.error(f'Failed to connect after {max_retries} retries')
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""Send a message to a channel or user"""
|
||||
content, msg_type = await self.message_converter.yiri2target(message)
|
||||
|
||||
# Determine endpoint based on target_type
|
||||
if target_type == 'GROUP':
|
||||
# Send to channel
|
||||
url = 'https://www.kookapp.cn/api/v3/message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
else: # PERSON or default
|
||||
# Send direct message
|
||||
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
if not self.http_session:
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
async with self.http_session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
if result.get('code') == 0:
|
||||
await self.logger.debug(f'Message sent successfully to {target_id}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send message: {result.get("message")}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send message: HTTP {response.status}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending message: {e}')
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a message"""
|
||||
content, msg_type = await self.message_converter.yiri2target(message)
|
||||
|
||||
kook_event = message_source.source_platform_object
|
||||
channel_type = kook_event.get('channel_type')
|
||||
target_id = kook_event.get('target_id')
|
||||
msg_id = kook_event.get('msg_id')
|
||||
|
||||
# Determine endpoint based on channel_type
|
||||
if channel_type == 'GROUP':
|
||||
url = 'https://www.kookapp.cn/api/v3/message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
else: # PERSON
|
||||
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
|
||||
# For direct messages, we need the chat_code or target_id
|
||||
author_id = kook_event.get('author_id')
|
||||
extra = kook_event.get('extra', {})
|
||||
chat_code = extra.get('code', '')
|
||||
|
||||
payload = {
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
|
||||
if chat_code:
|
||||
payload['chat_code'] = chat_code
|
||||
else:
|
||||
payload['target_id'] = str(author_id)
|
||||
|
||||
# Add quote if requested
|
||||
if quote_origin and msg_id:
|
||||
payload['quote'] = msg_id
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
if not self.http_session:
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
async with self.http_session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
if result.get('code') == 0:
|
||||
await self.logger.debug('Reply sent successfully')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send reply: {result.get("message")}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send reply: HTTP {response.status}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending reply: {e}')
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
"""Check if bot is muted in a group (not implemented for KOOK)"""
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Register an event listener"""
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Unregister an event listener"""
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
"""Start the KOOK adapter"""
|
||||
# Debug: Track run_async
|
||||
with open('/tmp/kook_adapter_run.txt', 'w') as f:
|
||||
f.write(f'KOOK adapter run_async called at {time.time()}\n')
|
||||
|
||||
self.running = True
|
||||
|
||||
try:
|
||||
# Create HTTP session
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
await self.logger.info('Starting KOOK adapter')
|
||||
|
||||
# Get bot's user information and set bot_account_id
|
||||
try:
|
||||
bot_info = await self._get_bot_user_info()
|
||||
self.bot_account_id = str(bot_info.get('id', ''))
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to get bot user info: {e}')
|
||||
# Continue anyway, but bot will process its own messages
|
||||
|
||||
# Start WebSocket connection
|
||||
self.ws_task = asyncio.create_task(self._websocket_loop())
|
||||
|
||||
# Keep running
|
||||
await self.ws_task
|
||||
except Exception as e:
|
||||
await self.logger.error(f'KOOK adapter error: {e}\n{traceback.format_exc()}')
|
||||
finally:
|
||||
self.running = False
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Stop the KOOK adapter"""
|
||||
self.running = False
|
||||
|
||||
# Cancel tasks
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.ws_task:
|
||||
self.ws_task.cancel()
|
||||
try:
|
||||
await self.ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Close WebSocket
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass # Already closed or error during close
|
||||
|
||||
# Close HTTP session
|
||||
if self.http_session:
|
||||
await self.http_session.close()
|
||||
|
||||
await self.logger.info('KOOK adapter stopped')
|
||||
return True
|
||||
@@ -1,24 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: kook
|
||||
label:
|
||||
en_US: KOOK
|
||||
zh_Hans: KOOK
|
||||
description:
|
||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||
icon: kook.png
|
||||
spec:
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./kook.py
|
||||
attr: KookAdapter
|
||||
398
src/langbot/pkg/platform/sources/teams.py
Normal file
398
src/langbot/pkg/platform/sources/teams.py
Normal file
@@ -0,0 +1,398 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import base64
|
||||
import datetime
|
||||
import aiohttp
|
||||
import pydantic
|
||||
|
||||
from botbuilder.core import BotFrameworkAdapter, BotFrameworkAdapterSettings, TurnContext
|
||||
from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, ConversationAccount
|
||||
from botframework.connector.auth import MicrosoftAppCredentials
|
||||
from quart import Quart, request, Response
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class TeamsMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Convert messages between LangBot format and Teams Activity format"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> dict:
|
||||
"""Convert LangBot message chain to Teams message format"""
|
||||
text_content = []
|
||||
attachments = []
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
text_content.append(component.text)
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# Teams supports image attachments
|
||||
image_data = None
|
||||
image_type = 'image/png'
|
||||
|
||||
if component.base64:
|
||||
# Extract base64 data
|
||||
if component.base64.startswith('data:'):
|
||||
parts = component.base64.split(',')
|
||||
header = parts[0]
|
||||
if 'image/' in header:
|
||||
image_type = header.split(';')[0].replace('data:', '')
|
||||
image_data = parts[1] if len(parts) > 1 else component.base64
|
||||
else:
|
||||
image_data = component.base64
|
||||
elif component.url:
|
||||
# For URLs, Teams can display them inline
|
||||
text_content.append(f'\n{component.url}')
|
||||
continue
|
||||
elif component.path:
|
||||
try:
|
||||
with open(component.path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
image_data = base64.b64encode(image_bytes).decode('utf-8')
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if image_data:
|
||||
# Create inline image attachment
|
||||
attachments.append({
|
||||
'contentType': image_type,
|
||||
'contentUrl': f'data:{image_type};base64,{image_data}',
|
||||
'name': 'image'
|
||||
})
|
||||
|
||||
result = {
|
||||
'text': ''.join(text_content),
|
||||
}
|
||||
|
||||
if attachments:
|
||||
result['attachments'] = attachments
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(activity: Activity) -> platform_message.MessageChain:
|
||||
"""Convert Teams Activity to LangBot message chain"""
|
||||
components = []
|
||||
|
||||
# Add message source
|
||||
components.append(
|
||||
platform_message.Source(
|
||||
id=activity.id,
|
||||
time=activity.timestamp if activity.timestamp else datetime.datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
# Handle mentions (convert to At components)
|
||||
if activity.entities:
|
||||
for entity in activity.entities:
|
||||
if entity.type == 'mention':
|
||||
mentioned = entity.mentioned
|
||||
if mentioned:
|
||||
components.append(platform_message.At(target=str(mentioned.id)))
|
||||
|
||||
# Add text content
|
||||
if activity.text:
|
||||
text = activity.text
|
||||
# Remove bot mentions from text
|
||||
if activity.entities:
|
||||
for entity in activity.entities:
|
||||
if entity.type == 'mention' and entity.text:
|
||||
text = text.replace(entity.text, '').strip()
|
||||
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
|
||||
# Handle attachments (images, files, etc.)
|
||||
if activity.attachments:
|
||||
for attachment in activity.attachments:
|
||||
if attachment.content_type and 'image' in attachment.content_type:
|
||||
# Download and convert image to base64
|
||||
if attachment.content_url:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(attachment.content_url) as response:
|
||||
image_data = await response.read()
|
||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
||||
content_type = attachment.content_type or 'image/png'
|
||||
components.append(
|
||||
platform_message.Image(
|
||||
base64=f'data:{content_type};base64,{image_base64}'
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class TeamsEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Convert events between Teams Activity and LangBot platform events"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event) -> Activity:
|
||||
"""Convert LangBot event to Teams Activity"""
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(activity: Activity, bot_id: str) -> platform_events.Event:
|
||||
"""Convert Teams Activity to LangBot event"""
|
||||
message_chain = await TeamsMessageConverter.target2yiri(activity)
|
||||
|
||||
# Determine if it's a personal or channel/group chat
|
||||
conversation_type = activity.conversation.conversation_type if activity.conversation else None
|
||||
|
||||
if conversation_type == 'personal':
|
||||
# Personal chat (1-on-1)
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=activity.from_property.id,
|
||||
nickname=activity.from_property.name or activity.from_property.id,
|
||||
remark=activity.from_property.id,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=activity.timestamp.timestamp() if activity.timestamp else datetime.datetime.now().timestamp(),
|
||||
source_platform_object=activity,
|
||||
)
|
||||
else:
|
||||
# Channel or group chat
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=activity.from_property.id,
|
||||
member_name=activity.from_property.name or activity.from_property.id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=activity.conversation.id,
|
||||
name=activity.conversation.name or activity.conversation.id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=activity.timestamp.timestamp() if activity.timestamp else datetime.datetime.now().timestamp(),
|
||||
source_platform_object=activity,
|
||||
)
|
||||
|
||||
|
||||
class TeamsAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""Microsoft Teams platform adapter for LangBot"""
|
||||
|
||||
adapter: BotFrameworkAdapter = pydantic.Field(exclude=True, default=None)
|
||||
app: Quart = pydantic.Field(exclude=True, default=None)
|
||||
bot_uuid: typing.Optional[str] = None
|
||||
|
||||
message_converter: TeamsMessageConverter = TeamsMessageConverter()
|
||||
event_converter: TeamsEventConverter = TeamsEventConverter()
|
||||
|
||||
config: dict
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
"""Initialize Teams adapter with app credentials"""
|
||||
# Validate required config
|
||||
if 'app_id' not in config or 'app_password' not in config:
|
||||
raise ValueError('Teams adapter requires app_id and app_password in configuration')
|
||||
|
||||
# Create Bot Framework adapter settings
|
||||
settings = BotFrameworkAdapterSettings(
|
||||
app_id=config['app_id'],
|
||||
app_password=config['app_password']
|
||||
)
|
||||
|
||||
# Create Bot Framework adapter
|
||||
adapter_instance = BotFrameworkAdapter(settings)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id=config['app_id'],
|
||||
listeners={},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Set the adapter after initialization
|
||||
self.adapter = adapter_instance
|
||||
|
||||
async def _handle_activity(self, activity: Activity):
|
||||
"""Internal method to handle incoming activities"""
|
||||
try:
|
||||
# Only process message activities from users (not bots)
|
||||
if activity.type == ActivityTypes.message:
|
||||
if activity.from_property and not (hasattr(activity.from_property, 'role') and activity.from_property.role == 'bot'):
|
||||
# Convert to LangBot event
|
||||
lb_event = await self.event_converter.target2yiri(activity, self.bot_account_id)
|
||||
|
||||
# Call appropriate listener
|
||||
event_type = type(lb_event)
|
||||
if event_type in self.listeners:
|
||||
await self.listeners[event_type](lb_event, self)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error handling Teams activity: {str(e)}\n{traceback.format_exc()}')
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""Send a message to a specific target"""
|
||||
try:
|
||||
message_data = await self.message_converter.yiri2target(message)
|
||||
|
||||
# Create activity
|
||||
activity = Activity(
|
||||
type=ActivityTypes.message,
|
||||
text=message_data.get('text', ''),
|
||||
attachments=message_data.get('attachments', []),
|
||||
conversation=ConversationAccount(id=target_id),
|
||||
)
|
||||
|
||||
# Send via Bot Framework adapter
|
||||
# Note: This requires a conversation reference which we would need to store
|
||||
# For now, this is a placeholder - full implementation would require conversation tracking
|
||||
await self.logger.warning('Direct send_message not fully implemented - use reply_message instead')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending Teams message: {str(e)}')
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a message"""
|
||||
try:
|
||||
assert isinstance(message_source.source_platform_object, Activity)
|
||||
source_activity: Activity = message_source.source_platform_object
|
||||
|
||||
message_data = await self.message_converter.yiri2target(message)
|
||||
|
||||
# Build conversation reference from source activity
|
||||
conversation_reference = TurnContext.get_conversation_reference(source_activity)
|
||||
|
||||
# Create reply activity
|
||||
async def send_reply(turn_context: TurnContext):
|
||||
reply_text = message_data.get('text', '')
|
||||
reply_attachments = message_data.get('attachments', [])
|
||||
|
||||
if quote_origin:
|
||||
# Include reply_to_id to quote the original message
|
||||
await turn_context.send_activity(
|
||||
Activity(
|
||||
type=ActivityTypes.message,
|
||||
text=reply_text,
|
||||
attachments=reply_attachments,
|
||||
reply_to_id=source_activity.id
|
||||
)
|
||||
)
|
||||
else:
|
||||
await turn_context.send_activity(
|
||||
Activity(
|
||||
type=ActivityTypes.message,
|
||||
text=reply_text,
|
||||
attachments=reply_attachments
|
||||
)
|
||||
)
|
||||
|
||||
# Continue conversation using the conversation reference
|
||||
await self.adapter.continue_conversation(
|
||||
conversation_reference,
|
||||
send_reply,
|
||||
self.bot_account_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error replying to Teams message: {str(e)}\n{traceback.format_exc()}')
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Register event listener"""
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
_callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Unregister event listener"""
|
||||
if event_type in self.listeners:
|
||||
del self.listeners[event_type]
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""Set bot UUID for webhook URL generation"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
async def handle_unified_webhook(self, _bot_uuid: str, _path: str, request_obj):
|
||||
"""Handle unified webhook requests from Teams
|
||||
|
||||
Args:
|
||||
_bot_uuid: Bot UUID (unused)
|
||||
_path: Sub-path (unused)
|
||||
request_obj: Quart Request object
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
try:
|
||||
# Get request body
|
||||
body = await request_obj.get_json()
|
||||
|
||||
# Get authorization header
|
||||
auth_header = request_obj.headers.get('Authorization', '')
|
||||
|
||||
# Process activity
|
||||
async def bot_logic(turn_context: TurnContext):
|
||||
# Handle the activity
|
||||
await self._handle_activity(turn_context.activity)
|
||||
|
||||
# Process the request through Bot Framework adapter
|
||||
await self.adapter.process_activity(body, auth_header, bot_logic)
|
||||
|
||||
return Response(status=200)
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error processing Teams webhook: {str(e)}\n{traceback.format_exc()}')
|
||||
return Response(status=500)
|
||||
|
||||
async def run_async(self):
|
||||
"""Run the adapter - Teams uses webhook mode, so just keep alive and log webhook URL"""
|
||||
# Print webhook callback address
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
|
||||
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
|
||||
|
||||
await self.logger.info(f"Teams Bot Webhook URL:")
|
||||
await self.logger.info(f" Local: {webhook_url}")
|
||||
await self.logger.info(f" Public: {webhook_url_public}")
|
||||
await self.logger.info(f"Configure this URL as the messaging endpoint in Azure Bot Service")
|
||||
except Exception as e:
|
||||
await self.logger.warning(f"Could not generate webhook URL: {e}")
|
||||
|
||||
# Keep the adapter running
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Shutdown the adapter"""
|
||||
await self.logger.info('Teams adapter stopped')
|
||||
return True
|
||||
37
src/langbot/pkg/platform/sources/teams.yaml
Normal file
37
src/langbot/pkg/platform/sources/teams.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: teams
|
||||
label:
|
||||
en_US: Microsoft Teams
|
||||
zh_Hans: 微软 Teams
|
||||
description:
|
||||
en_US: Microsoft Teams Adapter - supports personal and channel/group chats
|
||||
zh_Hans: 微软 Teams 适配器,支持个人聊天和频道/群组聊天
|
||||
icon: teams.svg
|
||||
spec:
|
||||
config:
|
||||
- name: app_id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用 ID
|
||||
description:
|
||||
en_US: Microsoft App ID from Azure Bot registration
|
||||
zh_Hans: Azure Bot 注册的 Microsoft 应用 ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: app_password
|
||||
label:
|
||||
en_US: App Password
|
||||
zh_Hans: 应用密码
|
||||
description:
|
||||
en_US: Microsoft App Password (Client Secret) from Azure Bot registration
|
||||
zh_Hans: Azure Bot 注册的 Microsoft 应用密码(客户端密钥)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./teams.py
|
||||
attr: TeamsAdapter
|
||||
@@ -22,16 +22,12 @@ class WebhookPusher:
|
||||
self.ap = ap
|
||||
self.logger = self.ap.logger
|
||||
|
||||
async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> bool:
|
||||
"""Push person message event to webhooks
|
||||
|
||||
Returns:
|
||||
bool: True if any webhook responded with skip_pipeline=true, False otherwise
|
||||
"""
|
||||
async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> None:
|
||||
"""Push person message event to webhooks"""
|
||||
try:
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
|
||||
if not webhooks:
|
||||
return False
|
||||
return
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
@@ -51,30 +47,17 @@ class WebhookPusher:
|
||||
|
||||
# Push to all webhooks asynchronously
|
||||
tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Check if any webhook responded with skip_pipeline=true
|
||||
for result in results:
|
||||
if isinstance(result, dict) and result.get('skip_pipeline') is True:
|
||||
self.logger.info(f'Webhook responded with skip_pipeline=true, skipping pipeline for person message')
|
||||
return True
|
||||
|
||||
return False
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to push person message to webhooks: {e}')
|
||||
return False
|
||||
|
||||
async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> bool:
|
||||
"""Push group message event to webhooks
|
||||
|
||||
Returns:
|
||||
bool: True if any webhook responded with skip_pipeline=true, False otherwise
|
||||
"""
|
||||
async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> None:
|
||||
"""Push group message event to webhooks"""
|
||||
try:
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
|
||||
if not webhooks:
|
||||
return False
|
||||
return
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
@@ -98,26 +81,13 @@ class WebhookPusher:
|
||||
|
||||
# Push to all webhooks asynchronously
|
||||
tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Check if any webhook responded with skip_pipeline=true
|
||||
for result in results:
|
||||
if isinstance(result, dict) and result.get('skip_pipeline') is True:
|
||||
self.logger.info(f'Webhook responded with skip_pipeline=true, skipping pipeline for group message')
|
||||
return True
|
||||
|
||||
return False
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to push group message to webhooks: {e}')
|
||||
return False
|
||||
|
||||
async def _push_to_webhook(self, url: str, payload: dict) -> dict | None:
|
||||
"""Push payload to a single webhook URL
|
||||
|
||||
Returns:
|
||||
dict | None: The response JSON if successful, None otherwise
|
||||
"""
|
||||
async def _push_to_webhook(self, url: str, payload: dict) -> None:
|
||||
"""Push payload to a single webhook URL"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
@@ -128,17 +98,9 @@ class WebhookPusher:
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
self.logger.warning(f'Webhook {url} returned status {response.status}')
|
||||
return None
|
||||
else:
|
||||
self.logger.debug(f'Successfully pushed to webhook {url}')
|
||||
try:
|
||||
return await response.json()
|
||||
except Exception as json_error:
|
||||
self.logger.debug(f'Failed to parse JSON response from webhook {url}: {json_error}')
|
||||
return None
|
||||
except asyncio.TimeoutError:
|
||||
self.logger.warning(f'Timeout pushing to webhook {url}')
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Error pushing to webhook {url}: {e}')
|
||||
return None
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.507.0",
|
||||
"next": "15.4.8",
|
||||
"next": "15.4.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.3",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -48,7 +48,7 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
<div className={`${styles.botLogCardContainer}`}>
|
||||
{/* 头部标签,时间 */}
|
||||
<div className={`${styles.cardTitleContainer}`}>
|
||||
<div className={`flex flex-row gap-2 items-center`}>
|
||||
<div className={`flex flex-row gap-4`}>
|
||||
<div className={`${styles.tag}`}>{botLog.level}</div>
|
||||
{botLog.message_session_id && (
|
||||
<div
|
||||
@@ -60,7 +60,6 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
toast.success(t('common.copySuccess'));
|
||||
});
|
||||
}}
|
||||
title={t('common.clickToCopy')}
|
||||
>
|
||||
<svg
|
||||
className="icon"
|
||||
@@ -68,8 +67,8 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="1664"
|
||||
width="16"
|
||||
height="16"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
@@ -88,6 +87,7 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
{/* 会话ID */}
|
||||
|
||||
<span className={`${styles.chatId}`}>
|
||||
{getSubChatId(botLog.message_session_id)}
|
||||
@@ -95,25 +95,22 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${styles.timestamp}`}>
|
||||
{formatTime(botLog.timestamp)}
|
||||
</div>
|
||||
<div>{formatTime(botLog.timestamp)}</div>
|
||||
</div>
|
||||
<div className={`${styles.cardText}`}>{botLog.text}</div>
|
||||
{botLog.images.length > 0 && (
|
||||
<PhotoProvider>
|
||||
<div className={`flex flex-wrap gap-2 mt-3`}>
|
||||
{botLog.images.map((item) => (
|
||||
<img
|
||||
key={item}
|
||||
src={`${baseURL}/api/v1/files/image/${item}`}
|
||||
alt=""
|
||||
className="max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PhotoProvider>
|
||||
)}
|
||||
<div className={`${styles.cardTitleContainer} ${styles.cardText}`}>
|
||||
{botLog.text}
|
||||
</div>
|
||||
<PhotoProvider className={``}>
|
||||
<div className={`w-50 mt-2`}>
|
||||
{botLog.images.map((item) => (
|
||||
<img
|
||||
key={item}
|
||||
src={`${baseURL}/api/v1/files/image/${item}`}
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PhotoProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
.botLogListContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.botLogCardContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botLogCardContainer:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer {
|
||||
@@ -34,74 +23,40 @@
|
||||
border: 1px solid #2a2a2e;
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer:hover {
|
||||
border-color: #3a3a3e;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
height: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
justify-content: flex-start;
|
||||
gap: 0.2rem;
|
||||
height: 1.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
background-color: #a5d8ff;
|
||||
color: #ffffff;
|
||||
max-width: 16rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
:global(.dark) .tag {
|
||||
background-color: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.chatTag {
|
||||
color: #4b5563;
|
||||
background-color: #f3f4f6;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chatTag:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag {
|
||||
color: #9ca3af;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag:hover {
|
||||
background-color: #4b5563;
|
||||
color: #626262;
|
||||
background-color: #d1d1d1;
|
||||
}
|
||||
|
||||
.chatId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
|
||||
'Courier New', monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.cardTitleContainer {
|
||||
@@ -110,33 +65,9 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
color: #1e293b;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
max-width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', sans-serif;
|
||||
}
|
||||
|
||||
:global(.dark) .cardText {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .timestamp {
|
||||
margin-top: 0.4rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user