Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
65463820f3 refactor: simplify sender_name extraction using tuple isinstance check
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-12-01 03:31:22 +00:00
copilot-swe-agent[bot]
9d5de54379 feat: add sender_id and sender_name variables for Dify workflows
This adds user identification fields that can be passed to Dify workflows:
- sender_id: the sender's ID (converted to string)
- sender_name: the sender's display name (from Friend.nickname or GroupMember.member_name)

Both variables are also available as legacy inputs:
- langbot_sender_id
- langbot_sender_name

Resolves the feature request to pass WeChat Work user info to Dify workflows.

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-12-01 03:28:48 +00:00
copilot-swe-agent[bot]
9846099957 Initial plan 2025-12-01 03:18:27 +00:00
135 changed files with 11596 additions and 14758 deletions

View File

@@ -1,8 +0,0 @@
.github
.venv
.vscode
.data
.temp
web/.next
web/node_modules
web/.env

View File

@@ -3,6 +3,7 @@ on:
## 发布release的时候会自动构建
release:
types: [published]
workflow_dispatch:
jobs:
publish-docker-image:
runs-on: ubuntu-latest
@@ -41,7 +42,7 @@ jobs:
run: docker buildx create --name mybuilder --use
- name: Build for Release # only relase, exlude pre-release
if: ${{ github.event.release.prerelease == false }}
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
- name: Build for Pre-release # no update for latest tag
if: ${{ github.event.release.prerelease == true }}
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push

View File

@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12']
fail-fast: false
steps:

View File

@@ -8,17 +8,16 @@ LangBot is a open-source LLM native instant messaging bot development platform,
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
- `./src/langbot`: The main python package of the project, below are the main modules in this package:
- `./pkg`: The core python package of the project backend.
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
- `./templates`: Templates of config files, components, etc.
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
- `./docker`: docker-compose deployment files.
- `./pkg`: The core python package of the project backend.
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
- `./templates`: Templates of config files, components, etc.
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
- `./docker`: docker-compose deployment files.
## Backend Development

View File

@@ -20,4 +20,4 @@ RUN apt update \
&& uv sync \
&& touch /.dockerenv
CMD [ "uv", "run", "--no-sync", "main.py" ]
CMD [ "uv", "run", "main.py" ]

View File

@@ -1,15 +1,13 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>使用 LangBot 快速构建、调试、部署即时通信机器人。</h3>
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -24,10 +22,12 @@
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a>
</div>
</p>
LangBot 是一个开源的大语言模型原生即时通信机器人开发平台,旨在提供开箱即用的 IM 机器人开发体验,具有 Agent、RAG、MCP 等多种 LLM 应用功能,适配全球主流即时通信平台,并提供丰富的 API 接口,支持自定义开发。
## 📦 开始使用
@@ -83,11 +83,8 @@ docker compose up -d
## ✨ 特性
<img width="500" src="https://docs.langbot.app/ui/bot-page-zh-rounded.png" />
- 💬 大模型对话、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 实例,不再需要手动编写配置文件。
@@ -111,7 +108,6 @@ docker compose up -d
| 微信公众号 | ✅ | |
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
| KOOK | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |

View File

@@ -1,14 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Quickly build, debug, and ship IM bots with LangBot.</h3>
English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,7 @@ English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語]
</p>
LangBot is an open-source LLM native instant messaging robot development platform, aiming to provide out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, adapting to global instant messaging platforms, and providing rich API interfaces, supporting custom development.
## 📦 Getting Started
@@ -80,11 +79,8 @@ Click the Star and Watch button in the upper right corner of the repository to g
## ✨ Features
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 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.
@@ -111,7 +107,6 @@ Or visit the demo environment: https://demo.langbot.dev/
| Personal WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,14 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Cree, depure y despliegue bots de mensajería instantánea rápidamente con LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,7 @@
</p>
LangBot es una plataforma de desarrollo de robots de mensajería instantánea nativa de LLM de código abierto, con el objetivo de proporcionar una experiencia de desarrollo de robots de mensajería instantánea lista para usar, con funciones de aplicación LLM como Agent, RAG, MCP, adaptándose a plataformas de mensajería instantánea globales y proporcionando interfaces API ricas, compatible con desarrollo personalizado.
## 📦 Comenzar
@@ -80,11 +79,8 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
## ✨ Características
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 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.
@@ -111,7 +107,6 @@ O visite el entorno de demostración: https://demo.langbot.dev/
| WeChat Personal | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,14 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Créez, déboguez et déployez rapidement des bots de messagerie instantanée avec LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,8 @@
</p>
LangBot est une plateforme de développement de robots de messagerie instantanée native LLM open source, visant à fournir une expérience de développement de robots de messagerie instantanée prête à l'emploi, avec des fonctionnalités d'application LLM telles qu'Agent, RAG, MCP, s'adaptant aux plateformes de messagerie instantanée mondiales et fournissant des interfaces API riches, prenant en charge le développement personnalisé.
## 📦 Commencer
#### Démarrage Rapide
@@ -79,11 +79,8 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
## ✨ Fonctionnalités
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 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.
@@ -110,7 +107,6 @@ Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
| WeChat Personnel | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,14 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>LangBotでIMボットを素早く構築、デバッグ、デプロイ。</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,8 @@
</p>
LangBot は、エージェント、RAG、MCP などの LLM アプリケーション機能を備えた、オープンソースの LLM ネイティブのインスタントメッセージングロボット開発プラットフォームです。世界中のインスタントメッセージングプラットフォームに適応し、豊富な API インターフェースを提供し、カスタム開発をサポートします。
## 📦 始め方
#### クイックスタート
@@ -79,11 +79,8 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
## ✨ 機能
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 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インスタンスを管理することをサポート。
@@ -110,7 +107,6 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| 個人WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,14 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>LangBot으로 IM 봇을 빠르게 구축, 디버그 및 배포하세요.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,8 @@
</p>
LangBot은 오픈 소스 LLM 네이티브 인스턴트 메시징 로봇 개발 플랫폼으로, Agent, RAG, MCP 등 다양한 LLM 애플리케이션 기능을 갖춘 즉시 사용 가능한 IM 로봇 개발 경험을 제공하며, 글로벌 인스턴트 메시징 플랫폼에 적응하고 풍부한 API 인터페이스를 제공하여 맞춤형 개발을 지원합니다.
## 📦 시작하기
#### 빠른 시작
@@ -79,11 +79,8 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
## ✨ 기능
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 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 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
@@ -108,7 +105,6 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| 개인 WeChat | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -1,14 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Быстро создавайте, отлаживайте и развертывайте IM-ботов с LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,8 @@
</p>
LangBot — это платформа разработки ботов для мгновенных сообщений на основе LLM с открытым исходным кодом, целью которой является предоставление готового к использованию опыта разработки ботов для IM, с функциями приложений LLM, такими как Agent, RAG, MCP, адаптацией к глобальным платформам мгновенных сообщений и предоставлением богатых API-интерфейсов, поддерживающих пользовательскую разработку.
## 📦 Начало работы
#### Быстрый старт
@@ -79,11 +79,8 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
## ✨ Функции
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Чат с 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 через браузер. Нет необходимости вручную писать конфигурационные файлы.
@@ -108,7 +105,6 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| Личный WeChat | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -1,12 +1,10 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
</a>
<div align="center"><a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>使用 LangBot 快速建構、除錯和部署 IM 機器人。</h3>
[English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,8 @@
</p>
LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台,旨在提供開箱即用的 IM 機器人開發體驗,具有 Agent、RAG、MCP 等多種 LLM 應用功能,適配全球主流即時通訊平台,並提供豐富的 API 介面,支援自定義開發。
## 📦 開始使用
#### 快速部署
@@ -79,11 +79,8 @@ docker compose up -d
## ✨ 特性
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 大模型對話、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 +105,6 @@ docker compose up -d
| 企微對外客服 | ✅ | |
| 企微智能機器人 | ✅ | |
| 微信公眾號 | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -1,14 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Xây dựng, gỡ lỗi và triển khai bot IM nhanh chóng với LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -25,6 +23,8 @@
</p>
LangBot là một nền tảng phát triển robot nhắn tin tức thời gốc LLM mã nguồn mở, nhằm mục đích cung cấp trải nghiệm phát triển robot IM sẵn sàng sử dụng, với các chức năng ứng dụng LLM như Agent, RAG, MCP, thích ứng với các nền tảng nhắn tin tức thời toàn cầu và cung cấp giao diện API phong phú, hỗ trợ phát triển tùy chỉnh.
## 📦 Bắt đầu
#### Khởi động Nhanh
@@ -79,11 +79,8 @@ 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
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 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.
@@ -108,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 | ✅ | |

View File

@@ -7,6 +7,7 @@ services:
langbot_plugin_runtime:
image: rockchin/langbot:latest
container_name: langbot_plugin_runtime
platform: linux/amd64 # For Apple Silicon compatibility
volumes:
- ./data/plugins:/app/data/plugins
ports:
@@ -21,14 +22,16 @@ services:
langbot:
image: rockchin/langbot:latest
container_name: langbot
platform: linux/amd64 # For Apple Silicon compatibility
volumes:
- ./data:/app/data
- ./plugins:/app/plugins
restart: on-failure
environment:
- TZ=Asia/Shanghai
ports:
- 5300:5300 # For web ui and webhook callback
- 2280-2285:2280-2285 # For platform reverse connection
- 5300:5300 # For web ui
- 2280-2290:2280-2290 # For platform webhook
networks:
- langbot_network

View File

@@ -1,259 +0,0 @@
# SeekDB Vector Database Integration
This document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature.
## What is SeekDB?
**OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license.
### Key Features
- **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement
- **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine
- **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory
- **Multiple Deployment Modes**: Supports both embedded mode and client/server mode
- **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility
## Installation
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
If you need to install it manually:
```bash
pip install pyseekdb
```
## ⚠️ Platform Compatibility
### Embedded Mode
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
### Server Mode (Docker)
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full Docker support |
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Untested | Should work but not yet tested |
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
- Using ChromaDB or Qdrant as alternatives
- Connecting to a remote SeekDB server on Linux if available
### Server Mode (Remote Connection)
| Platform | Status | Notes |
|----------|--------|-------|
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
## Configuration
### Embedded Mode (Recommended for Development)
Embedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services.
Edit your `config.yaml`:
```yaml
vdb:
use: seekdb
seekdb:
mode: embedded
path: './data/seekdb' # Path to store SeekDB data
database: 'langbot' # Database name
```
### Server Mode (For Production)
Server mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments.
#### SeekDB Server
```yaml
vdb:
use: seekdb
seekdb:
mode: server
host: 'localhost'
port: 2881
database: 'langbot'
user: 'root'
password: '' # Can also use SEEKDB_PASSWORD env var
```
#### OceanBase Server
If you're using OceanBase with seekdb capabilities:
```yaml
vdb:
use: seekdb
seekdb:
mode: server
host: 'localhost'
port: 2881
tenant: 'sys' # OceanBase tenant name
database: 'langbot'
user: 'root'
password: ''
```
## Configuration Parameters
| Parameter | Required | Default | Description |
|-----------|----------|--------------|-------------|
| `mode` | No | `embedded` | Deployment mode: `embedded` or `server` |
| `path` | No | `./data/seekdb` | Data directory for embedded mode |
| `database` | No | `langbot` | Database name |
| `host` | No | `localhost` | Server host (server mode only) |
| `port` | No | `2881` | Server port (server mode only) |
| `user` | No | `root` | Username (server mode only) |
| `password` | No | `''` | Password (server mode only) |
| `tenant` | No | None | OceanBase tenant (optional, server mode only) |
## Usage
Once configured, SeekDB will be used automatically for all knowledge base operations in LangBot:
1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections
2. **Adding Documents**: Document embeddings will be indexed in SeekDB
3. **Searching**: Vector similarity search will use SeekDB's efficient indexing
4. **Deleting**: Document removal will delete vectors from SeekDB
No code changes are required - just update your configuration!
## Architecture Details
### Implementation
The SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters.
Key methods:
- `add_embeddings()`: Add vectors with metadata to a collection
- `search()`: Perform vector similarity search
- `delete_by_file_id()`: Delete vectors by file ID metadata
- `get_or_create_collection()`: Manage collections
- `delete_collection()`: Remove entire collections
### Vector Storage
- Collections are created with HNSW (Hierarchical Navigable Small World) index
- Default distance metric: Cosine similarity
- Default vector dimension: 384 (adjusts automatically based on embeddings)
- Metadata is stored alongside vectors for filtering
## Advantages Over Other Vector Databases
### vs. ChromaDB
- ✅ Better MySQL compatibility
- ✅ Hybrid search capabilities (vector + full-text + SQL)
- ✅ Production-grade distributed mode support
- ✅ Lightweight embedded mode
### vs. Qdrant
- ✅ SQL query support
- ✅ MySQL ecosystem integration
- ✅ Simpler deployment (no Docker required for embedded mode)
- ✅ Multi-model data support (not just vectors)
## Troubleshooting
### Import Error
If you see: `ImportError: pyseekdb is not installed`
Solution:
```bash
pip install pyseekdb
```
### Embedded Mode Error on macOS/Windows
**Error**:
```
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
```
**Cause**: `pylibseekdb` is only available on Linux platforms.
**Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM
2. Configure LangBot to use server mode:
```yaml
vdb:
use: seekdb
seekdb:
mode: server
host: 'your-seekdb-server-ip'
port: 2881
database: 'langbot'
user: 'root'
password: ''
```
**Alternative**: Use ChromaDB or Qdrant, which work on all platforms:
```yaml
vdb:
use: chroma # or qdrant
```
### Docker Container Fails on macOS
**Symptoms**:
```bash
docker run -d -p 2881:2881 oceanbase/seekdb:latest
# Container exits immediately with code 30
```
**Error in logs**:
```
[ERROR] Code: Agent.SeekDB.Not.Exists
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
```
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
**Status**: Under investigation by OceanBase team.
**Workaround Options**:
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
3. **Wait for fix**: Monitor the GitHub issue for updates
### Connection Error (Server Mode)
If SeekDB server is not reachable, check:
1. Server is running: `ps aux | grep observer`
2. Port is accessible: `nc -zv localhost 2881`
3. Credentials are correct in config
4. Firewall allows connections on port 2881
### Performance Issues
For large datasets:
- Use server mode instead of embedded mode
- Ensure adequate memory allocation
- Consider using OceanBase distributed mode for very large scale
- Adjust HNSW index parameters if needed
## Resources
- SeekDB GitHub: https://github.com/oceanbase/seekdb
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
- OceanBase Documentation: https://oceanbase.ai
- LangBot Documentation: https://docs.langbot.app
## License
SeekDB is licensed under Apache License 2.0.

View File

@@ -1,10 +1,10 @@
[project]
name = "langbot"
version = "4.6.5"
version = "4.5.4"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
requires-python = ">=3.11,<4.0"
requires-python = ">=3.10.1,<4.0"
dependencies = [
"aiocqhttp>=1.4.4",
"aiofiles>=24.1.0",
@@ -63,14 +63,11 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb>=0.1.0",
"langbot-plugin==0.2.4",
"langbot-plugin==0.2.0b1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
"boto3>=1.35.0",
"pymilvus>=2.6.4",
"pgvector>=0.4.1",
]
keywords = [
"bot",

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
__version__ = '4.6.5'
__version__ = '4.5.4'

View File

@@ -1,11 +1,8 @@
import asyncio
import base64
import json
import time
import urllib.parse
from typing import Callable
import dingtalk_stream # type: ignore
import websockets
from .EchoHandler import EchoTextHandler
from .dingtalkevent import DingTalkEvent
import httpx
@@ -39,7 +36,6 @@ class DingTalkClient:
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
self.logger = logger
self._stopped = False # Flag to control the event loop
async def get_access_token(self):
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
@@ -174,9 +170,6 @@ class DingTalkClient:
"""
处理消息事件。
"""
# Skip message handling if stopped
if self._stopped:
return
msg_type = event.conversation
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
@@ -385,70 +378,4 @@ class DingTalkClient:
async def start(self):
"""启动 WebSocket 连接,监听消息"""
self._stopped = False
self.client.pre_start()
while not self._stopped:
try:
connection = self.client.open_connection()
if not connection:
if self.logger:
await self.logger.error('DingTalk: open connection failed')
await asyncio.sleep(10)
continue
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
async with websockets.connect(uri) as websocket:
self.client.websocket = websocket
keepalive_task = asyncio.create_task(self._keepalive(websocket))
try:
async for raw_message in websocket:
if self._stopped:
break
json_message = json.loads(raw_message)
asyncio.create_task(self.client.background_task(json_message))
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
# Properly exit when task is cancelled
break
except websockets.exceptions.ConnectionClosedError as e:
if self._stopped:
break
if self.logger:
await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}')
await asyncio.sleep(5)
continue
except Exception as e:
if self._stopped:
break
if self.logger:
await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}')
await asyncio.sleep(3)
continue
async def _keepalive(self, ws, ping_interval=60):
"""Keep WebSocket connection alive"""
while not self._stopped:
await asyncio.sleep(ping_interval)
try:
await ws.ping()
except websockets.exceptions.ConnectionClosed:
break
async def stop(self):
"""停止 WebSocket 连接"""
self._stopped = True
# Close WebSocket connection if exists
if self.client.websocket:
try:
await self.client.websocket.close()
except Exception:
pass
# Clear message handlers to prevent stale callbacks
self._message_handlers = {'example': []}
await self.client.start()

View File

@@ -23,25 +23,20 @@ xml_template = """
class OAClient:
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None, unified_mode: bool = False):
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None):
self.token = token
self.aes = EncodingAESKey
self.appid = AppID
self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com'
self.access_token = ''
self.unified_mode = unified_mode
self.app = Quart(__name__)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self._message_handlers = {
'example': [],
}
@@ -51,39 +46,19 @@ class OAClient:
self.logger = logger
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request
Args:
req: Quart Request 对象
Returns:
响应数据
"""
return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
Args:
req: Quart Request 对象
"""
try:
# 每隔100毫秒查询是否生成ai回答
start_time = time.time()
signature = req.args.get('signature', '')
timestamp = req.args.get('timestamp', '')
nonce = req.args.get('nonce', '')
echostr = req.args.get('echostr', '')
msg_signature = req.args.get('msg_signature', '')
signature = request.args.get('signature', '')
timestamp = request.args.get('timestamp', '')
nonce = request.args.get('nonce', '')
echostr = request.args.get('echostr', '')
msg_signature = request.args.get('msg_signature', '')
if msg_signature is None:
await self.logger.error('msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中')
if req.method == 'GET':
if request.method == 'GET':
# 校验签名
check_str = ''.join(sorted([self.token, timestamp, nonce]))
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
@@ -93,8 +68,8 @@ class OAClient:
else:
await self.logger.error('拒绝请求')
raise Exception('拒绝请求')
elif req.method == 'POST':
encryt_msg = await req.data
elif request.method == 'POST':
encryt_msg = await request.data
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
xml_msg = xml_msg.decode('utf-8')
@@ -207,7 +182,6 @@ class OAClientForLongerResponse:
Appsecret: str,
LoadingMessage: str,
logger: None,
unified_mode: bool = False,
):
self.token = token
self.aes = EncodingAESKey
@@ -215,18 +189,13 @@ class OAClientForLongerResponse:
self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com'
self.access_token = ''
self.unified_mode = unified_mode
self.app = Quart(__name__)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self._message_handlers = {
'example': [],
}
@@ -237,44 +206,24 @@ class OAClientForLongerResponse:
self.logger = logger
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request
Args:
req: Quart Request 对象
Returns:
响应数据
"""
return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
Args:
req: Quart Request 对象
"""
try:
signature = req.args.get('signature', '')
timestamp = req.args.get('timestamp', '')
nonce = req.args.get('nonce', '')
echostr = req.args.get('echostr', '')
msg_signature = req.args.get('msg_signature', '')
signature = request.args.get('signature', '')
timestamp = request.args.get('timestamp', '')
nonce = request.args.get('nonce', '')
echostr = request.args.get('echostr', '')
msg_signature = request.args.get('msg_signature', '')
if msg_signature is None:
await self.logger.error('msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中')
if req.method == 'GET':
if request.method == 'GET':
check_str = ''.join(sorted([self.token, timestamp, nonce]))
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
return echostr if check_signature == signature else '拒绝请求'
elif req.method == 'POST':
encryt_msg = await req.data
elif request.method == 'POST':
encryt_msg = await request.data
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
xml_msg = xml_msg.decode('utf-8')

View File

@@ -10,20 +10,38 @@ import traceback
from cryptography.hazmat.primitives.asymmetric import ed25519
def handle_validation(body: dict, bot_secret: str):
# bot正确的secert是32位的此处仅为了适配演示demo
while len(bot_secret) < 32:
bot_secret = bot_secret * 2
bot_secret = bot_secret[:32]
# 实际使用场景中以上三行内容可清除
seed_bytes = bot_secret.encode()
signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed_bytes)
msg = body['d']['event_ts'] + body['d']['plain_token']
msg_bytes = msg.encode()
signature = signing_key.sign(msg_bytes)
signature_hex = signature.hex()
response = {'plain_token': body['d']['plain_token'], 'signature': signature_hex}
return response
class QQOfficialClient:
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
self.unified_mode = unified_mode
def __init__(self, secret: str, token: str, app_id: str, logger: None):
self.app = Quart(__name__)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self.secret = secret
self.token = token
self.app_id = app_id
@@ -64,45 +82,18 @@ class QQOfficialClient:
raise Exception(f'获取access_token失败: {e}')
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request
Args:
req: Quart Request 对象
Returns:
响应数据
"""
return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""处理回调请求的内部实现。
Args:
req: Quart Request 对象
"""
"""处理回调请求"""
try:
body = await req.get_data()
print(f'[QQ Official] Received request, body length: {len(body)}')
if not body or len(body) == 0:
print('[QQ Official] Received empty body, might be health check or GET request')
return {'code': 0, 'message': 'ok'}, 200
# 读取请求数据
body = await request.get_data()
payload = json.loads(body)
# 验证是否为回调验证请求
if payload.get('op') == 13:
validation_data = payload.get('d')
if not validation_data:
return {'error': "missing 'd' field"}, 400
response = await self.verify(validation_data)
return response, 200
# 生成签名
response = handle_validation(payload, self.secret)
return response
if payload.get('op') == 0:
message_data = await self.get_message(payload)
@@ -113,7 +104,6 @@ class QQOfficialClient:
return {'code': 0, 'message': 'success'}
except Exception as e:
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
return {'error': str(e)}, 400
@@ -271,26 +261,3 @@ class QQOfficialClient:
if self.access_token_expiry_time is None:
return True
return time.time() > self.access_token_expiry_time
async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes:
seed = bot_secret
while len(seed) < target_size:
seed *= 2
return seed[:target_size].encode("utf-8")
async def verify(self, validation_payload: dict):
seed = await self.repeat_seed(self.secret)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
event_ts = validation_payload.get("event_ts", "")
plain_token = validation_payload.get("plain_token", "")
msg = event_ts + plain_token
# sign
signature = private_key.sign(msg.encode()).hex()
response = {
"plain_token": plain_token,
"signature": signature,
}
return response

View File

@@ -8,19 +8,14 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
class SlackClient:
def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False):
def __init__(self, bot_token: str, signing_secret: str, logger: None):
self.bot_token = bot_token
self.signing_secret = signing_secret
self.unified_mode = unified_mode
self.app = Quart(__name__)
self.client = AsyncWebClient(self.bot_token)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
)
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
)
self._message_handlers = {
'example': [],
}
@@ -28,28 +23,8 @@ class SlackClient:
self.logger = logger
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request
Args:
req: Quart Request 对象
Returns:
响应数据
"""
return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""处理回调请求的内部实现。
Args:
req: Quart Request 对象
"""
try:
body = await req.get_data()
body = await request.get_data()
data = json.loads(body)
if 'type' in data:
if data['type'] == 'url_verification':

View File

@@ -200,7 +200,7 @@ class StreamSessionManager:
class WecomBotClient:
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger):
"""企业微信智能机器人客户端。
Args:
@@ -208,7 +208,6 @@ class WecomBotClient:
EnCodingAESKey: 企业微信消息加解密密钥。
Corpid: 企业 ID。
logger: 日志记录器。
unified_mode: 是否使用统一 webhook 模式(默认 False
Example:
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
@@ -218,15 +217,10 @@ class WecomBotClient:
self.EnCodingAESKey = EnCodingAESKey
self.Corpid = Corpid
self.ReceiveId = ''
self.unified_mode = unified_mode
self.app = Quart(__name__)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']
)
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']
)
self._message_handlers = {
'example': [],
}
@@ -365,7 +359,7 @@ class WecomBotClient:
return await self._encrypt_and_reply(payload, nonce)
async def handle_callback_request(self):
"""企业微信回调入口(独立端口模式,使用全局 request
"""企业微信回调入口。
Returns:
Quart Response: 根据请求类型返回验证、首包或刷新结果。
@@ -373,33 +367,15 @@ class WecomBotClient:
Example:
作为 Quart 路由处理函数直接注册并使用。
"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request
Args:
req: Quart Request 对象
Returns:
响应数据
"""
return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
Args:
req: Quart Request 对象
"""
try:
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
await self.logger.info(f'{request.method} {request.url} {str(request.args)}')
if req.method == 'GET':
return await self._handle_get_callback(req)
if request.method == 'GET':
return await self._handle_get_callback()
if req.method == 'POST':
return await self._handle_post_callback(req)
if request.method == 'POST':
return await self._handle_post_callback()
return Response('', status=405)
@@ -407,13 +383,13 @@ class WecomBotClient:
await self.logger.error(traceback.format_exc())
return Response('Internal Server Error', status=500)
async def _handle_get_callback(self, req) -> tuple[Response, int] | Response:
async def _handle_get_callback(self) -> tuple[Response, int] | Response:
"""处理企业微信的 GET 验证请求。"""
msg_signature = unquote(req.args.get('msg_signature', ''))
timestamp = unquote(req.args.get('timestamp', ''))
nonce = unquote(req.args.get('nonce', ''))
echostr = unquote(req.args.get('echostr', ''))
msg_signature = unquote(request.args.get('msg_signature', ''))
timestamp = unquote(request.args.get('timestamp', ''))
nonce = unquote(request.args.get('nonce', ''))
echostr = unquote(request.args.get('echostr', ''))
if not all([msg_signature, timestamp, nonce, echostr]):
await self.logger.error('请求参数缺失')
@@ -426,16 +402,16 @@ class WecomBotClient:
return Response(decrypted_str, mimetype='text/plain')
async def _handle_post_callback(self, req) -> tuple[Response, int] | Response:
async def _handle_post_callback(self) -> tuple[Response, int] | Response:
"""处理企业微信的 POST 回调请求。"""
self.stream_sessions.cleanup()
msg_signature = unquote(req.args.get('msg_signature', ''))
timestamp = unquote(req.args.get('timestamp', ''))
nonce = unquote(req.args.get('nonce', ''))
msg_signature = unquote(request.args.get('msg_signature', ''))
timestamp = unquote(request.args.get('timestamp', ''))
nonce = unquote(request.args.get('nonce', ''))
encrypted_json = await req.get_json()
encrypted_json = await request.get_json()
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
if not encrypted_msg:
await self.logger.error("请求体中缺少 'encrypt' 字段")
@@ -457,174 +433,32 @@ class WecomBotClient:
async def get_message(self, msg_json):
message_data = {}
msg_type = msg_json.get('msgtype', '')
if msg_type:
message_data['msgtype'] = msg_type
if msg_json.get('chattype', '') == 'single':
message_data['type'] = 'single'
elif msg_json.get('chattype', '') == 'group':
message_data['type'] = 'group'
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
async def _safe_download(url: str):
if not url:
return None
return await self.download_url_to_base64(url, self.EnCodingAESKey)
if msg_type == 'text':
if msg_json.get('msgtype') == 'text':
message_data['content'] = msg_json.get('text', {}).get('content')
elif msg_type == 'markdown':
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
'content', ''
)
elif msg_type == 'image':
elif msg_json.get('msgtype') == 'image':
picurl = msg_json.get('image', {}).get('url', '')
base64_data = await _safe_download(picurl)
if base64_data:
message_data['picurl'] = base64_data
message_data['images'] = [base64_data]
elif msg_type == 'voice':
voice_info = msg_json.get('voice', {}) or {}
download_url = voice_info.get('url')
message_data['voice'] = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
# 企业微信智能转写文本(如果已有)直接复用,避免重复转写
if voice_info.get('content'):
message_data['content'] = voice_info.get('content')
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
message_data['voice']['base64'] = voice_base64
elif msg_type == 'video':
video_info = msg_json.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
message_data['video'] = video_data
elif msg_type == 'file':
file_info = msg_json.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
message_data['file'] = file_data
elif msg_type == 'link':
message_data['link'] = msg_json.get('link', {})
if not message_data.get('content'):
title = message_data['link'].get('title', '')
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
message_data['content'] = '\n'.join(filter(None, [title, desc]))
elif msg_type == 'mixed':
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
message_data['picurl'] = base64
elif msg_json.get('msgtype') == 'mixed':
items = msg_json.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
voices = []
videos = []
links = []
picurl = None
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
if item.get('msgtype') == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_url = item.get('image', {}).get('url')
base64_data = await _safe_download(img_url)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_base64 = await _safe_download(download_url)
if file_base64:
file_data['base64'] = file_base64
files.append(file_data)
elif item_type == 'voice':
voice_info = item.get('voice', {}) or {}
download_url = voice_info.get('url')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
texts.append(voice_info.get('content'))
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
voice_base64 = await _safe_download(download_url)
if voice_base64:
voice_data['base64'] = voice_base64
voices.append(voice_data)
elif item_type == 'video':
video_info = item.get('video', {}) or {}
download_url = video_info.get('url')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
if (video_data.get('filesize') or 0) <= max_inline_file_size:
video_base64 = await _safe_download(download_url)
if video_base64:
video_data['base64'] = video_base64
videos.append(video_data)
elif item_type == 'link':
links.append(item.get('link', {}))
elif item.get('msgtype') == 'image' and picurl is None:
picurl = item.get('image', {}).get('url')
if texts:
message_data['content'] = ' '.join(texts) # 拼接所有 text
if images:
message_data['images'] = images
message_data['picurl'] = images[0] # 只保留第一个 image
if files:
message_data['files'] = files
message_data['file'] = files[0]
if voices:
message_data['voices'] = voices
message_data['voice'] = voices[0]
if videos:
message_data['videos'] = videos
message_data['video'] = videos[0]
if links:
message_data['link'] = links[0]
if items:
message_data['attachments'] = items
else:
message_data['raw_msg'] = msg_json
message_data['content'] = ''.join(texts) # 拼接所有 text
if picurl:
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
message_data['picurl'] = base64 # 只保留第一个 image
# Extract user information
from_info = msg_json.get('from', {})

View File

@@ -17,13 +17,6 @@ class WecomBotEvent(dict):
"""
return self.get('type', '')
@property
def msgtype(self) -> str:
"""
消息 msgtype
"""
return self.get('msgtype', '')
@property
def userid(self) -> str:
"""
@@ -36,7 +29,12 @@ class WecomBotEvent(dict):
"""
用户名称
"""
return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid
return (
self.get('username', '')
or self.get('from', {}).get('alias', '')
or self.get('from', {}).get('name', '')
or self.userid
)
@property
def chatname(self) -> str:
@@ -59,55 +57,6 @@ class WecomBotEvent(dict):
"""
return self.get('picurl', '')
@property
def images(self):
"""
图片列表(兼容 mixed
"""
return self.get('images', [])
@property
def file(self):
"""
文件信息
"""
return self.get('file', {})
@property
def voice(self):
"""
语音信息
"""
return self.get('voice', {})
@property
def video(self):
"""
视频信息
"""
return self.get('video', {})
@property
def link(self):
"""
链接消息信息
"""
return self.get('link', {})
@property
def location(self):
"""
位置信息
"""
return self.get('location', {})
@property
def attachments(self):
"""
原始 mixed 中的附件项
"""
return self.get('attachments', [])
@property
def chatid(self) -> str:
"""
@@ -121,7 +70,7 @@ class WecomBotEvent(dict):
消息id
"""
return self.get('msgid', '')
@property
def ai_bot_id(self) -> str:
"""

View File

@@ -21,7 +21,6 @@ class WecomClient:
EncodingAESKey: str,
contacts_secret: str,
logger: None,
unified_mode: bool = False,
):
self.corpid = corpid
self.secret = secret
@@ -32,18 +31,13 @@ class WecomClient:
self.access_token = ''
self.secret_for_contacts = contacts_secret
self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self.app.add_url_rule(
'/callback/command',
'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self._message_handlers = {
'example': [],
}
@@ -139,58 +133,6 @@ class WecomClient:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(data))
async def send_voice(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'voice',
'agentid': agent_id,
'voice': {
'media_id': media_id,
},
'safe': 0,
'enable_id_trans': 0,
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_voice(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送语音失败:{data}')
raise Exception('Failed to send voice: ' + str(data))
async def send_file(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'file',
'agentid': agent_id,
'file': {
'media_id': media_id,
},
'safe': 0,
'enable_id_trans': 0,
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_file(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送文件失败:{data}')
raise Exception('Failed to send file: ' + str(data))
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
@@ -219,43 +161,25 @@ class WecomClient:
raise Exception('Failed to send message: ' + str(data))
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request
Args:
req: Quart Request 对象
Returns:
响应数据
"""
return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
Args:
req: Quart Request 对象
处理回调请求,包括 GET 验证和 POST 消息接收。
"""
try:
msg_signature = req.args.get('msg_signature')
timestamp = req.args.get('timestamp')
nonce = req.args.get('nonce')
msg_signature = request.args.get('msg_signature')
timestamp = request.args.get('timestamp')
nonce = request.args.get('nonce')
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
if req.method == 'GET':
echostr = req.args.get('echostr')
if request.method == 'GET':
echostr = request.args.get('echostr')
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0:
await self.logger.error('验证失败')
raise Exception(f'验证失败,错误码: {ret}')
return reply_echo_str
elif req.method == 'POST':
encrypt_msg = await req.data
elif request.method == 'POST':
encrypt_msg = await request.data
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0:
await self.logger.error('消息解密失败')
@@ -339,7 +263,7 @@ class WecomClient:
return ext
return 'jpg' # 默认返回jpg
async def upload_image_to_work(self, image: platform_message.Image):
async def upload_to_work(self, image: platform_message.Image):
"""
获取 media_id
"""
@@ -356,7 +280,7 @@ class WecomClient:
file_bytes = await f.read()
file_name = image.path.split('/')[-1]
elif image.url:
file_bytes = await self.download_media_to_bytes(image.url)
file_bytes = await self.download_image_to_bytes(image.url)
file_name = image.url.split('/')[-1]
elif image.base64:
try:
@@ -391,7 +315,7 @@ class WecomClient:
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_image_to_work(image)
media_id = await self.upload_to_work(image)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传图片失败:{data}')
raise Exception('failed to upload file')
@@ -399,128 +323,13 @@ class WecomClient:
media_id = data.get('media_id')
return media_id
async def upload_voice_to_work(self, voice: platform_message.Voice):
"""
上传语音文件到企业微信
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = 'voice.mp3'
if voice.path:
async with aiofiles.open(voice.path, 'rb') as f:
file_bytes = await f.read()
file_name = voice.path.split('/')[-1]
elif voice.url:
file_bytes = await self.download_media_to_bytes(voice.url)
file_name = voice.url.split('/')[-1]
elif voice.base64:
try:
base64_data = voice.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('Voice对象出错')
raise ValueError('voice对象出错')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
(
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
f'Content-Type: application/octet-stream\r\n\r\n'
).encode('utf-8')
+ file_bytes
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
)
# print(body)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_voice_to_work(voice)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传语音文件失败:{data}')
raise Exception('failed to upload file')
media_id = data.get('media_id')
return media_id
async def upload_file_to_work(self, file: platform_message.File):
"""
上传文件到企业微信
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = 'file.txt'
if file.path:
async with aiofiles.open(file.path, 'rb') as f:
file_bytes = await f.read()
file_name = file.path.split('/')[-1]
elif file.url:
file_bytes = await self.download_media_to_bytes(file.url)
file_name = file.url.split('/')[-1]
elif file.base64:
try:
base64_data = file.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('File对象出错')
raise ValueError('file对象出错')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
(
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
f'Content-Type: application/octet-stream\r\n\r\n'
).encode('utf-8')
+ file_bytes
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_file_to_work(file)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传文件失败:{data}')
raise Exception('failed to upload file')
media_id = data.get('media_id')
return media_id
async def download_media_to_bytes(self, url: str) -> bytes:
async def download_image_to_bytes(self, url: str) -> bytes:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.content
# 进行media_id的获取
async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
if isinstance(media, platform_message.Image):
media_id = await self.upload_image_to_work(image=media)
elif isinstance(media, platform_message.Voice):
media_id = await self.upload_voice_to_work(voice=media)
elif isinstance(media, platform_message.File):
media_id = await self.upload_file_to_work(file=media)
else:
raise ValueError('Unsupported media type')
async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image)
return media_id

View File

@@ -13,7 +13,7 @@ import aiofiles
class WecomCSClient:
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None, unified_mode: bool = False):
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None):
self.corpid = corpid
self.secret = secret
self.access_token_for_contacts = ''
@@ -22,15 +22,10 @@ class WecomCSClient:
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
self.access_token = ''
self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__)
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
)
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
)
self._message_handlers = {
'example': [],
}
@@ -197,45 +192,27 @@ class WecomCSClient:
return data
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request
Args:
req: Quart Request 对象
Returns:
响应数据
"""
return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
Args:
req: Quart Request 对象
处理回调请求,包括 GET 验证和 POST 消息接收。
"""
try:
msg_signature = req.args.get('msg_signature')
timestamp = req.args.get('timestamp')
nonce = req.args.get('nonce')
msg_signature = request.args.get('msg_signature')
timestamp = request.args.get('timestamp')
nonce = request.args.get('nonce')
try:
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
except Exception as e:
raise Exception(f'初始化失败,错误码: {e}')
if req.method == 'GET':
echostr = req.args.get('echostr')
if request.method == 'GET':
echostr = request.args.get('echostr')
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0:
raise Exception(f'验证失败,错误码: {ret}')
return reply_echo_str
elif req.method == 'POST':
encrypt_msg = await req.data
elif request.method == 'POST':
encrypt_msg = await request.data
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0:
raise Exception(f'消息解密失败,错误码: {ret}')

View File

@@ -49,14 +49,6 @@ class PipelinesRouterGroup(group.RouterGroup):
return self.success()
@self.route('/<pipeline_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(pipeline_uuid: str) -> str:
try:
new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)
return self.success(data={'uuid': new_uuid})
except ValueError as e:
return self.http_status(404, -1, str(e))
@self.route(
'/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)

View File

@@ -18,8 +18,7 @@ class BotsRouterGroup(group.RouterGroup):
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET':
# 返回运行时信息包括webhook地址等
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
bot = await self.ap.bot_service.get_bot(bot_uuid)
if bot is None:
return self.http_status(404, -1, 'bot not found')
return self.success(data={'bot': bot})

View File

@@ -21,22 +21,6 @@ class PluginsRouterGroup(group.RouterGroup):
return self.success(data={'plugins': plugins})
@self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
"""Get plugin debug information including debug URL and key"""
debug_info = await self.ap.plugin_connector.get_debug_info()
# Get debug URL from config
plugin_config = self.ap.instance_config.data.get('plugin', {})
debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
return self.success(
data={
'debug_url': debug_url,
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
}
)
@self.route(
'/<author>/<plugin_name>/upgrade',
methods=['POST'],

View File

@@ -23,9 +23,6 @@ class SystemRouterGroup(group.RouterGroup):
if 'cloud_service_url' in self.ap.instance_config.data.get('plugin', {})
else 'https://space.langbot.app'
),
'allow_change_password': self.ap.instance_config.data.get('system', {}).get(
'allow_change_password', True
),
}
)

View File

@@ -70,13 +70,6 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
# Check if password change is allowed
allow_change_password = self.ap.instance_config.data.get('system', {}).get(
'allow_change_password', True
)
if not allow_change_password:
return self.http_status(403, -1, 'Password change is disabled')
json_data = await quart.request.json
current_password = json_data['current_password']

View File

@@ -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()

View File

@@ -1,57 +1,49 @@
from __future__ import annotations
import quart
import traceback
from .. import group
@group.group_class('webhooks', '/bots')
class WebhookRouterGroup(group.RouterGroup):
@group.group_class('webhooks', '/api/v1/webhooks')
class WebhooksRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def handle_webhook(bot_uuid: str):
"""处理 bot webhook 回调(无子路径)"""
return await self._dispatch_webhook(bot_uuid, '')
@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)
@self.route('/<bot_uuid>/<path:path>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def handle_webhook_with_path(bot_uuid: str, path: str):
"""处理 bot webhook 回调(带子路径)"""
return await self._dispatch_webhook(bot_uuid, path)
if not name:
return self.http_status(400, -1, 'Name is required')
if not url:
return self.http_status(400, -1, 'URL is required')
async def _dispatch_webhook(self, bot_uuid: str, path: str):
"""分发 webhook 请求到对应的 bot adapter
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
return self.success(data={'webhook': webhook})
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
@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})
Returns:
适配器返回的响应
"""
try:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
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')
if not runtime_bot:
return quart.jsonify({'error': 'Bot not found'}), 404
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
return self.success()
if not runtime_bot.enable:
return quart.jsonify({'error': 'Bot is disabled'}), 403
if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'):
return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501
response = await runtime_bot.adapter.handle_unified_webhook(
bot_uuid=bot_uuid,
path=path,
request=quart.request,
)
return response
except Exception as e:
self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}')
return quart.jsonify({'error': str(e)}), 500
elif quart.request.method == 'DELETE':
await self.ap.webhook_service.delete_webhook(webhook_id)
return self.success()

View File

@@ -92,11 +92,7 @@ class HTTPController:
@self.quart_app.route('/')
async def index():
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
return await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
@self.quart_app.route('/<path:path>')
async def static_file(path: str):

View File

@@ -58,16 +58,6 @@ class BotService:
if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
# Webhook URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE', 'lark']:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
webhook_url = f'/bots/{bot_uuid}'
adapter_runtime_values['webhook_url'] = webhook_url
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
else:
adapter_runtime_values['webhook_url'] = None
adapter_runtime_values['webhook_full_url'] = None
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
return persistence_bot

View File

@@ -74,16 +74,7 @@ class KnowledgeService:
# Only internal KBs support file storage
if runtime_kb.get_type() != 'internal':
raise Exception('Only internal knowledge bases support file storage')
result = await runtime_kb.store_file(file_id)
# Update the KB's updated_at timestamp
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(updated_at=sqlalchemy.func.now())
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
return result
return await runtime_kb.store_file(file_id)
async def retrieve_knowledge_base(self, kb_uuid: str, query: str) -> list[dict]:
"""检索知识库"""
@@ -112,13 +103,6 @@ class KnowledgeService:
raise Exception('Only internal knowledge bases support file deletion')
await runtime_kb.delete_file(file_id)
# Update the KB's updated_at timestamp
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(updated_at=sqlalchemy.func.now())
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
async def delete_knowledge_base(self, kb_uuid: str) -> None:
"""删除知识库"""
await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)

View File

@@ -151,52 +151,6 @@ class PipelineService:
)
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
async def copy_pipeline(self, pipeline_uuid: str) -> str:
"""Copy a pipeline with all its configurations"""
# Get the original pipeline
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
)
)
original_pipeline = result.first()
if original_pipeline is None:
raise ValueError(f'Pipeline {pipeline_uuid} not found')
# Create new pipeline data
new_uuid = str(uuid.uuid4())
new_pipeline_data = {
'uuid': new_uuid,
'name': f'{original_pipeline.name} (Copy)',
'description': original_pipeline.description,
'for_version': self.ap.ver_mgr.get_current_version(),
'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(),
'config': original_pipeline.config.copy() if original_pipeline.config else {},
'is_default': False,
'extensions_preferences': (
original_pipeline.extensions_preferences.copy()
if original_pipeline.extensions_preferences
else {
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
}
),
}
# Insert the new pipeline
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data)
)
# Load the new pipeline
pipeline = await self.get_pipeline(new_uuid)
await self.ap.pipeline_mgr.load_pipeline(pipeline)
return new_uuid
async def update_pipeline_extensions(
self,
pipeline_uuid: str,

View File

@@ -1,5 +1,4 @@
import logging
import logging.handlers
import sys
import time
@@ -16,10 +15,6 @@ log_colors_config = {
'CRITICAL': 'cyan',
}
# Log rotation configuration to prevent unbounded log file growth
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
# Remove all existing loggers
@@ -48,17 +43,9 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
# stream_handler.setFormatter(color_formatter)
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
# Use RotatingFileHandler to prevent unbounded log file growth
rotating_file_handler = logging.handlers.RotatingFileHandler(
log_file_name,
encoding='utf-8',
maxBytes=LOG_FILE_MAX_BYTES,
backupCount=LOG_FILE_BACKUP_COUNT,
)
log_handlers: list[logging.Handler] = [
stream_handler,
rotating_file_handler,
logging.FileHandler(log_file_name, encoding='utf-8'),
]
log_handlers += extra_handlers if extra_handlers is not None else []

View File

@@ -8,7 +8,6 @@ class KnowledgeBase(Base):
name = sqlalchemy.Column(sqlalchemy.String, index=True)
description = sqlalchemy.Column(sqlalchemy.Text)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now())
embedding_model_uuid = sqlalchemy.Column(sqlalchemy.String, default='')
top_k = sqlalchemy.Column(sqlalchemy.Integer, default=5)

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(2)
@@ -10,45 +11,30 @@ class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
async def upgrade(self):
"""Upgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Ensure 'trigger' exists
if 'trigger' not in config:
config['trigger'] = {}
# Ensure 'misc' exists in 'trigger'
if 'misc' not in config['trigger']:
config['trigger']['misc'] = {}
# Add 'combine-quote-message' if not exists
if 'combine-quote-message' not in config['trigger']['misc']:
config['trigger']['misc']['combine-quote-message'] = False
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(3)
@@ -10,23 +11,14 @@ class DBMigrateN8nConfig(migration.DBMigration):
async def upgrade(self):
"""Upgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Ensure 'ai' exists
if 'ai' not in config:
config['ai'] = {}
# Add 'n8n-service-api' if not exists
if 'n8n-service-api' not in config['ai']:
config['ai']['n8n-service-api'] = {
'webhook-url': 'http://your-n8n-webhook-url',
@@ -41,21 +33,16 @@ class DBMigrateN8nConfig(migration.DBMigration):
'output-key': 'response',
}
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(4)
@@ -10,43 +11,27 @@ class DBMigrateRAGKBUUID(migration.DBMigration):
async def upgrade(self):
"""升级"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Ensure nested structure exists
if 'ai' not in config:
config['ai'] = {}
if 'local-agent' not in config['ai']:
config['ai']['local-agent'] = {}
# Add 'knowledge-base' if not exists
if 'knowledge-base' not in config['ai']['local-agent']:
config['ai']['local-agent']['knowledge-base'] = ''
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""降级"""

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(5)
@@ -10,43 +11,27 @@ class DBMigratePipelineRemoveCotConfig(migration.DBMigration):
async def upgrade(self):
"""Upgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Ensure nested structure exists
if 'output' not in config:
config['output'] = {}
if 'misc' not in config['output']:
config['output']['misc'] = {}
# Add 'remove-think' if not exists
if 'remove-think' not in config['output']['misc']:
config['output']['misc']['remove-think'] = False
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(6)
@@ -10,23 +11,14 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
async def upgrade(self):
"""Upgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Ensure 'ai' exists
if 'ai' not in config:
config['ai'] = {}
# Add 'langflow-api' if not exists
if 'langflow-api' not in config['ai']:
config['ai']['langflow-api'] = {
'base-url': 'http://localhost:7860',
@@ -37,21 +29,16 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
'tweaks': '{}',
}
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(10)
@@ -10,20 +11,16 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
async def upgrade(self):
"""Upgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Convert knowledge-base from string to array
if 'ai' in config and 'local-agent' in config['ai']:
if 'local-agent' in config['ai']:
current_kb = config['ai']['local-agent'].get('knowledge-base', '')
# If it's already a list, skip
@@ -40,38 +37,29 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
if 'knowledge-base' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-base']
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Convert knowledge-bases from array back to string
if 'ai' in config and 'local-agent' in config['ai']:
if 'local-agent' in config['ai']:
current_kbs = config['ai']['local-agent'].get('knowledge-bases', [])
# If it's already a string, skip
@@ -88,18 +76,13 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
if 'knowledge-bases' in config['ai']['local-agent']:
del config['ai']['local-agent']['knowledge-bases']
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(11)
@@ -10,45 +11,29 @@ class DBMigrateDifyApiConfig(migration.DBMigration):
async def upgrade(self):
"""Upgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
config = serialized_pipeline['config']
# Ensure nested structure exists
if 'ai' not in config:
config['ai'] = {}
if 'dify-service-api' not in config['ai']:
config['ai']['dify-service-api'] = {}
# Add 'base-prompt' if not exists
if 'base-prompt' not in config['ai']['dify-service-api']:
config['ai']['dify-service-api']['base-prompt'] = (
'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.',
)
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
),
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""Downgrade"""

View File

@@ -1,7 +1,8 @@
from .. import migration
import sqlalchemy
import json
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(12)
@@ -10,25 +11,14 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
async def upgrade(self):
"""Upgrade"""
# Read all pipelines using raw SQL
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines')
)
pipelines = result.fetchall()
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
current_version = self.ap.ver_mgr.get_current_version()
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline_row in pipelines:
uuid = pipeline_row[0]
extensions_preferences = (
json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
)
extensions_preferences = serialized_pipeline['extensions_preferences']
# Ensure extensions_preferences is a dict
if extensions_preferences is None:
extensions_preferences = {}
# Add 'enable_all_plugins' if not exists
if 'enable_all_plugins' not in extensions_preferences:
if 'plugins' in extensions_preferences:
extensions_preferences['enable_all_plugins'] = False
@@ -36,7 +26,6 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
extensions_preferences['enable_all_plugins'] = True
extensions_preferences['plugins'] = []
# Add 'enable_all_mcp_servers' if not exists
if 'enable_all_mcp_servers' not in extensions_preferences:
if 'mcp_servers' in extensions_preferences:
extensions_preferences['enable_all_mcp_servers'] = False
@@ -44,29 +33,14 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
extensions_preferences['enable_all_mcp_servers'] = True
extensions_preferences['mcp_servers'] = []
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid'
),
{
'extensions_preferences': json.dumps(extensions_preferences),
'for_version': current_version,
'uuid': uuid,
},
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid'
),
{
'extensions_preferences': json.dumps(extensions_preferences),
'for_version': current_version,
'uuid': uuid,
},
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
extensions_preferences=extensions_preferences,
for_version=self.ap.ver_mgr.get_current_version(),
)
)
async def downgrade(self):
"""Downgrade"""

View File

@@ -1,49 +0,0 @@
import sqlalchemy
from .. import migration
@migration.migration_class(13)
class DBMigrateKnowledgeBaseUpdatedAt(migration.DBMigration):
"""Add updated_at field to knowledge_bases table"""
async def upgrade(self):
"""Upgrade"""
# Get all column names from the table
columns = []
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'knowledge_bases';"
)
)
all_result = result.fetchall()
columns = [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(knowledge_bases);'))
all_result = result.fetchall()
columns = [row[1] for row in all_result]
# Check and add updated_at column
if 'updated_at' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'ALTER TABLE knowledge_bases ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'
)
)
else:
# SQLite doesn't support DEFAULT CURRENT_TIMESTAMP in ALTER TABLE
# Add column without default first
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE knowledge_bases ADD COLUMN updated_at DATETIME')
)
# Set initial updated_at values to created_at for existing records
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE knowledge_bases SET updated_at = created_at WHERE updated_at IS NULL')
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -33,14 +33,11 @@ class Controller:
for query in queries:
session = await self.ap.sess_mgr.get_session(query)
# Debug logging removed from tight loop to prevent excessive log generation
# that can cause memory overflow in high-traffic scenarios
self.ap.logger.debug(f'Checking query {query} session {session}')
if not session._semaphore.locked():
selected_query = query
await session._semaphore.acquire()
# Only log when actually selecting a query
self.ap.logger.debug(f'Selected query {query.query_id} for processing')
break

View File

@@ -237,7 +237,6 @@ class RuntimePipeline:
launcher_type=query.launcher_type.value,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
message_event=query.message_event,
message_chain=query.message_chain,
)

View File

@@ -6,8 +6,8 @@ from .. import stage, entities
from langbot_plugin.api.entities.builtin.provider import message as provider_message
import langbot_plugin.api.entities.events as events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@stage.stage_class('PreProcessor')
@@ -75,25 +75,18 @@ class PreProcessor(stage.PipelineStage):
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
# Extract sender name from message event
sender_name = ''
if isinstance(query.message_event, platform_events.GroupMessage):
sender_name = query.message_event.sender.member_name
elif isinstance(query.message_event, platform_events.FriendMessage):
sender_name = query.message_event.sender.nickname
if isinstance(query.message_event, (platform_events.FriendMessage, platform_events.GroupMessage)):
sender_name = query.message_event.sender.get_name()
variables = {
'launcher_type': query.session.launcher_type.value,
'launcher_id': query.session.launcher_id,
'sender_id': query.sender_id,
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
'conversation_id': conversation.uuid,
'msg_create_time': (
int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp())
),
'group_name': query.message_event.group.name
if isinstance(query.message_event, platform_events.GroupMessage)
else '',
'sender_id': str(query.sender_id),
'sender_name': sender_name,
}
query.variables.update(variables)
@@ -126,12 +119,6 @@ class PreProcessor(stage.PipelineStage):
):
if me.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
elif isinstance(me, platform_message.Voice):
# 转成文件链接,让下游 runner 上传到目标模型
if me.base64:
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))
elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
elif isinstance(me, platform_message.File):
# if me.url is not None:
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))

View File

@@ -14,7 +14,6 @@ from ....utils import importutil
from ....provider import runners
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
importutil.import_modules_in_pkg(runners)
@@ -41,7 +40,6 @@ class ChatMessageHandler(handler.MessageHandler):
launcher_id=query.launcher_id,
sender_id=query.sender_id,
text_message=str(query.message_chain),
message_event=query.message_event,
message_chain=query.message_chain,
query=query,
)
@@ -62,14 +60,8 @@ class ChatMessageHandler(handler.MessageHandler):
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
else:
if event_ctx.event.user_message_alter is not None:
if isinstance(event_ctx.event.user_message_alter, list):
query.user_message.content = event_ctx.event.user_message_alter
elif isinstance(event_ctx.event.user_message_alter, str):
query.user_message.content = [
provider_message.ContentElement.from_text(event_ctx.event.user_message_alter)
]
elif isinstance(event_ctx.event.user_message_alter, provider_message.ContentElement):
query.user_message.content = [event_ctx.event.user_message_alter]
# if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter
query.user_message.content = event_ctx.event.user_message_alter
text_length = 0
try:
@@ -83,10 +75,9 @@ class ChatMessageHandler(handler.MessageHandler):
runner = r(self.ap, query.pipeline_config)
break
else:
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
raise ValueError(f'未找到请求运行器: {query.pipeline_config["ai"]["runner"]["runner"]}')
if is_stream:
resp_message_id = uuid.uuid4()
chunk_count = 0 # Track streaming chunks to reduce excessive logging
async for result in runner.run(query):
result.resp_message_id = str(resp_message_id)
@@ -99,37 +90,18 @@ class ChatMessageHandler(handler.MessageHandler):
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
is_create_card = True
query.resp_messages.append(result)
chunk_count += 1
# Only log every 10th chunk to reduce excessive logging during streaming
# This prevents memory overflow from thousands of log entries per conversation
# First chunk uses INFO level to confirm connection establishment
if chunk_count == 1:
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}'
)
elif chunk_count % 10 == 0:
self.ap.logger.debug(
f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'
)
self.ap.logger.info(f'对话({query.query_id})流式响应: {self.cut_str(result.readable_str())}')
if result.content is not None:
text_length += len(result.content)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
# Log final summary after streaming completes
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
)
else:
async for result in runner.run(query):
query.resp_messages.append(result)
self.ap.logger.info(
f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'
)
self.ap.logger.info(f'对话({query.query_id})响应: {self.cut_str(result.readable_str())}')
if result.content is not None:
text_length += len(result.content)
@@ -140,7 +112,7 @@ class ChatMessageHandler(handler.MessageHandler):
query.session.using_conversation.messages.extend(query.resp_messages)
except Exception as e:
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {type(e).__name__} {str(e)}')
self.ap.logger.error(f'对话({query.query_id})请求失败: {type(e).__name__} {str(e)}')
traceback.print_exc()
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']

View File

@@ -31,8 +31,4 @@ class AtBotRule(rule_model.GroupRespondRule):
remove_at(message_chain)
remove_at(message_chain) # 回复消息时会at两次检查并删除重复的
should_respond_at = rule_dict.get('at', None)
if should_respond_at is not None:
return entities.RuleJudgeResult(matching=found and bool(should_respond_at), replacement=message_chain)
return entities.RuleJudgeResult(matching=found, replacement=message_chain)

View File

@@ -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)
@@ -255,10 +245,6 @@ class PlatformManager:
logger,
)
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
await runtime_bot.initialize()

View File

@@ -327,6 +327,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title=event.sender['title'] if 'title' in event.sender else '',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=yiri_chain,
time=event.time,

View File

@@ -119,6 +119,9 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
)
time = event.incoming_message.create_at
return platform_events.GroupMessage(
@@ -260,8 +263,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.start()
async def kill(self) -> bool:
await self.bot.stop()
return True
return False
async def is_muted(self) -> bool:
return False

View File

@@ -8,9 +8,6 @@ import base64
import uuid
import os
import datetime
# 使用BytesIO创建文件对象避免路径问题
import io
import asyncio
from enum import Enum
@@ -597,7 +594,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
break
text_string = ''
files = []
image_files = []
for ele in message_chain:
if isinstance(ele, platform_message.Image):
@@ -671,67 +668,22 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
continue # 跳过读取失败的文件
if image_bytes:
files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
# 使用BytesIO创建文件对象避免路径问题
import io
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
elif isinstance(ele, platform_message.Plain):
text_string += ele.text
elif isinstance(ele, platform_message.Voice):
file_bytes = None
filename = f'{uuid.uuid4()}.mp3'
if ele.base64:
if ele.base64.startswith('data:'):
data_header = ele.base64.split(',')[0]
if 'wav' in data_header:
filename = f'{uuid.uuid4()}.wav'
elif 'mp3' in data_header:
filename = f'{uuid.uuid4()}.mp3'
elif 'ogg' in data_header:
filename = f'{uuid.uuid4()}.ogg'
elif 'm4a' in data_header:
filename = f'{uuid.uuid4()}.m4a'
elif 'aac' in data_header:
filename = f'{uuid.uuid4()}.aac'
elif 'flac' in data_header:
filename = f'{uuid.uuid4()}.flac'
elif 'alac' in data_header:
filename = f'{uuid.uuid4()}.alac'
elif 'opus' in data_header:
filename = f'{uuid.uuid4()}.opus'
elif 'webm' in data_header:
filename = f'{uuid.uuid4()}.webm'
file_base64 = ele.base64.split(',')[-1]
file_bytes = base64.b64decode(file_base64)
elif ele.url:
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
file_bytes = await response.read()
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.File):
file_bytes = None
filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}'
if ele.base64:
if ele.base64.startswith('data:'):
file_base64 = ele.base64.split(',')[1]
file_bytes = base64.b64decode(file_base64)
else:
file_bytes = base64.b64decode(ele.base64)
elif ele.url:
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
file_bytes = await response.read()
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.Forward):
for node in ele.node_list:
(
node_text,
node_files,
node_images,
) = await DiscordMessageConverter.yiri2target(node.message_chain)
text_string += node_text
files.extend(node_files)
image_files.extend(node_images)
return text_string, files
return text_string, image_files
@staticmethod
async def target2yiri(message: discord.Message) -> platform_message.MessageChain:
@@ -817,6 +769,9 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=event.created_at.timestamp(),
@@ -1038,7 +993,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.voice_manager.cleanup_inactive_connections()
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
msg_to_send, files = await self.message_converter.yiri2target(message)
msg_to_send, image_files = await self.message_converter.yiri2target(message)
try:
# 获取频道对象
@@ -1051,8 +1006,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'content': msg_to_send,
}
if len(files) > 0:
args['files'] = files
if len(image_files) > 0:
args['files'] = image_files
await channel.send(**args)
@@ -1066,16 +1021,15 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message: platform_message.MessageChain,
quote_origin: bool = False,
):
msg_to_send, files = await self.message_converter.yiri2target(message)
msg_to_send, image_files = await self.message_converter.yiri2target(message)
assert isinstance(message_source.source_platform_object, discord.Message)
args = {
'content': msg_to_send,
}
if len(files) > 0:
args['files'] = files
if len(image_files) > 0:
args['files'] = image_files
if quote_origin:
args['reference'] = message_source.source_platform_object

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,680 +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.File(url=content, name=file_name))
elif msg_type == 8: # Audio message
# For audio messages, content is typically the audio URL
attachments = extra.get('attachments', {})
components.append(platform_message.Voice(url=content))
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='',
),
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']
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:
return
# Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels
if event_type in [1, 2, 4, 8, 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))
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
async with websockets.connect(self.gateway_url) as ws:
await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}')
self.ws = ws
# 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')
pass
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
payload['reply_msg_id'] = 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

View File

@@ -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

View File

@@ -9,13 +9,9 @@ import re
import base64
import uuid
import json
import time
import datetime
import hashlib
from Crypto.Cipher import AES
import tempfile
import os
import mimetypes
import aiohttp
import lark_oapi.ws.exception
@@ -23,8 +19,6 @@ import quart
from lark_oapi.api.im.v1 import *
import pydantic
from lark_oapi.api.cardkit.v1 import *
from lark_oapi.api.auth.v3 import *
from lark_oapi.core.model import *
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -61,7 +55,9 @@ class AESCipher(object):
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]:
async def upload_image_to_lark(
msg: platform_message.Image, api_client: lark_oapi.Client
) -> typing.Optional[str]:
"""Upload an image to Lark and return the image_key, or None if upload fails."""
image_bytes = None
@@ -99,9 +95,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return None
if image_bytes is None:
print(
f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})'
)
print(f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})')
return None
try:
@@ -119,7 +113,10 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
request = (
CreateImageRequest.builder()
.request_body(
CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
CreateImageRequestBody.builder()
.image_type('message')
.image(open(temp_file_path, 'rb'))
.build()
)
.build()
)
@@ -146,7 +143,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
) -> typing.Tuple[list, list]:
"""Convert message chain to Lark format.
Returns:
Tuple of (text_elements, image_keys):
- text_elements: List of paragraphs for post message format
@@ -162,24 +159,24 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
async def process_text_with_images(text: str) -> typing.Tuple[str, list]:
"""Extract Markdown images from text and return cleaned text + image URLs."""
extracted_urls = []
# Find all Markdown images
matches = list(markdown_image_pattern.finditer(text))
if not matches:
return text, []
# Extract URLs and remove image syntax from text
cleaned_text = text
for match in reversed(matches): # Reverse to maintain correct positions
url = match.group(2)
extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse
# Replace image syntax with empty string or a placeholder
cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :]
cleaned_text = cleaned_text[:match.start()] + cleaned_text[match.end():]
# Clean up multiple consecutive newlines that might result from removing images
cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text)
cleaned_text = cleaned_text.strip()
return cleaned_text, extracted_urls
for msg in message_chain:
@@ -192,14 +189,14 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
text = msg.text.encode('latin1').decode('utf-8')
except UnicodeError:
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
# Check for and extract Markdown images from text
cleaned_text, extracted_urls = await process_text_with_images(text)
# Add cleaned text if not empty
if cleaned_text:
pending_paragraph.append({'tag': 'md', 'text': cleaned_text})
# Process extracted image URLs
for url in extracted_urls:
# Create a temporary Image message to upload
@@ -207,7 +204,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client)
if image_key:
image_keys.append(image_key)
elif isinstance(msg, platform_message.At):
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
elif isinstance(msg, platform_message.AtAll):
@@ -244,7 +241,6 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time))
if message.message_type == 'text':
element_list = []
@@ -304,14 +300,6 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
message_content['content'] = new_list
elif message.message_type == 'image':
message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}]
elif message.message_type == 'file':
message_content['content'] = [
{'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']}
]
elif message.message_type == 'audio':
message_content['content'] = [
{'tag': 'audio', 'file_key': message_content['file_key'], "duration": message_content.get('duration',0)}
]
for ele in message_content['content']:
if ele['tag'] == 'text':
@@ -342,117 +330,6 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
image_format = response.raw.headers['content-type']
lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
elif ele['tag'] == 'audio':
file_key = ele['file_key']
duration = ele['duration']
# Download audio file
request: GetMessageResourceRequest = (
GetMessageResourceRequest.builder()
.message_id(message.message_id)
.file_key(file_key)
.type('file')
.build()
)
try:
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
if not response.success():
print(f'Failed to download audio: code: {response.code}, msg: {response.msg}')
lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]'))
return platform_message.MessageChain(lb_msg_list)
# Read audio bytes
audio_bytes = response.file.read()
audio_base64 = base64.b64encode(audio_bytes).decode()
# Get content type from response headers
content_type = response.raw.headers.get('content-type', 'audio/mpeg')
mime_main = content_type.split(';')[0].strip()
ext = mimetypes.guess_extension(mime_main) or '.bin'
temp_dir = tempfile.gettempdir()
temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}')
with open(temp_file_path, 'wb') as f:
f.write(audio_bytes)
# Create Voice message: prefer path/url + length, include base64 as optional data URI
lb_msg_list.append(
platform_message.Voice(
voice_id=file_key,
url=f'file://{temp_file_path}',
path=temp_file_path,
base64=f'data:{content_type};base64,{audio_base64}',
length=(duration // 1000) if duration else None,
)
)
except Exception as e:
print(f'Error downloading audio: {e}')
traceback.print_exc()
lb_msg_list.append(platform_message.Plain(text='[Audio file download error]'))
elif ele['tag'] == 'file':
file_key = ele['file_key']
file_name = ele['file_name']
request: GetMessageResourceRequest = (
GetMessageResourceRequest.builder()
.message_id(message.message_id)
.file_key(file_key)
.type('file')
.build()
)
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
if not response.success():
raise Exception(
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
file_bytes = response.file.read()
file_base64 = base64.b64encode(file_bytes).decode()
file_format = response.raw.headers['content-type']
file_size = len(file_bytes)
# Determine extension from content-type if possible
content_type = response.raw.headers.get('content-type', '')
mime_main = content_type.split(';')[0].strip() if content_type else ''
ext = mimetypes.guess_extension(mime_main) or ''
# Ensure a safe filename (avoid path components)
safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_')
if ext and not safe_name.lower().endswith(ext.lower()):
filename_with_ext = f'{safe_name}{ext}'
else:
filename_with_ext = safe_name
temp_dir = tempfile.gettempdir()
temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}')
with open(temp_file_path, 'wb') as f:
f.write(file_bytes)
# Create File message with local path and file:// URL
lb_msg_list.append(
platform_message.File(
id=file_key,
name=file_name,
size=file_size,
url=f'file://{temp_file_path}',
path=temp_file_path,
base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed
)
)
return platform_message.MessageChain(lb_msg_list)
@@ -479,7 +356,6 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
),
message_chain=message_chain,
time=event.event.message.create_time,
source_platform_object=event,
)
elif event.event.message.chat_type == 'group':
return platform_events.GroupMessage(
@@ -493,10 +369,12 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=event.event.message.create_time,
source_platform_object=event,
)
@@ -513,7 +391,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_converter: LarkMessageConverter = LarkMessageConverter()
event_converter: LarkEventConverter = LarkEventConverter()
cipher: AESCipher
listeners: typing.Dict[
typing.Type[platform_events.Event],
@@ -525,15 +402,51 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_id_dict: dict[str, str] # 消息id到卡片id的映射便于创建卡片后的发送消息到指定卡片
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
bot_uuid: str = None # 机器人UUID
app_ticket: str = None # 商店应用用到
app_access_token: str = None # 商店应用用到
app_access_token_expire_at: int = None
tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
quart_app = quart.Quart(__name__)
@quart_app.route('/lark/callback', methods=['POST'])
async def lark_callback():
try:
data = await quart.request.json
if 'encrypt' in data:
cipher = AESCipher(config['encrypt-key'])
data = cipher.decrypt_string(data['encrypt'])
data = json.loads(data)
type = data.get('type')
if type is None:
context = EventContext(data)
type = context.header.event_type
if 'url_verification' == type:
# todo 验证verification token
return {'challenge': data.get('challenge')}
context = EventContext(data)
type = context.header.event_type
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender'])
p2v1.event = event
p2v1.schema = context.schema
if 'im.message.receive_v1' == type:
try:
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
lb_event = await self.event_converter.target2yiri(event, self.api_client)
@@ -549,9 +462,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id = config['bot_name']
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
api_client = self.build_api_client(config)
cipher = AESCipher(config.get('encrypt-key', ''))
self.request_app_ticket(api_client, config)
api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build()
super().__init__(
config=config,
@@ -564,105 +475,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot=bot,
api_client=api_client,
bot_account_id=bot_account_id,
cipher=cipher,
**kwargs,
)
def request_app_ticket(self, api_client, config):
app_id = config['app_id']
app_secret = config['app_secret']
print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}')
if 'isv' == config.get('app_type', 'self'):
request: ResendAppTicketRequest = (
ResendAppTicketRequest.builder()
.request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build())
.build()
)
response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request)
if not response.success():
raise Exception(
f'client.auth.v3.auth.app_ticket_resend failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
def request_app_access_token(self):
app_id = self.config['app_id']
app_secret = self.config['app_secret']
if 'isv' == self.config.get('app_type', 'self'):
request: CreateAppAccessTokenRequest = (
CreateAppAccessTokenRequest.builder()
.request_body(
CreateAppAccessTokenRequestBody.builder()
.app_id(app_id)
.app_secret(app_secret)
.app_ticket(self.app_ticket)
.build()
)
.build()
)
response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request)
if not response.success():
raise Exception(
f'client.auth.v3.auth.app_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
content = json.loads(response.raw.content)
self.app_access_token = content['app_access_token']
self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300
def get_app_access_token(self):
if 'isv' != self.config.get('app_type', 'self'):
return None
if (
self.app_access_token is None
or self.app_access_token_expire_at is None
or int(time.time()) >= self.app_access_token_expire_at
):
self.request_app_access_token()
return self.app_access_token
def request_tenant_access_token(self, tenant_key: str):
app_access_token = self.get_app_access_token()
if 'isv' == self.config.get('app_type', 'self'):
request: CreateTenantAccessTokenRequest = (
CreateTenantAccessTokenRequest.builder()
.request_body(
CreateTenantAccessTokenRequestBody.builder()
.app_access_token(app_access_token)
.tenant_key(tenant_key)
.build()
)
.build()
)
response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request)
if not response.success():
raise Exception(
f'client.auth.v3.auth.tenant_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
content = json.loads(response.raw.content)
tenant_access_token = content['tenant_access_token']
expire = content['expire']
self.tenant_access_tokens[tenant_key] = {
'token': tenant_access_token,
'expire_at': int(time.time()) + expire - 300,
}
def get_tenant_access_token(self, tenant_key: str):
if tenant_key is None or 'isv' != self.config.get('app_type', 'self'):
return None
tenant_access_token = self.tenant_access_tokens.get(tenant_key)
if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']:
self.request_tenant_access_token(tenant_key)
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
def build_api_client(self, config):
app_id = config['app_id']
app_secret = config['app_secret']
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
if 'isv' == config.get('app_type', 'self'):
api_client = (
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
)
return api_client
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
@@ -890,19 +705,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
.build()
)
tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
# 发起请求
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
# 处理失败返回
if not response.success():
@@ -929,6 +734,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'content': text_elements,
},
}
request: ReplyMessageRequest = (
ReplyMessageRequest.builder()
.message_id(message_source.message_chain.message_id)
@@ -943,22 +749,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
.build()
)
tenant_key = (
message_source.source_platform_object.header.tenant_key
if message_source.source_platform_object
else None
)
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
if not response.success():
raise Exception(
@@ -983,22 +774,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
.build()
)
tenant_key = (
message_source.source_platform_object.header.tenant_key
if message_source.source_platform_object
else None
)
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
if not response.success():
raise Exception(
@@ -1052,24 +828,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if is_final and bot_message.tool_calls is None:
# self.seq = 1 # 消息回复结束之后重置seq
self.card_id_dict.pop(message_id) # 清理已经使用过的卡片
tenant_key = (
message_source.source_platform_object.header.tenant_key
if message_source.source_platform_object
else None
)
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
# 发起请求
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt)
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request)
# 处理失败返回
if not response.success():
@@ -1099,110 +859,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
self.listeners.pop(event_type)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
def get_event_type(self, data):
schema = '1.0'
if 'schema' in data:
schema = data['schema']
if '2.0' == schema:
return data['header']['event_type']
elif 'event' in data:
return data['event']['type']
else:
return data['type']
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
try:
data = await request.json
if 'encrypt' in data:
data = self.cipher.decrypt_string(data['encrypt'])
data = json.loads(data)
type = self.get_event_type(data)
context = EventContext(data)
if 'url_verification' == type:
# todo 验证verification token
return {'challenge': data.get('challenge')}
elif 'app_ticket' == type:
self.app_ticket = context.event['app_ticket']
elif 'im.message.receive_v1' == type:
try:
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender'])
p2v1.event = event
p2v1.schema = context.schema
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
elif 'im.chat.member.bot.added_v1' == type:
try:
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
if bot_added_welcome_msg:
final_content = {
'zh_Hans': {
'title': '',
'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]],
},
}
chat_id = context.event['chat_id']
request: CreateMessageRequest = (
CreateMessageRequest.builder()
.receive_id_type('chat_id')
.request_body(
CreateMessageRequestBody.builder()
.receive_id(chat_id)
.content(json.dumps(final_content))
.msg_type('post')
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
tenant_key = context.header.tenant_key if context.header else None
app_access_token = self.get_app_access_token()
tenant_access_token = self.get_tenant_access_token(tenant_key)
req_opt: RequestOption = (
RequestOption.builder()
.app_ticket(self.app_ticket)
.tenant_key(tenant_key)
.app_access_token(app_access_token)
.tenant_access_token(tenant_access_token)
.build()
)
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
if not response.success():
raise Exception(
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
except Exception as e:
print(f'im.chat.member.bot.added_v1: {e}')
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 200, 'message': 'ok'}
except Exception as e:
print(f'Error in lark callback: {e}')
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
async def run_async(self):
port = self.config['port']
enable_webhook = self.config['enable-webhook']
if not enable_webhook:
@@ -1217,14 +875,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
raise e
else:
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.quart_app.run_task(
host='0.0.0.0',
port=port,
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接

View File

@@ -45,6 +45,16 @@ spec:
type: boolean
required: true
default: false
- name: port
label:
en_US: Webhook Port
zh_Hans: Webhook端口
description:
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
zh_Hans: 仅在启用 Webhook 模式时有效,请填写 Webhook 端口
type: integer
required: true
default: 2285
- name: encrypt-key
label:
en_US: Encrypt Key
@@ -65,35 +75,6 @@ spec:
type: boolean
required: true
default: false
- name: app_type
label:
en_US: App Type
zh_Hans: 应用类型
description:
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
type: select
options:
- name: self
label:
en_US: Self-built Application
zh_Hans: 自建应用
- name: isv
label:
en_US: Store Application
zh_Hans: 商店应用
required: false
default: self
- name: bot_added_welcome
label:
en_US: Bot Welcome Message
zh_Hans: 机器人进群欢迎语
description:
en_US: Welcome message when the bot is added to a group, supports Markdown format
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
type: text
required: false
default: ""
execution:
python:
path: ./lark.py

View File

@@ -437,6 +437,9 @@ class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=event['Data']['CreateTime'],

View File

@@ -153,6 +153,9 @@ class NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConvert
permission=platform_entities.Permission.Member,
),
special_title=event.sender.title,
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=yiri_chain,
time=event.time,

View File

@@ -279,6 +279,11 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=int(
datetime.datetime.strptime(event.member.joined_at, '%Y-%m-%dT%H:%M:%S%z').timestamp()
),
last_speak_timestamp=datetime.datetime.now().timestamp(),
mute_time_remaining=0,
),
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
@@ -307,6 +312,9 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=int(0),
last_speak_timestamp=datetime.datetime.now().timestamp(),
mute_time_remaining=0,
),
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),

View File

@@ -108,6 +108,9 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=event.timestamp,
@@ -118,7 +121,6 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: MessagingApi
api_client: ApiClient
parser: WebhookParser
bot_account_id: str # 用于在流水线中识别at是否是本bot直接以bot_name作为标识
message_converter: LINEMessageConverter
@@ -130,7 +132,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
]
config: dict
bot_uuid: str = None
quart_app: quart.Quart
card_id_dict: dict[str, str] # 消息id到卡片id的映射便于创建卡片后的发送消息到指定卡片
@@ -147,6 +149,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
super().__init__(
config=config,
logger=logger,
quart_app=quart.Quart(__name__),
listeners={},
card_id_dict={},
seq=1,
@@ -160,6 +163,29 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id=bot_account_id,
)
@self.quart_app.route('/line/callback', methods=['POST'])
async def line_callback():
try:
signature = quart.request.headers.get('X-Line-Signature')
body = await quart.request.get_data(as_text=True)
events = parser.parse(body, signature) # 解密解析消息
try:
# print(events)
lb_event = await self.event_converter.target2yiri(events[0], self.api_client)
if lb_event.__class__ in self.listeners:
await self.listeners[lb_event.__class__](lb_event, self)
except InvalidSignatureError:
self.logger.info(
f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}'
)
return quart.Response('Invalid signature', status=400)
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
@@ -210,60 +236,18 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
self.listeners.pop(event_type)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
try:
signature = request.headers.get('X-Line-Signature')
body = await request.get_data(as_text=True)
# Check if signature header exists
if not signature:
await self.logger.warning('Missing X-Line-Signature header')
return quart.Response('Missing X-Line-Signature header', status=400)
try:
events = self.parser.parse(body, signature) # 解密解析消息
except InvalidSignatureError:
await self.logger.info(
f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}'
)
return quart.Response('Invalid signature', status=400)
# 处理事件
if events and len(events) > 0:
lb_event = await self.event_converter.target2yiri(events[0], self.api_client)
if lb_event.__class__ in self.listeners:
await self.listeners[lb_event.__class__](lb_event, self)
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}')
print(traceback.format_exc())
return {'code': 500, 'message': 'error'}
async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
port = self.config['port']
# 打印 webhook 回调地址
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.quart_app.run_task(
host='0.0.0.0',
port=port,
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
pass

View File

@@ -22,6 +22,18 @@ spec:
type: string
required: true
default: ""
- name: port
label:
en_US: Webhook Port
zh_Hans: Webhook端口
description:
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
zh_Hans: 请填写 Webhook 端口
ja_JP: Webhookポートを入力してください
zh_Hant: 請填寫 Webhook 端口
type: integer
required: true
default: 2287
- name: channel_secret
label:
en_US: Channel secret

View File

@@ -11,7 +11,7 @@ from langbot.libs.official_account_api.api import OAClientForLongerResponse
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from ..logger import EventLogger
from langbot.pkg.platform.logger import EventLogger
class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -58,16 +58,13 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
message_converter: OAMessageConverter = OAMessageConverter()
event_converter: OAEventConverter = OAEventConverter()
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger):
# 校验必填项
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
missing_keys = [k for k in required_keys if k not in config]
if missing_keys:
raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}')
# 创建运行时 bot 对象,始终使用统一 webhook 模式
if config['Mode'] == 'drop':
bot = OAClient(
token=config['token'],
@@ -75,7 +72,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
Appsecret=config['AppSecret'],
AppID=config['AppID'],
logger=logger,
unified_mode=True,
)
elif config['Mode'] == 'passive':
bot = OAClientForLongerResponse(
@@ -85,7 +81,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
AppID=config['AppID'],
LoadingMessage=config.get('LoadingMessage', ''),
logger=logger,
unified_mode=True,
)
else:
raise KeyError('请设置微信公众号通信模式')
@@ -134,32 +129,16 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
elif event_type == platform_events.GroupMessage:
pass
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.bot.run_task(
host=self.config['host'],
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False

View File

@@ -53,6 +53,23 @@ spec:
type: string
required: true
default: "AI正在思考中请发送任意内容获取回复。"
- name: host
label:
en_US: Host
zh_Hans: 监听主机
description:
en_US: The host that Official Account listens on for Webhook connections.
zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 0.0.0.0
type: string
required: true
default: 0.0.0.0
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2287
execution:
python:
path: ./officialaccount.py

View File

@@ -11,8 +11,8 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from langbot.libs.qq_official_api.api import QQOfficialClient
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image
from ..logger import EventLogger
from langbot.pkg.utils import image
from langbot.pkg.platform.logger import EventLogger
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -94,6 +94,9 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
)
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
return platform_events.GroupMessage(
@@ -114,6 +117,9 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
)
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
return platform_events.GroupMessage(
@@ -128,14 +134,11 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot: QQOfficialClient
config: dict
bot_account_id: str
bot_uuid: str = None
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, logger: EventLogger):
bot = QQOfficialClient(
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
)
bot = QQOfficialClient(app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger)
super().__init__(
config=config,
@@ -220,32 +223,16 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message)
self.bot.on_message('AT_MESSAGE_CREATE')(on_message)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.bot.run_task(
host='0.0.0.0',
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False

View File

@@ -25,6 +25,13 @@ spec:
type: string
required: true
default: ""
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2284
- name: token
label:
en_US: Token

View File

@@ -76,6 +76,9 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
id=event.channel_id, name='MEMBER', permission=platform_entities.Permission.Member
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
)
time = int(datetime.datetime.utcnow().timestamp())
return platform_events.GroupMessage(
@@ -94,12 +97,13 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: SlackClient
bot_account_id: str
bot_uuid: str = None
message_converter: SlackMessageConverter = SlackMessageConverter()
event_converter: SlackEventConverter = SlackEventConverter()
config: dict
def __init__(self, config: dict, logger: EventLogger):
self.config = config
self.logger = logger
required_keys = [
'bot_token',
'signing_secret',
@@ -108,15 +112,8 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if missing_keys:
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项请查看文档或联系管理员')
bot = SlackClient(
bot_token=config['bot_token'], signing_secret=config['signing_secret'], logger=logger, unified_mode=True
)
super().__init__(
config=config,
logger=logger,
bot=bot,
bot_account_id=config['bot_token'],
self.bot = SlackClient(
bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger
)
async def reply_message(
@@ -168,31 +165,16 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif event_type == platform_events.GroupMessage:
self.bot.on_message('channel')(on_message)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.bot.run_task(
host='0.0.0.0',
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False

View File

@@ -25,6 +25,13 @@ spec:
type: string
required: true
default: ""
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: int
required: true
default: 2288
execution:
python:
path: ./slack.py

View File

@@ -120,6 +120,9 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=lb_message,
time=event.message.date.timestamp(),

View File

@@ -65,10 +65,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
"""后端主动推送消息的队列"""
# 流式输出开关
stream_enabled: bool = pydantic.Field(default=True, exclude=True)
"""是否启用流式输出"""
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__(
config=config,
@@ -81,7 +77,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
self.bot_account_id = 'websocketbot'
self.outbound_message_queue = asyncio.Queue()
self.stream_enabled = True
async def send_message(
self,
@@ -102,6 +97,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
# 推送到所有相关连接
await self.outbound_message_queue.put(message_data)
await self.logger.info(f'Send message to {target_id}: {message}')
return message_data
async def reply_message(
@@ -120,7 +117,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
# 从message_source获取pipeline_uuid和connection_id
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
# session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
# 生成新的消息ID
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
@@ -137,15 +134,13 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
# 保存到历史记录
session.get_message_list(pipeline_uuid).append(message_data)
# 直接广播到所有该pipeline的连接包含session_type信息
# 直接广播到所有该pipeline的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'response',
'session_type': session_type,
'data': message_data.model_dump(),
},
session_type=session_type,
)
return message_data.model_dump()
@@ -167,7 +162,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
message_list = session.get_message_list(pipeline_uuid)
# 检查是否是新的流式消息通过bot_message对象判断
@@ -203,22 +197,20 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if is_final and bot_message.tool_calls is None:
message_list[-1] = message_data
# 直接广播到所有该pipeline的连接包含session_type信息
# 直接广播到所有该pipeline的连接
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'response',
'session_type': session_type,
'data': message_data.model_dump(),
},
session_type=session_type,
)
return message_data.model_dump()
async def is_stream_output_supported(self) -> bool:
"""根据stream_enabled标志返回是否支持流式输出"""
return self.stream_enabled
"""WebSocket始终支持流式输出"""
return True
def register_listener(
self,
@@ -245,6 +237,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def run_async(self):
"""运行适配器"""
await self.logger.info('WebSocket适配器已启动')
try:
while True:
@@ -260,11 +253,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
await asyncio.sleep(0.1)
except asyncio.CancelledError:
await self.logger.info('WebSocket适配器已停止')
raise
async def kill(self):
"""停止适配器"""
pass
await self.logger.info('WebSocket适配器正在停止')
async def _process_image_components(self, message_chain_obj: list):
"""
@@ -319,16 +313,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
Args:
connection: WebSocket连接对象
message_data: 消息数据,包含:
- message: 消息链
- stream: 是否启用流式输出 (可选默认True)
message_data: 消息数据
"""
pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type
# 获取stream参数默认为True
self.stream_enabled = message_data.get('stream', True)
# 选择会话
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
@@ -355,15 +344,13 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
use_session.get_message_list(pipeline_uuid).append(user_message)
# 广播用户消息到所有连接(包括发送者)包含session_type信息
# 广播用户消息到所有连接(包括发送者)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
{
'type': 'user_message',
'session_type': session_type,
'data': user_message.model_dump(),
},
session_type=session_type,
)
# 添加消息源

View File

@@ -134,20 +134,9 @@ class WebSocketConnectionManager:
connection_ids = self.session_connections.get(session_type, set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None):
"""向指定流水线的所有连接广播消息
Args:
pipeline_uuid: 流水线UUID
message: 要广播的消息
session_type: 可选的会话类型过滤器如果提供则只向匹配的session_type连接广播
"""
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict):
"""向指定流水线的所有连接广播消息"""
connections = await self.get_connections_by_pipeline(pipeline_uuid)
# 如果指定了session_type只向匹配的连接广播
if session_type is not None:
connections = [conn for conn in connections if conn.session_type == session_type]
tasks = []
for conn in connections:
tasks.append(self.send_to_connection(conn.connection_id, message))

View File

@@ -501,6 +501,9 @@ class WeChatPadEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=event['create_time'],

View File

@@ -8,8 +8,8 @@ import datetime
from langbot.libs.wecom_api.api import WecomClient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.libs.wecom_api.wecomevent import WecomEvent
from ...utils import image
from ..logger import EventLogger
from langbot.pkg.utils import image
from langbot.pkg.platform.logger import EventLogger
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
@@ -35,20 +35,6 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.Voice:
content_list.append(
{
'type': 'voice',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.File:
content_list.append(
{
'type': 'file',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.Forward:
for node in msg.node_list:
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
@@ -145,7 +131,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter()
config: dict
bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger):
# 校验必填项
@@ -156,12 +141,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'EncodingAESKey',
'contacts_secret',
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception(f'Wecom 缺少配置项: {missing_keys}')
# 创建运行时 bot 对象,始终使用统一 webhook 模式
# 创建运行时 bot 对象
bot = WecomClient(
corpid=config['corpid'],
secret=config['secret'],
@@ -169,7 +153,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'],
logger=logger,
unified_mode=True,
)
super().__init__(
@@ -179,10 +162,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id='',
)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -199,12 +178,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
elif content['type'] == 'image':
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'voice':
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'file':
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""企业微信目前只有发送给个人的方法,
构造target_id的方式为前半部分为账户id后半部分为agent_id,中间使用“|”符号隔开。
"""
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
parts = target_id.split('|')
user_id = parts[0]
@@ -215,10 +193,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media'])
if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media'])
if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media'])
def register_listener(
self,
@@ -240,25 +214,16 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif event_type == platform_events.GroupMessage:
pass
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.bot.run_task(
host=self.config['host'],
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False

View File

@@ -11,6 +11,23 @@ metadata:
icon: wecom.png
spec:
config:
- name: host
label:
en_US: Host
zh_Hans: 监听主机
description:
en_US: Webhook host, unless you know what you're doing, please write 0.0.0.0
zh_Hans: Webhook 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
type: string
required: true
default: "0.0.0.0"
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2290
- name: corpid
label:
en_US: Corpid
@@ -21,21 +38,21 @@ spec:
- name: secret
label:
en_US: Secret
zh_Hans: 密钥 (Secret)
zh_Hans: 密钥
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hans: 令牌
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hans: 消息加解密密钥
type: string
required: true
default: ""

View File

@@ -8,7 +8,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
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
from ..logger import EventLogger
from langbot.pkg.platform.logger import EventLogger
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
@@ -28,105 +28,9 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if event.type == 'group':
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
if event.content:
yiri_msg_list.append(platform_message.Plain(text=event.content))
images = []
if event.images:
images.extend([img for img in event.images if img])
if not images and event.picurl:
images.append(event.picurl)
for image_base64 in images:
if image_base64:
yiri_msg_list.append(platform_message.Image(base64=image_base64))
file_info = event.file or {}
if file_info:
file_url = (
file_info.get('download_url')
or file_info.get('url')
or file_info.get('fileurl')
or file_info.get('path')
)
file_base64 = file_info.get('base64')
file_name = file_info.get('filename') or file_info.get('name')
file_size = file_info.get('filesize') or file_info.get('size')
file_data = file_url or file_base64
if file_data or file_name:
file_kwargs = {}
if file_data:
file_kwargs['url'] = file_data
if file_name:
file_kwargs['name'] = file_name
if file_size is not None:
file_kwargs['size'] = file_size
try:
yiri_msg_list.append(platform_message.File(**file_kwargs))
except Exception:
# 兜底
yiri_msg_list.append(platform_message.Unknown(text='[file message unsupported]'))
voice_info = event.voice or {}
if voice_info:
voice_payload = voice_info.get('base64') or voice_info.get('url')
if voice_payload:
if voice_info.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
try:
voice_kwargs = {'url': voice_payload}
voice_name = voice_info.get('filename') or voice_info.get('name')
voice_size = voice_info.get('filesize') or voice_info.get('size')
if voice_name:
voice_kwargs['name'] = voice_name
if voice_size is not None:
voice_kwargs['size'] = voice_size
yiri_msg_list.append(platform_message.File(**voice_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[voice message unsupported]'))
video_info = event.video or {}
if video_info:
video_payload = (
video_info.get('base64')
or video_info.get('url')
or video_info.get('download_url')
or video_info.get('fileurl')
)
if video_payload:
video_kwargs = {'url': video_payload}
video_name = video_info.get('filename') or video_info.get('name')
video_size = video_info.get('filesize') or video_info.get('size')
if video_name:
video_kwargs['name'] = video_name
if video_size is not None:
video_kwargs['size'] = video_size
try:
# 没有专门的视频类型,沿用 File 传递给上层
yiri_msg_list.append(platform_message.File(**video_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[video message unsupported]'))
if event.msgtype == 'link' and event.link:
link = event.link
summary = '\n'.join(
filter(
None,
[link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')],
)
)
if summary:
yiri_msg_list.append(platform_message.Plain(text=summary))
has_content_element = any(
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
)
if not has_content_element:
fallback_type = event.msgtype or 'unknown'
yiri_msg_list.append(platform_message.Unknown(text=f'[unsupported wecom msgtype: {fallback_type}]'))
yiri_msg_list.append(platform_message.Plain(text=event.content))
if event.picurl != '':
yiri_msg_list.append(platform_message.Image(base64=event.picurl))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
@@ -163,6 +67,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
)
time = datetime.datetime.now().timestamp()
return platform_events.GroupMessage(
@@ -181,20 +88,19 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
event_converter: WecomBotEventConverter = WecomBotEventConverter()
config: dict
bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger):
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId']
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId', 'port']
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
# 创建运行时 bot 对象
bot = WecomBotClient(
Token=config['Token'],
EnCodingAESKey=config['EncodingAESKey'],
Corpid=config['Corpid'],
logger=logger,
unified_mode=True,
)
bot_account_id = config['BotId']
@@ -283,32 +189,16 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
except Exception:
print(traceback.format_exc())
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.bot.run_task(
host='0.0.0.0',
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False

View File

@@ -11,6 +11,13 @@ metadata:
icon: wecombot.png
spec:
config:
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2291
- name: Corpid
label:
en_US: Corpid
@@ -21,14 +28,14 @@ spec:
- name: Token
label:
en_US: Token
zh_Hans: 令牌 (Token)
zh_Hans: 令牌
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥 (EncodingAESKey)
zh_Hans: 消息加解密密钥
type: string
required: true
default: ""

View File

@@ -121,7 +121,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: WecomCSClient = pydantic.Field(exclude=True)
message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter()
bot_uuid: str = None
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = [
@@ -140,7 +139,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
logger=logger,
unified_mode=True,
)
super().__init__(
@@ -172,10 +170,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
@@ -196,28 +190,16 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif event_type == platform_events.GroupMessage:
pass
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def keep_alive():
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await keep_alive()
await self.bot.run_task(
host='0.0.0.0',
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False

View File

@@ -11,6 +11,13 @@ metadata:
icon: wecom.png
spec:
config:
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: int
required: true
default: 2289
- name: corpid
label:
en_US: Corpid

View File

@@ -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

View File

@@ -385,12 +385,6 @@ class PluginRuntimeConnector:
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
if not self.is_enable_plugin:
return {}
return await self.handler.get_debug_info()
async def emit_event(
self,
event: events.BaseEventModel,

View File

@@ -139,8 +139,6 @@ class RuntimeConnectionHandler(handler.Handler):
message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
self.ap.logger.debug(f'Reply message: {message_chain_obj.model_dump(serialize_as_any=False)}')
await query.adapter.reply_message(
query.message_event,
message_chain_obj,
@@ -565,7 +563,7 @@ class RuntimeConnectionHandler(handler.Handler):
'event_context': event_context,
'include_plugins': include_plugins,
},
timeout=180,
timeout=60,
)
return result
@@ -760,12 +758,3 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=30,
)
return result
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
)
return result

View File

@@ -1,8 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="5" fill="#1E3A5F"/>
<path d="M6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12" stroke="#4FC3F7" stroke-width="2" stroke-linecap="round"/>
<path d="M18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12" stroke="#81D4FA" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="2" fill="#4FC3F7"/>
<circle cx="6" cy="12" r="1.5" fill="#81D4FA"/>
<circle cx="18" cy="12" r="1.5" fill="#4FC3F7"/>
</svg>

Before

Width:  |  Height:  |  Size: 569 B

View File

@@ -1,59 +0,0 @@
from __future__ import annotations
import typing
from .. import requester
REQUESTER_NAME: str = 'seekdb-embedding'
class SeekDBEmbedding(requester.ProviderAPIRequester):
"""SeekDB built-in embedding requester.
Uses pyseekdb's local embedding function (all-MiniLM-L6-v2).
The base_url config is reserved for future remote embedding support.
"""
default_config: dict[str, typing.Any] = {
'base_url': '',
}
_embedding_function = None
async def initialize(self):
try:
import pyseekdb
except ImportError:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self._embedding_function = pyseekdb.get_default_embedding_function()
async def invoke_llm(
self,
query,
model: requester.RuntimeLLMModel,
messages: typing.List,
funcs: typing.List = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
):
raise NotImplementedError('SeekDB embedding does not support LLM inference')
async def invoke_embedding(
self,
model: requester.RuntimeEmbeddingModel,
input_text: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[typing.List[float]]:
"""Generate embeddings using SeekDB's built-in embedding function."""
try:
if self._embedding_function is None:
await self.initialize()
if self._embedding_function is None:
raise RuntimeError("SeekDB embedding function initialization failed")
return self._embedding_function(input_text)
except Exception as e:
from .. import errors
raise errors.RequesterError(f'SeekDB embedding failed: {str(e)}')

View File

@@ -1,21 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: seekdb-embedding
label:
en_US: SeekDB Embedding
zh_Hans: SeekDB 嵌入
description:
en_US: SeekDB Python library built-in embedding model (all-MiniLM-L6-v2), it will take time to download the model file for the first time
zh_Hans: 使用来自 SeekDB Python 库的内置嵌入模型 (all-MiniLM-L6-v2),首次使用时将会花费时间自动下载模型文件
ja_JP: SeekDB Python ライブラリの組み込み埋め込みモデル (all-MiniLM-L6-v2) を使用します。初回使用時にモデルファイルのダウンロードに時間がかかります。
icon: seekdb.svg
spec:
config: []
support_type:
- text-embedding
provider_category: builtin
execution:
python:
path: ./seekdbembed.py
attr: SeekDBEmbedding

View File

@@ -4,7 +4,6 @@ import typing
import json
import uuid
import base64
import mimetypes
from langbot.pkg.provider import runner
@@ -13,7 +12,6 @@ import langbot_plugin.api.entities.builtin.provider.message as provider_message
from langbot.pkg.utils import image
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from langbot.libs.dify_service_api.v1 import client, errors
import httpx
@runner.runner_class('dify-service-api')
@@ -72,43 +70,14 @@ class DifyServiceAPIRunner(runner.RequestRunner):
content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip()
return content, thinking_content
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]:
"""预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:
"""预处理用户消息,提取纯文本,并将图片上传到 Dify 服务
Returns:
tuple[str, list[dict]]: 纯文本和上传后的文件描述(包含 type 与 id
tuple[str, list[str]]: 纯文本和图片的 Dify 服务图片 ID
"""
plain_text = ''
upload_files: list[dict] = []
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str:
file_name = file_name or 'file'
content_type = content_type or 'application/octet-stream'
file = (file_name, file_bytes, content_type)
resp = await self.dify_client.upload_file(file, user_tag)
return resp['id']
async def download_file(file_url: str) -> tuple[bytes, str]:
"""Download file from url (supports data url)."""
async with httpx.AsyncClient() as client_session:
resp = await client_session.get(file_url)
resp.raise_for_status()
content_type = (
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
)
return resp.content, content_type
def _detect_file_type(content_type: str) -> str:
"""Map MIME to dify file type."""
if content_type and content_type.startswith('image/'):
return 'image'
if content_type and content_type.startswith('audio/'):
return 'audio'
if content_type and content_type.startswith('video/'):
return 'video'
return 'document'
file_ids = []
if isinstance(query.user_message.content, list):
for ce in query.user_message.content:
@@ -117,53 +86,46 @@ class DifyServiceAPIRunner(runner.RequestRunner):
elif ce.type == 'image_base64':
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
file_bytes = base64.b64decode(image_b64)
image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}')
upload_files.append({'type': 'image', 'id': image_id})
elif ce.type == 'file_url':
file_url = getattr(ce, 'file_url', None)
file_name = getattr(ce, 'file_name', None) or 'file'
try:
file_bytes, content_type = await download_file(file_url)
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
file_type = _detect_file_type(content_type)
upload_files.append({'type': file_type, 'id': file_id})
except Exception as e:
self.ap.logger.warning(f'dify file upload failed: {e}')
elif ce.type == 'file_base64':
file_name = getattr(ce, 'file_name', None) or 'file'
header, b64_data = ce.file_base64.split(',', 1)
content_type = 'application/octet-stream'
if ';' in header:
content_type = header.split(';')[0][5:] or content_type
file_bytes = base64.b64decode(b64_data)
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
file_type = _detect_file_type(content_type)
upload_files.append({'type': file_type, 'id': file_id})
file = ('img.png', file_bytes, f'image/{image_format}')
file_upload_resp = await self.dify_client.upload_file(
file,
f'{query.session.launcher_type.value}_{query.session.launcher_id}',
)
image_id = file_upload_resp['id']
file_ids.append(image_id)
# elif ce.type == "file_url":
# file_bytes = base64.b64decode(ce.file_url)
# file_upload_resp = await self.dify_client.upload_file(
# file_bytes,
# f'{query.session.launcher_type.value}_{query.session.launcher_id}',
# )
# file_id = file_upload_resp['id']
# file_ids.append(file_id)
elif isinstance(query.user_message.content, str):
plain_text = query.user_message.content
# plain_text = "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image." if file_ids and not plain_text else plain_text
# plain_text = "The user message type cannot be parsed." if not file_ids and not plain_text else plain_text
# plain_text = plain_text if plain_text else "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
# print(self.pipeline_config['ai'])
plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt']
return plain_text, upload_files
return plain_text, file_ids
async def _chat_messages(
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用聊天助手"""
cov_id = query.session.using_conversation.uuid or None
cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query)
plain_text, image_ids = await self._preprocess_user_message(query)
files = [
{
'type': f['type'],
'transfer_method': 'local_file',
'upload_file_id': f['id'],
'type': 'image',
'upload_file_id': image_id,
}
for f in upload_files
for image_id in image_ids
]
mode = 'basic' # 标记是基础编排还是工作流编排
@@ -218,18 +180,18 @@ class DifyServiceAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用聊天助手"""
cov_id = query.session.using_conversation.uuid or None
cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query)
plain_text, image_ids = await self._preprocess_user_message(query)
files = [
{
'type': f['type'],
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': f['id'],
'upload_file_id': image_id,
}
for f in upload_files
for image_id in image_ids
]
ignored_events = []
@@ -318,15 +280,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
query.variables['conversation_id'] = query.session.using_conversation.uuid
plain_text, upload_files = await self._preprocess_user_message(query)
plain_text, image_ids = await self._preprocess_user_message(query)
files = [
{
'type': f['type'],
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': f['id'],
'upload_file_id': image_id,
}
for f in upload_files
for image_id in image_ids
]
ignored_events = ['text_chunk', 'workflow_started']
@@ -336,6 +298,8 @@ class DifyServiceAPIRunner(runner.RequestRunner):
'langbot_session_id': query.variables['session_id'],
'langbot_conversation_id': query.variables['conversation_id'],
'langbot_msg_create_time': query.variables['msg_create_time'],
'langbot_sender_id': query.variables.get('sender_id', ''),
'langbot_sender_name': query.variables.get('sender_name', ''),
}
inputs.update(query.variables)
@@ -387,18 +351,18 @@ class DifyServiceAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""调用聊天助手"""
cov_id = query.session.using_conversation.uuid or None
cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query)
plain_text, image_ids = await self._preprocess_user_message(query)
files = [
{
'type': f['type'],
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': f['id'],
'upload_file_id': image_id,
}
for f in upload_files
for image_id in image_ids
]
basic_mode_pending_chunk = ''
@@ -471,18 +435,18 @@ class DifyServiceAPIRunner(runner.RequestRunner):
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""调用聊天助手"""
cov_id = query.session.using_conversation.uuid or None
cov_id = query.session.using_conversation.uuid or ''
query.variables['conversation_id'] = cov_id
plain_text, upload_files = await self._preprocess_user_message(query)
plain_text, image_ids = await self._preprocess_user_message(query)
files = [
{
'type': f['type'],
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': f['id'],
'upload_file_id': image_id,
}
for f in upload_files
for image_id in image_ids
]
ignored_events = []
@@ -596,15 +560,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
query.variables['conversation_id'] = query.session.using_conversation.uuid
plain_text, upload_files = await self._preprocess_user_message(query)
plain_text, image_ids = await self._preprocess_user_message(query)
files = [
{
'type': f['type'],
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': f['id'],
'upload_file_id': image_id,
}
for f in upload_files
for image_id in image_ids
]
ignored_events = ['workflow_started']
@@ -614,6 +578,8 @@ class DifyServiceAPIRunner(runner.RequestRunner):
'langbot_session_id': query.variables['session_id'],
'langbot_conversation_id': query.variables['conversation_id'],
'langbot_msg_create_time': query.variables['msg_create_time'],
'langbot_sender_id': query.variables.get('sender_id', ''),
'langbot_sender_name': query.variables.get('sender_name', ''),
}
inputs.update(query.variables)

View File

@@ -94,6 +94,7 @@ class LangflowAPIRunner(runner.RequestRunner):
if is_stream:
# 流式请求
async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response:
print(response)
response.raise_for_status()
accumulated_content = ''

View File

@@ -70,88 +70,30 @@ class N8nServiceAPIRunner(runner.RequestRunner):
async def _process_stream_response(self, response: aiohttp.ClientResponse) -> typing.AsyncGenerator[
provider_message.Message, None]:
"""处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况"""
"""处理流式响应"""
full_content = ""
chunk_idx = 0
is_final = False
message_idx = 0
buffer = ""
decoder = json.JSONDecoder()
async for raw_chunk in response.content.iter_chunked(1024):
if not raw_chunk:
is_final = False
async for chunk in response.content.iter_chunked(1024):
if not chunk:
continue
try:
# 将 bytes 解码为字符串(容忍错误)
if isinstance(raw_chunk, (bytes, bytearray)):
chunk_str = raw_chunk.decode('utf-8', errors='replace')
else:
chunk_str = str(raw_chunk)
buffer += chunk_str
# 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象)
while buffer:
buffer = buffer.lstrip()
if not buffer:
break
try:
obj, idx = decoder.raw_decode(buffer)
buffer = buffer[idx:]
if not isinstance(obj, dict):
# 忽略非字典类型的顶级 JSON
continue
if obj.get('type') == 'item' and 'content' in obj:
chunk_idx += 1
content = obj['content']
full_content += content
elif obj.get('type') == 'end':
is_final = True
if is_final or chunk_idx % 8 == 0:
message_idx += 1
yield provider_message.MessageChunk(
role='assistant',
content=full_content,
is_final=is_final,
msg_sequence=message_idx,
)
except json.JSONDecodeError:
# buffer 末尾可能是一个不完整的 JSON等待更多数据
break
except Exception as e:
# 记录解析失败并继续接收后续 chunk
try:
preview = chunk_str[:200]
except Exception:
preview = '<unavailable>'
self.ap.logger.warning(f"Failed to process chunk: {e}; chunk preview: {preview}")
# 流结束后,尝试解析残余 buffer
if buffer:
try:
buffer = buffer.strip()
if buffer:
obj, _ = decoder.raw_decode(buffer)
if isinstance(obj, dict):
if obj.get('type') == 'item' and 'content' in obj:
full_content += obj['content']
elif obj.get('type') == 'end':
is_final = True
data = json.loads(chunk)
if data.get('type') == 'item' and 'content' in data:
message_idx += 1
content = data['content']
full_content += content
elif data.get('type') == 'end':
is_final = True
if is_final or message_idx % 8 == 0:
yield provider_message.MessageChunk(
role='assistant',
content=full_content,
is_final=is_final,
msg_sequence=message_idx,
)
except Exception as e:
preview = buffer[:200]
self.ap.logger.warning(f"Failed to parse remaining buffer: {e}; buffer preview: {preview}")
except json.JSONDecodeError:
self.ap.logger.warning(f"Failed to parse final JSON line: {response.text()}")
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用n8n webhook"""

View File

@@ -33,7 +33,6 @@ class SessionManager:
session = provider_session.Session(
launcher_type=query.launcher_type,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
)
session._semaphore = asyncio.Semaphore(session_concurrency)
self.session_list.append(session)

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 13
required_database_version = 12
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

View File

@@ -153,9 +153,7 @@ async def get_qq_image_bytes(image_url: str, query: dict = {}) -> tuple[bytes, s
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession(trust_env=False) as session:
async with session.get(
image_url, params=query, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=30.0)
) as resp:
async with session.get(image_url, params=query, ssl=ssl_context) as resp:
resp.raise_for_status()
file_bytes = await resp.read()
content_type = resp.headers.get('Content-Type')

View File

@@ -4,9 +4,6 @@ from ..core import app
from .vdb import VectorDatabase
from .vdbs.chroma import ChromaVectorDatabase
from .vdbs.qdrant import QdrantVectorDatabase
from .vdbs.seekdb import SeekDBVectorDatabase
from .vdbs.milvus import MilvusVectorDatabase
from .vdbs.pgvector_db import PgVectorDatabase
class VectorDBManager:
@@ -19,50 +16,12 @@ class VectorDBManager:
async def initialize(self):
kb_config = self.ap.instance_config.data.get('vdb')
if kb_config:
vdb_type = kb_config.get('use')
if vdb_type == 'chroma':
if kb_config.get('use') == 'chroma':
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.info('Initialized Chroma vector database backend.')
elif vdb_type == 'qdrant':
elif kb_config.get('use') == 'qdrant':
self.vector_db = QdrantVectorDatabase(self.ap)
self.ap.logger.info('Initialized Qdrant vector database backend.')
elif vdb_type == 'seekdb':
self.vector_db = SeekDBVectorDatabase(self.ap)
self.ap.logger.info('Initialized SeekDB vector database backend.')
elif vdb_type == 'milvus':
# Get Milvus configuration
milvus_config = kb_config.get('milvus', {})
uri = milvus_config.get('uri', './data/milvus.db')
token = milvus_config.get('token')
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token)
self.ap.logger.info('Initialized Milvus vector database backend.')
elif vdb_type == 'pgvector':
# Get pgvector configuration
pgvector_config = kb_config.get('pgvector', {})
connection_string = pgvector_config.get('connection_string')
if connection_string:
self.vector_db = PgVectorDatabase(self.ap, connection_string=connection_string)
else:
# Use individual parameters
host = pgvector_config.get('host', 'localhost')
port = pgvector_config.get('port', 5432)
database = pgvector_config.get('database', 'langbot')
user = pgvector_config.get('user', 'postgres')
password = pgvector_config.get('password', 'postgres')
self.vector_db = PgVectorDatabase(
self.ap,
host=host,
port=port,
database=database,
user=user,
password=password
)
self.ap.logger.info('Initialized pgvector database backend.')
else:
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.')

View File

@@ -1,7 +0,0 @@
"""Vector database implementations for LangBot."""
from .chroma import ChromaVectorDatabase
from .qdrant import QdrantVectorDatabase
from .seekdb import SeekDBVectorDatabase
__all__ = ['ChromaVectorDatabase', 'QdrantVectorDatabase', 'SeekDBVectorDatabase']

View File

@@ -1,249 +0,0 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict
from pymilvus import MilvusClient, DataType
from langbot.pkg.vector.vdb import VectorDatabase
from langbot.pkg.core import app
class MilvusVectorDatabase(VectorDatabase):
"""Milvus vector database implementation"""
def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None):
"""Initialize Milvus vector database
Args:
ap: Application instance
uri: Milvus connection URI. For local file: "milvus.db"
For remote server: "http://localhost:19530"
token: Optional authentication token for remote connections
"""
self.ap = ap
self.uri = uri
self.token = token
self.client = None
self._collections = {}
self._initialize_client()
def _initialize_client(self):
"""Initialize Milvus client connection"""
try:
if self.token:
self.client = MilvusClient(uri=self.uri, token=self.token)
else:
self.client = MilvusClient(uri=self.uri)
self.ap.logger.info(f"Connected to Milvus at {self.uri}")
except Exception as e:
self.ap.logger.error(f"Failed to connect to Milvus: {e}")
raise
async def get_or_create_collection(self, collection: str):
"""Get or create a Milvus collection
Args:
collection: Collection name (corresponds to knowledge base UUID)
"""
if collection in self._collections:
return self._collections[collection]
# Check if collection exists
has_collection = await asyncio.to_thread(
self.client.has_collection, collection_name=collection
)
if not has_collection:
# Create collection with custom schema to support string IDs
from pymilvus import CollectionSchema, FieldSchema, DataType
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1536),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255),
FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields=fields, description="LangBot knowledge base vectors")
await asyncio.to_thread(
self.client.create_collection,
collection_name=collection,
schema=schema,
metric_type="COSINE",
)
# Create index for vector field (required for loading/searching)
index_params = {
"metric_type": "COSINE",
"index_type": "AUTOINDEX",
"params": {}
}
await asyncio.to_thread(
self.client.create_index,
collection_name=collection,
field_name="vector",
index_params=index_params
)
self.ap.logger.info(f"Created Milvus collection '{collection}' with index")
else:
self.ap.logger.info(f"Milvus collection '{collection}' already exists")
self._collections[collection] = collection
return collection
async def add_embeddings(
self,
collection: str,
ids: list[str],
embeddings_list: list[list[float]],
metadatas: list[dict[str, Any]],
) -> None:
"""Add vector embeddings to Milvus collection
Args:
collection: Collection name
ids: List of unique IDs for each vector
embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries for each vector
"""
await self.get_or_create_collection(collection)
# Prepare data in Milvus format
data = []
for i, vector_id in enumerate(ids):
entry = {
"id": vector_id,
"vector": embeddings_list[i],
}
# Add metadata fields
if metadatas and i < len(metadatas):
metadata = metadatas[i]
# Add common metadata fields
if "text" in metadata:
entry["text"] = metadata["text"]
if "file_id" in metadata:
entry["file_id"] = metadata["file_id"]
if "uuid" in metadata:
entry["chunk_uuid"] = metadata["uuid"]
data.append(entry)
# Insert data into Milvus
await asyncio.to_thread(
self.client.insert,
collection_name=collection,
data=data
)
# Load collection for searching (Milvus requires this)
await asyncio.to_thread(
self.client.load_collection,
collection_name=collection
)
self.ap.logger.info(f"Added {len(ids)} embeddings to Milvus collection '{collection}'")
async def search(
self, collection: str, query_embedding: list[float], k: int = 5
) -> Dict[str, Any]:
"""Search for similar vectors in Milvus collection
Args:
collection: Collection name
query_embedding: Query vector
k: Number of top results to return
Returns:
Dictionary with search results in Chroma-compatible format
"""
await self.get_or_create_collection(collection)
# Perform search
search_params = {
"metric_type": "COSINE",
"params": {}
}
results = await asyncio.to_thread(
self.client.search,
collection_name=collection,
data=[query_embedding],
limit=k,
search_params=search_params,
output_fields=["text", "file_id", "chunk_uuid"]
)
# Convert results to Chroma-compatible format
# Milvus returns: [[ {id, distance, entity: {...}} ]]
ids = []
distances = []
metadatas = []
if results and len(results) > 0:
for hit in results[0]:
ids.append(hit.get("id", ""))
distances.append(hit.get("distance", 0.0))
# Build metadata from entity fields
entity = hit.get("entity", {})
metadata = {}
if "text" in entity:
metadata["text"] = entity["text"]
if "file_id" in entity:
metadata["file_id"] = entity["file_id"]
if "chunk_uuid" in entity:
metadata["uuid"] = entity["chunk_uuid"]
metadatas.append(metadata)
# Return in Chroma-compatible format (nested lists)
result = {
"ids": [ids],
"distances": [distances],
"metadatas": [metadatas]
}
self.ap.logger.info(
f"Milvus search in '{collection}' returned {len(ids)} results"
)
return result
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
"""Delete vectors from collection by file_id
Args:
collection: Collection name
file_id: File ID to filter deletion
"""
await self.get_or_create_collection(collection)
# Delete entities matching the file_id
await asyncio.to_thread(
self.client.delete,
collection_name=collection,
filter=f'file_id == "{file_id}"'
)
self.ap.logger.info(
f"Deleted embeddings from Milvus collection '{collection}' with file_id: {file_id}"
)
async def delete_collection(self, collection: str):
"""Delete a Milvus collection
Args:
collection: Collection name to delete
"""
if collection in self._collections:
del self._collections[collection]
# Check if collection exists before attempting deletion
has_collection = await asyncio.to_thread(
self.client.has_collection, collection_name=collection
)
if has_collection:
await asyncio.to_thread(
self.client.drop_collection, collection_name=collection
)
self.ap.logger.info(f"Deleted Milvus collection '{collection}'")
else:
self.ap.logger.warning(f"Milvus collection '{collection}' not found")

View File

@@ -1,286 +0,0 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict
from sqlalchemy import create_engine, text, Column, String, Text
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from pgvector.sqlalchemy import Vector
from langbot.pkg.vector.vdb import VectorDatabase
from langbot.pkg.core import app
import uuid
Base = declarative_base()
class PgVectorEntry(Base):
"""SQLAlchemy model for pgvector entries"""
__tablename__ = 'langbot_vectors'
id = Column(String, primary_key=True)
collection = Column(String, index=True, nullable=False)
embedding = Column(Vector(1536)) # Default dimension, will be created dynamically
text = Column(Text)
file_id = Column(String, index=True)
chunk_uuid = Column(String)
class PgVectorDatabase(VectorDatabase):
"""PostgreSQL with pgvector extension database implementation"""
def __init__(
self,
ap: app.Application,
connection_string: str = None,
host: str = "localhost",
port: int = 5432,
database: str = "langbot",
user: str = "postgres",
password: str = "postgres"
):
"""Initialize pgvector database
Args:
ap: Application instance
connection_string: Full PostgreSQL connection string (overrides other params)
host: PostgreSQL host
port: PostgreSQL port
database: Database name
user: Database user
password: Database password
"""
self.ap = ap
# Build connection string if not provided
if connection_string:
self.connection_string = connection_string
else:
self.connection_string = (
f"postgresql+psycopg://{user}:{password}@{host}:{port}/{database}"
)
self.async_connection_string = self.connection_string.replace(
"postgresql://", "postgresql+asyncpg://"
).replace(
"postgresql+psycopg://", "postgresql+asyncpg://"
)
self.engine = None
self.async_engine = None
self.SessionLocal = None
self.AsyncSessionLocal = None
self._collections = set()
self._initialize_db()
def _initialize_db(self):
"""Initialize database connection and create tables"""
try:
# Create async engine for async operations
self.async_engine = create_async_engine(
self.async_connection_string,
echo=False,
pool_pre_ping=True
)
self.AsyncSessionLocal = async_sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False
)
# Create sync engine for table creation
sync_connection_string = self.connection_string.replace(
"postgresql+asyncpg://", "postgresql+psycopg://"
)
self.engine = create_engine(sync_connection_string, echo=False)
# Create pgvector extension and tables
with self.engine.connect() as conn:
# Enable pgvector extension
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.commit()
# Create tables
Base.metadata.create_all(self.engine)
self.ap.logger.info(f"Connected to PostgreSQL with pgvector")
except Exception as e:
self.ap.logger.error(f"Failed to connect to PostgreSQL: {e}")
raise
async def get_or_create_collection(self, collection: str):
"""Get or create a collection (logical grouping in pgvector)
Args:
collection: Collection name (knowledge base UUID)
"""
# In pgvector, collections are logical - we just track them
if collection not in self._collections:
self._collections.add(collection)
self.ap.logger.info(f"Registered pgvector collection '{collection}'")
return collection
async def add_embeddings(
self,
collection: str,
ids: list[str],
embeddings_list: list[list[float]],
metadatas: list[dict[str, Any]],
) -> None:
"""Add vector embeddings to pgvector
Args:
collection: Collection name
ids: List of unique IDs for each vector
embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries
"""
await self.get_or_create_collection(collection)
async with self.AsyncSessionLocal() as session:
try:
for i, vector_id in enumerate(ids):
metadata = metadatas[i] if i < len(metadatas) else {}
entry = PgVectorEntry(
id=vector_id,
collection=collection,
embedding=embeddings_list[i],
text=metadata.get("text", ""),
file_id=metadata.get("file_id", ""),
chunk_uuid=metadata.get("uuid", "")
)
session.add(entry)
await session.commit()
self.ap.logger.info(
f"Added {len(ids)} embeddings to pgvector collection '{collection}'"
)
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error adding embeddings to pgvector: {e}")
raise
async def search(
self, collection: str, query_embedding: list[float], k: int = 5
) -> Dict[str, Any]:
"""Search for similar vectors using cosine distance
Args:
collection: Collection name
query_embedding: Query vector
k: Number of top results to return
Returns:
Dictionary with search results in Chroma-compatible format
"""
await self.get_or_create_collection(collection)
async with self.AsyncSessionLocal() as session:
try:
# Use cosine distance for similarity search
from sqlalchemy import select, func
# Query for similar vectors
stmt = (
select(
PgVectorEntry.id,
PgVectorEntry.text,
PgVectorEntry.file_id,
PgVectorEntry.chunk_uuid,
PgVectorEntry.embedding.cosine_distance(query_embedding).label('distance')
)
.filter(PgVectorEntry.collection == collection)
.order_by(PgVectorEntry.embedding.cosine_distance(query_embedding))
.limit(k)
)
result = await session.execute(stmt)
rows = result.fetchall()
# Convert to Chroma-compatible format
ids = []
distances = []
metadatas = []
for row in rows:
ids.append(row.id)
distances.append(float(row.distance))
metadatas.append({
"text": row.text or "",
"file_id": row.file_id or "",
"uuid": row.chunk_uuid or ""
})
result_dict = {
"ids": [ids],
"distances": [distances],
"metadatas": [metadatas]
}
self.ap.logger.info(
f"pgvector search in '{collection}' returned {len(ids)} results"
)
return result_dict
except Exception as e:
self.ap.logger.error(f"Error searching pgvector: {e}")
raise
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
"""Delete vectors by file_id
Args:
collection: Collection name
file_id: File ID to filter deletion
"""
await self.get_or_create_collection(collection)
async with self.AsyncSessionLocal() as session:
try:
from sqlalchemy import delete
stmt = delete(PgVectorEntry).where(
PgVectorEntry.collection == collection,
PgVectorEntry.file_id == file_id
)
await session.execute(stmt)
await session.commit()
self.ap.logger.info(
f"Deleted embeddings from pgvector collection '{collection}' with file_id: {file_id}"
)
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error deleting from pgvector: {e}")
raise
async def delete_collection(self, collection: str):
"""Delete all vectors in a collection
Args:
collection: Collection name to delete
"""
if collection in self._collections:
self._collections.remove(collection)
async with self.AsyncSessionLocal() as session:
try:
from sqlalchemy import delete
stmt = delete(PgVectorEntry).where(
PgVectorEntry.collection == collection
)
await session.execute(stmt)
await session.commit()
self.ap.logger.info(f"Deleted pgvector collection '{collection}'")
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error deleting pgvector collection: {e}")
raise
async def close(self):
"""Close database connections"""
if self.async_engine:
await self.async_engine.dispose()
if self.engine:
self.engine.dispose()

View File

@@ -1,252 +0,0 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict, List
import sqlalchemy
from langbot.pkg.core import app
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.vector.vdb import VectorDatabase
try:
import pyseekdb
from pyseekdb import HNSWConfiguration
SEEKDB_AVAILABLE = True
except ImportError:
SEEKDB_AVAILABLE = False
SEEKDB_EMBEDDING_MODEL_UUID = 'seekdb-builtin-embedding'
SEEKDB_EMBEDDING_REQUESTER = 'seekdb-embedding'
class SeekDBVectorDatabase(VectorDatabase):
"""SeekDB vector database adapter for LangBot.
SeekDB is an AI-native search database by OceanBase that unifies
relational, vector, text, JSON and GIS in a single engine.
Supports both embedded mode and remote server mode.
"""
def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb']
# Determine connection mode based on config
mode = config.get('mode', 'embedded') # 'embedded' or 'server'
if mode == 'embedded':
# Embedded mode: local database
path = config.get('path', './data/seekdb')
database = config.get('database', 'langbot')
# Use AdminClient for database management operations
admin_client = pyseekdb.AdminClient(path=path)
# Check if database exists using public API
existing_dbs = [db.name for db in admin_client.list_databases()]
if database not in existing_dbs:
# Use public API to create database
admin_client.create_database(database)
self.ap.logger.info(f"Created SeekDB database '{database}'")
self.client = pyseekdb.Client(path=path, database=database)
self.ap.logger.info(f"Initialized SeekDB in embedded mode at '{path}', database '{database}'")
elif mode == 'server':
# Server mode: remote SeekDB or OceanBase server
host = config.get('host', 'localhost')
port = config.get('port', 2881)
database = config.get('database', 'langbot')
user = config.get('user', 'root')
password = config.get('password', '')
tenant = config.get('tenant', None) # Optional, for OceanBase
connection_params = {
'host': host,
'port': int(port),
'database': database,
'user': user,
'password': password,
}
if tenant:
connection_params['tenant'] = tenant
self.client = pyseekdb.Client(**connection_params)
self.ap.logger.info(
f"Initialized SeekDB in server mode: {host}:{port}, database '{database}'"
+ (f", tenant '{tenant}'" if tenant else '')
)
else:
raise ValueError(f"Invalid SeekDB mode: {mode}. Must be 'embedded' or 'server'")
self._collections: Dict[str, Any] = {}
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._escape_table = str.maketrans({
'\x00': '',
'\\': '\\\\',
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
})
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
"""Internal method to get or create a collection with proper configuration."""
if collection in self._collections:
return self._collections[collection]
# Check if collection exists
if await asyncio.to_thread(self.client.has_collection, collection):
# Collection exists, get it
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
self._collections[collection] = coll
self.ap.logger.info(f"SeekDB collection '{collection}' retrieved.")
return coll
# Collection doesn't exist, create it
if vector_size is None:
# Default dimension if not specified
vector_size = 384
# Create HNSW configuration
config = HNSWConfiguration(dimension=vector_size, distance='cosine')
self._collection_configs[collection] = config
# Create collection without embedding function (we manage embeddings externally)
coll = await asyncio.to_thread(
self.client.create_collection,
name=collection,
configuration=config,
embedding_function=None, # Disable automatic embedding
)
self._collections[collection] = coll
self.ap.logger.info(f"SeekDB collection '{collection}' created with dimension={vector_size}, distance='cosine'")
return coll
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table) if isinstance(v, str)
else v if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
}
async def get_or_create_collection(self, collection: str):
"""Get or create collection (without vector size - will use default)."""
return await self._get_or_create_collection_internal(collection)
async def add_embeddings(
self,
collection: str,
ids: List[str],
embeddings_list: List[List[float]],
metadatas: List[Dict[str, Any]]
) -> None:
"""Add vector embeddings to the specified collection.
Args:
collection: Collection name
ids: List of document IDs
embeddings_list: List of embedding vectors
metadatas: List of metadata dictionaries
"""
if not embeddings_list:
return
# Ensure collection exists with correct dimension
vector_size = len(embeddings_list[0])
coll = await self._get_or_create_collection_internal(collection, vector_size)
cleaned_metadatas = [self._clean_metadata(meta) for meta in metadatas]
await asyncio.to_thread(coll.add, ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
async def search(self, collection: str, query_embedding: List[float], k: int = 5) -> Dict[str, Any]:
"""Search for the most similar vectors in the specified collection.
Args:
collection: Collection name
query_embedding: Query vector
k: Number of results to return
Returns:
Dictionary with 'ids', 'metadatas', 'distances' keys
"""
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
self._collections[collection] = coll
else:
coll = self._collections[collection]
# Perform query
# SeekDB's query() returns: {'ids': [[...]], 'metadatas': [[...]], 'distances': [[...]]}
results = await asyncio.to_thread(coll.query, query_embeddings=query_embedding, n_results=k)
self.ap.logger.info(f"SeekDB search in '{collection}' returned {len(results.get('ids', [[]])[0])} results")
return results
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
"""Delete vectors from the collection by file_id metadata.
Args:
collection: Collection name
file_id: File ID to delete
"""
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
return
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
self._collections[collection] = coll
else:
coll = self._collections[collection]
# SeekDB's delete() expects a where clause for filtering
# Delete all records where metadata['file_id'] == file_id
await asyncio.to_thread(coll.delete, where={'file_id': file_id})
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' with file_id: {file_id}")
async def delete_collection(self, collection: str):
"""Delete the entire collection.
Args:
collection: Collection name
"""
# Remove from cache
if collection in self._collections:
del self._collections[collection]
if collection in self._collection_configs:
del self._collection_configs[collection]
# Check if collection exists
exists = await asyncio.to_thread(self.client.has_collection, collection)
if not exists:
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
return
# Delete collection
await asyncio.to_thread(self.client.delete_collection, collection)
self.ap.logger.info(f"SeekDB collection '{collection}' deleted")

Some files were not shown because too many files have changed in this diff Show More