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
73 changed files with 635 additions and 2515 deletions

View File

@@ -84,7 +84,7 @@ docker compose up -d
## ✨ 特性
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
@@ -108,7 +108,6 @@ docker compose up -d
| 微信公众号 | ✅ | |
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
| KOOK | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |

View File

@@ -80,7 +80,7 @@ Click the Star and Watch button in the upper right corner of the repository to g
## ✨ Features
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
- 🧩 Plugin Extension, Active Community: High stability, high security production-level plugin system; Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
@@ -107,7 +107,6 @@ Or visit the demo environment: https://demo.langbot.dev/
| Personal WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -80,7 +80,7 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
## ✨ Características
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, etc.
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios.
- 🧩 Extensión de Plugin, Comunidad Activa: Sistema de plugin de alta estabilidad, alta seguridad de nivel de producción; Compatible con mecanismos de plugin impulsados por eventos, extensión de componentes, etc.; Integración del protocolo [MCP](https://modelcontextprotocol.io/) de Anthropic; Actualmente cuenta con cientos de plugins.
- 😻 Interfaz Web: Admite la gestión de instancias de LangBot a través del navegador. No es necesario escribir archivos de configuración manualmente.
@@ -107,7 +107,6 @@ O visite el entorno de demostración: https://demo.langbot.dev/
| WeChat Personal | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -80,7 +80,7 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
## ✨ Fonctionnalités
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, etc.
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios.
- 🧩 Extension de Plugin, Communauté Active : Système de plugin de haute stabilité, haute sécurité de niveau production; Prend en charge les mécanismes de plugin pilotés par événements, l'extension de composants, etc. ; Intégration du protocole [MCP](https://modelcontextprotocol.io/) d'Anthropic ; Dispose actuellement de centaines de plugins.
- 😻 Interface Web : Prend en charge la gestion des instances LangBot via le navigateur. Pas besoin d'écrire manuellement les fichiers de configuration.
@@ -107,7 +107,6 @@ Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
| WeChat Personnel | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -80,7 +80,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
## ✨ 機能
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
@@ -107,7 +107,6 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| 個人WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -80,7 +80,7 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
## ✨ 기능
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다.
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram 등을 지원합니다.
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.
- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
@@ -105,7 +105,6 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| 개인 WeChat | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -80,7 +80,7 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
## ✨ Функции
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다.
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д.
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram и т.д.
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.
- 🧩 Расширение плагинов, активное сообщество: Высокая стабильность, высокая безопасность уровня производства; Поддержка механизмов плагинов, управляемых событиями, расширения компонентов и т.д.; Интеграция протокола [MCP](https://modelcontextprotocol.io/) от Anthropic; В настоящее время сотни плагинов.
- 😻 Веб-интерфейс: Поддержка управления экземплярами LangBot через браузер. Нет необходимости вручную писать конфигурационные файлы.
@@ -105,7 +105,6 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| Личный WeChat | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -80,7 +80,7 @@ docker compose up -d
## ✨ 特性
- 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
@@ -105,7 +105,6 @@ docker compose up -d
| 企微對外客服 | ✅ | |
| 企微智能機器人 | ✅ | |
| 微信公眾號 | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -80,7 +80,7 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t
## ✨ Tính năng
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms.
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v.
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, v.v.
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau.
- 🧩 Mở rộng Plugin, Cộng đồng Hoạt động: Hỗ trợ các cơ chế plugin hướng sự kiện, mở rộng thành phần, v.v.; Tích hợp giao thức [MCP](https://modelcontextprotocol.io/) của Anthropic; Hiện có hàng trăng plugin.
- 😻 Giao diện Web: Hỗ trợ quản lý các phiên bản LangBot thông qua trình duyệt. Không cần viết tệp cấu hình thủ công.
@@ -105,7 +105,6 @@ Hoặc truy cập môi trường demo: https://demo.langbot.dev/
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| WeChat Cá nhân | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -30,8 +30,8 @@ services:
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,6 +1,6 @@
[project]
name = "langbot"
version = "4.6.2"
version = "4.5.4"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.2.0",
"langbot-plugin==0.2.0b1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

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

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,34 +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'{req.method} {req.url} {str(req.args)}')
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)
@@ -408,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('请求参数缺失')
@@ -427,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' 字段")

View File

@@ -29,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:
@@ -65,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': [],
}
@@ -167,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('消息解密失败')

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

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

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

@@ -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']:
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

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

@@ -6,6 +6,7 @@ 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.platform.events as platform_events
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -74,12 +75,19 @@ 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.FriendMessage, platform_events.GroupMessage)):
sender_name = query.message_event.sender.get_name()
variables = {
'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())
),
'sender_id': str(query.sender_id),
'sender_name': sender_name,
}
query.variables.update(variables)

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,682 +0,0 @@
from __future__ import annotations
import typing
import asyncio
import json
import base64
import zlib
import traceback
import time
import aiohttp
import websockets
import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Convert between LangBot MessageChain and KOOK message format"""
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:
"""
Convert LangBot MessageChain to KOOK message format
Returns:
tuple: (content, message_type)
- content: message content string
- message_type: 1=text, 2=image, 4=file, 9=KMarkdown
"""
content_parts = []
message_type = 1 # Default to text
for component in message_chain:
if isinstance(component, platform_message.Plain):
content_parts.append(component.text)
elif isinstance(component, platform_message.At):
# KOOK mention format: (met)user_id(met)
if component.target:
content_parts.append(f'(met){component.target}(met)')
elif isinstance(component, platform_message.AtAll):
# KOOK @all format: (met)all(met)
content_parts.append('(met)all(met)')
elif isinstance(component, platform_message.Image):
# For images, we need to upload first via KOOK's asset API
# For now, we'll send the image URL if available
if component.url:
content_parts.append(component.url)
message_type = 2 # Image message type
elif isinstance(component, platform_message.Forward):
# Handle forward messages by concatenating content
for node in component.node_list:
forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)
content_parts.append(forward_content)
# Ignore Source and other components
content = ''.join(content_parts)
return content, message_type
@staticmethod
async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:
"""
Convert KOOK message format to LangBot MessageChain
Args:
kook_message: KOOK message event data dict
bot_account_id: Bot's account ID for handling role mentions
"""
components = []
msg_type = kook_message.get('type', 1)
content = kook_message.get('content', '')
extra = kook_message.get('extra', {})
# Handle mentions
mentions = extra.get('mention', [])
mention_all = extra.get('mention_all', False)
mention_roles = extra.get('mention_roles', [])
if mention_all:
components.append(platform_message.AtAll())
for mention_id in mentions:
components.append(platform_message.At(target=str(mention_id)))
# Handle role mentions (when bot is mentioned via role)
# In KOOK, when a role that the bot has is mentioned, we receive it as a role mention
# We need to convert this to an At with the bot's account ID for the pipeline to recognize it
if mention_roles and bot_account_id:
# Add an At component with the bot's account ID when any role is mentioned
# This is because KOOK bots are often assigned roles and @role mentions should trigger responses
components.append(platform_message.At(target=bot_account_id))
# Strip mention patterns from content
# Remove user mention patterns: (met)USER_ID(met)
for mention_id in mentions:
content = content.replace(f'(met){mention_id}(met)', '')
# Remove @all pattern
if mention_all:
content = content.replace('(met)all(met)', '')
# Remove role mention patterns: (rol)ROLE_ID(rol)
for role_id in mention_roles:
content = content.replace(f'(rol){role_id}(rol)', '')
# Clean up extra whitespace
content = content.strip()
# Handle different message types
if msg_type == 1: # Text message
if content:
components.append(platform_message.Plain(text=content))
elif msg_type == 2: # Image message
# Image content is typically a URL
if content:
# Download image and convert to base64
try:
async with aiohttp.ClientSession() as session:
async with session.get(content) as response:
if response.status == 200:
image_bytes = await response.read()
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
# Detect image format
content_type = response.headers.get('Content-Type', 'image/png')
components.append(
platform_message.Image(base64=f'data:{content_type};base64,{image_base64}')
)
except Exception:
# If download fails, just add as plain text
components.append(platform_message.Plain(text=f'[Image: {content}]'))
elif msg_type == 4: # File message
# For file messages, content is typically the file URL
attachments = extra.get('attachments', {})
file_name = attachments.get('name', 'file')
components.append(platform_message.Plain(text=f'[File: {file_name}]'))
elif msg_type == 9: # KMarkdown message
# Note: content is already stripped of mention patterns above
if content:
components.append(platform_message.Plain(text=content))
elif msg_type == 10: # Card message
# Card messages are complex, for now just indicate it's a card
components.append(platform_message.Plain(text='[Card Message]'))
else:
# Other message types, just use content as plain text
if content:
components.append(platform_message.Plain(text=content))
return platform_message.MessageChain(components)
class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Convert between LangBot events and KOOK events"""
@staticmethod
async def yiri2target(event: platform_events.MessageEvent):
"""Convert LangBot event to KOOK event (not implemented)"""
pass
@staticmethod
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageEvent:
"""
Convert KOOK event to LangBot MessageEvent
Args:
kook_event: KOOK event data dict containing channel_type, type, etc.
bot_account_id: Bot's account ID for handling role mentions
Returns:
FriendMessage or GroupMessage depending on channel_type
"""
channel_type = kook_event.get('channel_type')
author_id = kook_event.get('author_id')
target_id = kook_event.get('target_id')
msg_timestamp = kook_event.get('msg_timestamp', int(time.time() * 1000))
extra = kook_event.get('extra', {})
# Convert message to MessageChain
message_chain = await KookMessageConverter.target2yiri(kook_event, bot_account_id)
# Convert timestamp from milliseconds to seconds
event_time = msg_timestamp / 1000.0
if channel_type == 'PERSON':
# Direct/Private message
author = extra.get('author', {})
author_name = author.get('nickname', author.get('username', str(author_id)))
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=str(author_id),
nickname=author_name,
remark=str(author_id),
),
message_chain=message_chain,
time=event_time,
source_platform_object=kook_event,
)
elif channel_type == 'GROUP':
# Guild/Server channel message
author = extra.get('author', {})
author_name = author.get('nickname', author.get('username', str(author_id)))
# guild_id = extra.get('guild_id', '')
channel_name = extra.get('channel_name', str(target_id))
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=str(author_id),
member_name=author_name,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=str(target_id), # Channel ID
name=channel_name,
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=event_time,
source_platform_object=kook_event,
)
else:
# Fallback to FriendMessage for unknown channel types
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=str(author_id),
nickname=str(author_id),
remark=str(author_id),
),
message_chain=message_chain,
time=event_time,
source_platform_object=kook_event,
)
class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""KOOK platform adapter for LangBot"""
config: dict
message_converter: KookMessageConverter = KookMessageConverter()
event_converter: KookEventConverter = KookEventConverter()
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
# WebSocket connection
ws: typing.Optional[websockets.WebSocketClientProtocol] = pydantic.Field(exclude=True, default=None)
ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
running: bool = pydantic.Field(exclude=True, default=False)
# Connection state
session_id: str = pydantic.Field(exclude=True, default='')
current_sn: int = pydantic.Field(exclude=True, default=0)
gateway_url: str = pydantic.Field(exclude=True, default='')
# HTTP session
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
# Debug: Track init
with open('/tmp/kook_adapter_init.txt', 'w') as f:
f.write(f'KOOK adapter __init__ called at {time.time()}\n')
# Validate required config
if 'token' not in config:
raise Exception('KOOK adapter requires "token" in config')
super().__init__(
config=config,
logger=logger,
bot_account_id='', # Will be set after connection
listeners={},
**kwargs,
)
async def _get_gateway_url(self) -> str:
"""Get WebSocket gateway URL from KOOK API"""
base_url = 'https://www.kookapp.cn/api/v3/gateway/index'
# Always use compression for better performance
params = {'compress': 1}
headers = {
'Authorization': f'Bot {self.config["token"]}',
}
async with aiohttp.ClientSession() as session:
async with session.get(base_url, params=params, headers=headers) as response:
if response.status == 200:
data = await response.json()
if data.get('code') == 0:
gateway_url = data['data']['url']
return gateway_url
else:
raise Exception(f'Failed to get gateway URL: {data.get("message")}')
else:
raise Exception(f'Failed to get gateway URL: HTTP {response.status}')
async def _get_bot_user_info(self) -> dict:
"""Get bot's own user information from KOOK API"""
base_url = 'https://www.kookapp.cn/api/v3/user/me'
headers = {
'Authorization': f'Bot {self.config["token"]}',
}
async with aiohttp.ClientSession() as session:
async with session.get(base_url, headers=headers) as response:
if response.status == 200:
data = await response.json()
if data.get('code') == 0:
user_info = data['data']
await self.logger.info(
f'Retrieved bot user info: {user_info.get("username")} (ID: {user_info.get("id")})'
)
return user_info
else:
raise Exception(f'Failed to get bot user info: {data.get("message")}')
else:
raise Exception(f'Failed to get bot user info: HTTP {response.status}')
async def _handle_hello(self, data: dict):
"""Handle HELLO signal (signal 1)"""
session_id = data.get('session_id', '')
self.session_id = session_id
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {session_id}')
async def _handle_event(self, data: dict, sn: int):
"""Handle EVENT signal (signal 0)"""
self.current_sn = max(self.current_sn, sn)
# Check if this is a message event
event_type = data.get('type')
channel_type = data.get('channel_type')
author_id = data.get('author_id')
# Ignore messages from bot itself to prevent infinite loops
if self.bot_account_id and str(author_id) == self.bot_account_id:
await self.logger.debug(f'Ignoring message from bot itself (author_id: {author_id})')
return
# Only process text messages (type 1, 2, 4, 9, 10) in GROUP or PERSON channels
if event_type in [1, 2, 4, 9, 10] and channel_type in ['GROUP', 'PERSON']:
try:
# Convert to LangBot event
lb_event = await self.event_converter.target2yiri(data, self.bot_account_id)
# Call registered listener
event_class = type(lb_event)
if event_class in self.listeners:
await self.listeners[event_class](lb_event, self)
except Exception as e:
await self.logger.error(f'Error handling KOOK event: {e}\n{traceback.format_exc()}')
async def _handle_pong(self, data: dict):
"""Handle PONG signal (signal 3)"""
# PONG received, connection is healthy
pass
async def _heartbeat_loop(self):
"""Send PING every 30 seconds"""
try:
while self.running and self.ws:
await asyncio.sleep(30)
if self.ws:
try:
ping_msg = {
's': 2, # PING signal
'sn': self.current_sn,
}
await self.ws.send(json.dumps(ping_msg))
await self.logger.debug(f'Sent PING with sn={self.current_sn}')
except Exception:
# Connection closed or send failed, exit loop
break
except asyncio.CancelledError:
pass
except Exception as e:
await self.logger.error(f'Heartbeat error: {e}')
async def _websocket_loop(self):
"""Main WebSocket event loop"""
retry_count = 0
max_retries = 3
while self.running and retry_count < max_retries:
try:
# Get gateway URL if not already retrieved
if not self.gateway_url:
self.gateway_url = await self._get_gateway_url()
# Connect to WebSocket
await self.logger.info(f'Connecting to KOOK WebSocket: {self.gateway_url}')
async with websockets.connect(self.gateway_url) as ws:
self.ws = ws
await self.logger.info('KOOK WebSocket connected')
# Start heartbeat
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
# Wait for HELLO within 6 seconds
try:
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
# Handle compressed messages (same as main message loop)
if isinstance(hello_msg, bytes):
# Decompress if compressed
try:
hello_msg = zlib.decompress(hello_msg).decode('utf-8')
except Exception:
# Not compressed or decompression failed
hello_msg = hello_msg.decode('utf-8')
hello_data = json.loads(hello_msg)
if hello_data.get('s') == 1: # HELLO signal
await self._handle_hello(hello_data['d'])
else:
raise Exception(f'Expected HELLO signal, got signal {hello_data.get("s")}')
except asyncio.TimeoutError:
raise Exception('Did not receive HELLO within 6 seconds')
# Reset retry count on successful connection
retry_count = 0
# Main message loop
async for message in ws:
if isinstance(message, bytes):
# Decompress if compressed
try:
message = zlib.decompress(message).decode('utf-8')
except Exception:
# Not compressed or decompression failed
message = message.decode('utf-8')
try:
msg_data = json.loads(message)
signal = msg_data.get('s')
if signal == 0: # EVENT
data = msg_data.get('d', {})
sn = msg_data.get('sn', 0)
await self._handle_event(data, sn)
elif signal == 3: # PONG
await self._handle_pong(msg_data.get('d', {}))
elif signal == 5: # RECONNECT
await self.logger.info('Received RECONNECT signal')
break # Break to reconnect
elif signal == 6: # RESUME ACK
await self.logger.info('Resume successful')
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse message: {message}')
except Exception as e:
await self.logger.error(f'Error processing message: {e}\n{traceback.format_exc()}')
except websockets.exceptions.ConnectionClosed:
await self.logger.warning('KOOK WebSocket connection closed, reconnecting...')
retry_count += 1
await asyncio.sleep(2**retry_count) # Exponential backoff
except Exception as e:
await self.logger.error(f'KOOK WebSocket error: {e}\n{traceback.format_exc()}')
retry_count += 1
await asyncio.sleep(2**retry_count)
finally:
# Stop heartbeat
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
self.ws = None
if retry_count >= max_retries:
await self.logger.error(f'Failed to connect after {max_retries} retries')
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""Send a message to a channel or user"""
content, msg_type = await self.message_converter.yiri2target(message)
# Determine endpoint based on target_type
if target_type == 'GROUP':
# Send to channel
url = 'https://www.kookapp.cn/api/v3/message/create'
payload = {
'target_id': target_id,
'content': content,
'type': msg_type,
}
else: # PERSON or default
# Send direct message
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
payload = {
'target_id': target_id,
'content': content,
'type': msg_type,
}
headers = {
'Authorization': f'Bot {self.config["token"]}',
'Content-Type': 'application/json',
}
try:
if not self.http_session:
self.http_session = aiohttp.ClientSession()
async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200:
result = await response.json()
if result.get('code') == 0:
await self.logger.debug(f'Message sent successfully to {target_id}')
else:
await self.logger.error(f'Failed to send message: {result.get("message")}')
else:
await self.logger.error(f'Failed to send message: HTTP {response.status}')
except Exception as e:
await self.logger.error(f'Error sending message: {e}')
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
"""Reply to a message"""
content, msg_type = await self.message_converter.yiri2target(message)
kook_event = message_source.source_platform_object
channel_type = kook_event.get('channel_type')
target_id = kook_event.get('target_id')
msg_id = kook_event.get('msg_id')
# Determine endpoint based on channel_type
if channel_type == 'GROUP':
url = 'https://www.kookapp.cn/api/v3/message/create'
payload = {
'target_id': target_id,
'content': content,
'type': msg_type,
}
else: # PERSON
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
# For direct messages, we need the chat_code or target_id
author_id = kook_event.get('author_id')
extra = kook_event.get('extra', {})
chat_code = extra.get('code', '')
payload = {
'content': content,
'type': msg_type,
}
if chat_code:
payload['chat_code'] = chat_code
else:
payload['target_id'] = str(author_id)
# Add quote if requested
if quote_origin and msg_id:
payload['quote'] = msg_id
headers = {
'Authorization': f'Bot {self.config["token"]}',
'Content-Type': 'application/json',
}
try:
if not self.http_session:
self.http_session = aiohttp.ClientSession()
async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200:
result = await response.json()
if result.get('code') == 0:
await self.logger.debug('Reply sent successfully')
else:
await self.logger.error(f'Failed to send reply: {result.get("message")}')
else:
await self.logger.error(f'Failed to send reply: HTTP {response.status}')
except Exception as e:
await self.logger.error(f'Error sending reply: {e}')
async def is_muted(self, group_id: int) -> bool:
"""Check if bot is muted in a group (not implemented for KOOK)"""
return False
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
"""Register an event listener"""
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
"""Unregister an event listener"""
self.listeners.pop(event_type, None)
async def run_async(self):
"""Start the KOOK adapter"""
# Debug: Track run_async
with open('/tmp/kook_adapter_run.txt', 'w') as f:
f.write(f'KOOK adapter run_async called at {time.time()}\n')
self.running = True
try:
# Create HTTP session
self.http_session = aiohttp.ClientSession()
await self.logger.info('Starting KOOK adapter')
# Get bot's user information and set bot_account_id
try:
bot_info = await self._get_bot_user_info()
self.bot_account_id = str(bot_info.get('id', ''))
except Exception as e:
await self.logger.error(f'Failed to get bot user info: {e}')
# Continue anyway, but bot will process its own messages
# Start WebSocket connection
self.ws_task = asyncio.create_task(self._websocket_loop())
# Keep running
await self.ws_task
except Exception as e:
await self.logger.error(f'KOOK adapter error: {e}\n{traceback.format_exc()}')
finally:
self.running = False
async def kill(self) -> bool:
"""Stop the KOOK adapter"""
self.running = False
# Cancel tasks
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
if self.ws_task:
self.ws_task.cancel()
try:
await self.ws_task
except asyncio.CancelledError:
pass
# Close WebSocket
if self.ws:
try:
await self.ws.close()
except Exception:
pass # Already closed or error during close
# Close HTTP session
if self.http_session:
await self.http_session.close()
await self.logger.info('KOOK adapter stopped')
return True

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

@@ -121,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
@@ -133,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的映射便于创建卡片后的发送消息到指定卡片
@@ -150,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,
@@ -163,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
@@ -213,73 +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 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('LINE Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 LINE 后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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,46 +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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('微信公众号 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在微信公众号后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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):
@@ -134,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,
@@ -226,46 +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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('QQ 官方机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 QQ 官方机器人后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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

@@ -97,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',
@@ -111,18 +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(
@@ -174,45 +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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"Slack 机器人 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在 Slack 后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
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

@@ -117,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
@@ -134,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()
@@ -164,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对象判断
@@ -200,15 +197,13 @@ 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()
@@ -349,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

@@ -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
@@ -131,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):
# 校验必填项
@@ -142,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'],
@@ -155,7 +153,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'],
logger=logger,
unified_mode=True,
)
super().__init__(
@@ -165,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,
@@ -187,6 +180,9 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_image(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]
@@ -218,38 +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):
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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

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
@@ -88,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']
@@ -190,46 +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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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

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,41 +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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"企业微信客服 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在企业微信后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
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

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

@@ -298,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)
@@ -576,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

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

@@ -1,7 +1,6 @@
admins: []
api:
port: 5300
webhook_prefix: 'http://127.0.0.1:5300'
command:
enable: true
prefix:
@@ -49,4 +48,3 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
cloud_service_url: 'https://space.langbot.app'
display_plugin_debug_url: 'http://localhost:5401'

View File

@@ -1,143 +0,0 @@
"""
Tests for webhook_prefix configuration
"""
import os
import pytest
from typing import Any
def _apply_env_overrides_to_config(cfg: dict) -> dict:
"""Apply environment variable overrides to data/config.yaml
Environment variables should be uppercase and use __ (double underscore)
to represent nested keys. For example:
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
Arrays and dict types are ignored.
Args:
cfg: Configuration dictionary
Returns:
Updated configuration dictionary
"""
def convert_value(value: str, original_value: Any) -> Any:
"""Convert string value to appropriate type based on original value
Args:
value: String value from environment variable
original_value: Original value to infer type from
Returns:
Converted value (falls back to string if conversion fails)
"""
if isinstance(original_value, bool):
return value.lower() in ('true', '1', 'yes', 'on')
elif isinstance(original_value, int):
try:
return int(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
elif isinstance(original_value, float):
try:
return float(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
else:
return value
# Process environment variables
for env_key, env_value in os.environ.items():
# Check if the environment variable is uppercase and contains __
if not env_key.isupper():
continue
if '__' not in env_key:
continue
# Convert environment variable name to config path
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
keys = [key.lower() for key in env_key.split('__')]
# Navigate to the target value and validate the path
current = cfg
for i, key in enumerate(keys):
if not isinstance(current, dict) or key not in current:
break
if i == len(keys) - 1:
# At the final key - check if it's a scalar value
if isinstance(current[key], (dict, list)):
# Skip dict and list types
pass
else:
# Valid scalar value - convert and set it
converted_value = convert_value(env_value, current[key])
current[key] = converted_value
else:
# Navigate deeper
current = current[key]
return cfg
class TestWebhookDisplayPrefix:
"""Test webhook_prefix configuration functionality"""
def test_default_webhook_prefix(self):
"""Test that the default webhook display prefix is correctly set"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Should have the default value
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
def test_webhook_prefix_env_override(self):
"""Test overriding webhook_prefix via environment variable"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Set environment variable
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com:8080'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
def test_webhook_prefix_with_custom_domain(self):
"""Test webhook_prefix with custom domain"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Set to a custom domain
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://bot.mycompany.com'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
def test_webhook_prefix_with_subdirectory(self):
"""Test webhook_prefix with subdirectory path"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Set to a URL with subdirectory
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com/langbot'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -0,0 +1,110 @@
"""
Test that preproc module adds sender_id and sender_name variables.
These tests verify the variables are correctly extracted from message events.
"""
import pytest
from unittest.mock import Mock, AsyncMock
@pytest.mark.asyncio
async def test_sender_variables_friend_message():
"""Test sender_id and sender_name are extracted from FriendMessage"""
# Create mock Friend sender
mock_sender = Mock()
mock_sender.id = 'test_user_123'
mock_sender.nickname = 'Test User'
mock_sender.remark = 'Test Remark'
mock_sender.get_name = Mock(return_value='Test User')
# Verify get_name returns nickname
assert mock_sender.get_name() == 'Test User'
@pytest.mark.asyncio
async def test_sender_variables_group_message():
"""Test sender_id and sender_name are extracted from GroupMessage"""
# Create mock GroupMember sender
mock_sender = Mock()
mock_sender.id = 'group_user_456'
mock_sender.member_name = 'Group User Name'
mock_sender.get_name = Mock(return_value='Group User Name')
# Verify get_name returns member_name
assert mock_sender.get_name() == 'Group User Name'
def test_sender_id_string_conversion():
"""Test sender_id is converted to string"""
# Test integer sender_id
sender_id_int = 12345
assert str(sender_id_int) == '12345'
# Test string sender_id
sender_id_str = 'user_abc'
assert str(sender_id_str) == 'user_abc'
def test_sender_name_empty_fallback():
"""Test sender_name defaults to empty string when not available"""
# When sender has no name
sender_name = ''
assert sender_name == ''
def test_variables_dict_structure():
"""Test the variables dictionary has expected structure"""
# Simulate what the variables dict should look like
variables = {
'session_id': 'person_12345',
'conversation_id': 'conv-uuid-123',
'msg_create_time': 1609459200,
'sender_id': '12345',
'sender_name': 'Test User',
}
# Verify all expected keys are present
assert 'session_id' in variables
assert 'conversation_id' in variables
assert 'msg_create_time' in variables
assert 'sender_id' in variables
assert 'sender_name' in variables
# Verify values
assert variables['sender_id'] == '12345'
assert variables['sender_name'] == 'Test User'
def test_dify_workflow_inputs_structure():
"""Test the Dify workflow inputs have expected legacy variables"""
plain_text = 'Hello world'
variables = {
'session_id': 'person_12345',
'conversation_id': 'conv-uuid-123',
'msg_create_time': 1609459200,
'sender_id': '12345',
'sender_name': 'Test User',
}
# Simulate Dify workflow inputs structure
inputs = {
'langbot_user_message_text': plain_text,
'langbot_session_id': variables['session_id'],
'langbot_conversation_id': variables['conversation_id'],
'langbot_msg_create_time': variables['msg_create_time'],
'langbot_sender_id': variables.get('sender_id', ''),
'langbot_sender_name': variables.get('sender_name', ''),
}
inputs.update(variables)
# Verify all legacy variables are present
assert inputs['langbot_user_message_text'] == 'Hello world'
assert inputs['langbot_session_id'] == 'person_12345'
assert inputs['langbot_conversation_id'] == 'conv-uuid-123'
assert inputs['langbot_msg_create_time'] == 1609459200
assert inputs['langbot_sender_id'] == '12345'
assert inputs['langbot_sender_name'] == 'Test User'
# Verify regular variables are also present
assert inputs['sender_id'] == '12345'
assert inputs['sender_name'] == 'Test User'

View File

@@ -51,7 +51,7 @@
"input-otp": "^1.4.2",
"lodash": "^4.17.21",
"lucide-react": "^0.507.0",
"next": "15.4.8",
"next": "15.4.7",
"next-themes": "^0.4.6",
"postcss": "^8.5.3",
"react": "^19.0.0",
@@ -74,18 +74,11 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/debug": "^4.1.12",
"@types/estree": "^1.0.8",
"@types/estree-jsx": "^1.0.5",
"@types/hast": "^3.0.4",
"@types/lodash": "^4.17.16",
"@types/mdast": "^4.0.4",
"@types/ms": "^2.1.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/unist": "^3.0.3",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^10.1.2",

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import {
IChooseAdapterEntity,
IPipelineEntity,
@@ -112,86 +112,11 @@ export default function BotForm({
IDynamicFormItemSchema[]
>([]);
const [, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const webhookInputRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
setBotFormValues();
}, []);
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
const copyToClipboard = () => {
console.log('[Copy] Attempting to copy from input element');
const inputElement = webhookInputRef.current;
if (!inputElement) {
console.error('[Copy] Input element not found');
toast.error(t('common.copyFailed'));
return;
}
try {
// 确保input元素可见且未被禁用
inputElement.disabled = false;
inputElement.readOnly = false;
// 聚焦并选中所有文本
inputElement.focus();
inputElement.select();
// 尝试使用现代API
if (navigator.clipboard && navigator.clipboard.writeText) {
console.log(
'[Copy] Using Clipboard API with input value:',
inputElement.value,
);
navigator.clipboard
.writeText(inputElement.value)
.then(() => {
console.log('[Copy] Clipboard API success');
inputElement.blur(); // 取消选中
inputElement.readOnly = true;
toast.success(t('bots.webhookUrlCopied'));
})
.catch((err) => {
console.error(
'[Copy] Clipboard API failed, trying execCommand:',
err,
);
// 降级到execCommand
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
toast.success(t('bots.webhookUrlCopied'));
} else {
toast.error(t('common.copyFailed'));
}
});
} else {
// 直接使用execCommand
console.log(
'[Copy] Using execCommand with input value:',
inputElement.value,
);
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
toast.success(t('bots.webhookUrlCopied'));
} else {
toast.error(t('common.copyFailed'));
}
}
} catch (err) {
console.error('[Copy] Copy failed:', err);
inputElement.readOnly = true;
toast.error(t('common.copyFailed'));
}
};
function setBotFormValues() {
initBotFormComponent().then(() => {
// 拉取初始化表单信息
@@ -206,20 +131,12 @@ export default function BotForm({
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
handleAdapterSelect(val.adapter);
// dynamicForm.setFieldsValue(val.adapter_config);
// 设置 webhook 地址(如果有)
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
})
.catch((err) => {
toast.error(t('bots.getBotConfigError') + err.message);
});
} else {
form.reset();
setWebhookUrl('');
}
});
}
@@ -292,7 +209,7 @@ export default function BotForm({
async function getBotConfig(
botId: string,
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
): Promise<z.infer<typeof formSchema>> {
return new Promise((resolve, reject) => {
httpClient
.getBot(botId)
@@ -305,10 +222,6 @@ export default function BotForm({
adapter_config: bot.adapter_config,
enable: bot.enable ?? true,
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
webhook_full_url: bot.adapter_runtime_values
? ((bot.adapter_runtime_values as Record<string, unknown>)
.webhook_full_url as string)
: undefined,
});
})
.catch((err) => {
@@ -447,86 +360,51 @@ export default function BotForm({
<div className="space-y-4">
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
{initBotId && (
<>
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem
key={item.value}
value={item.value}
>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Webhook 地址显示(统一 Webhook 模式) */}
{webhookUrl && (
<FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2">
<Input
ref={webhookInputRef}
value={webhookUrl}
readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900"
onClick={(e) => {
// 点击输入框时自动全选
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={copyToClipboard}
>
{t('common.copy')}
</Button>
</div>
<p className="text-sm text-gray-500 mt-1">
{t('bots.webhookUrlHint')}
</p>
</FormItem>
)}
</>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
)}
<FormField

View File

@@ -48,7 +48,7 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
<div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */}
<div className={`${styles.cardTitleContainer}`}>
<div className={`flex flex-row gap-2 items-center`}>
<div className={`flex flex-row gap-4`}>
<div className={`${styles.tag}`}>{botLog.level}</div>
{botLog.message_session_id && (
<div
@@ -60,7 +60,6 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
toast.success(t('common.copySuccess'));
});
}}
title={t('common.clickToCopy')}
>
<svg
className="icon"
@@ -68,8 +67,8 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1664"
width="16"
height="16"
width="20"
height="20"
fill="currentColor"
>
<path
@@ -88,6 +87,7 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
fill="currentColor"
></path>
</svg>
{/* 会话ID */}
<span className={`${styles.chatId}`}>
{getSubChatId(botLog.message_session_id)}
@@ -95,25 +95,22 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
</div>
)}
</div>
<div className={`${styles.timestamp}`}>
{formatTime(botLog.timestamp)}
</div>
<div>{formatTime(botLog.timestamp)}</div>
</div>
<div className={`${styles.cardText}`}>{botLog.text}</div>
{botLog.images.length > 0 && (
<PhotoProvider>
<div className={`flex flex-wrap gap-2 mt-3`}>
{botLog.images.map((item) => (
<img
key={item}
src={`${baseURL}/api/v1/files/image/${item}`}
alt=""
className="max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity"
/>
))}
</div>
</PhotoProvider>
)}
<div className={`${styles.cardTitleContainer} ${styles.cardText}`}>
{botLog.text}
</div>
<PhotoProvider className={``}>
<div className={`w-50 mt-2`}>
{botLog.images.map((item) => (
<img
key={item}
src={`${baseURL}/api/v1/files/image/${item}`}
alt=""
/>
))}
</div>
</PhotoProvider>
</div>
);
}

View File

@@ -1,32 +1,21 @@
.botLogListContainer {
width: 100%;
max-width: 100%;
min-height: 10rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
overflow-y: scroll;
}
.botLogCardContainer {
width: 100%;
max-width: 100%;
background-color: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
overflow: hidden;
box-sizing: border-box;
}
.botLogCardContainer:hover {
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border-radius: 10px;
border: 1px solid #cbd5e1;
padding: 1.2rem;
margin-bottom: 1rem;
cursor: pointer;
}
:global(.dark) .botLogCardContainer {
@@ -34,74 +23,40 @@
border: 1px solid #2a2a2e;
}
:global(.dark) .botLogCardContainer:hover {
border-color: #3a3a3e;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.listHeader {
width: 100%;
height: 2.5rem;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.tag {
display: inline-flex;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.25rem;
height: auto;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background-color: #dbeafe;
color: #1e40af;
font-size: 0.75rem;
font-weight: 500;
justify-content: flex-start;
gap: 0.2rem;
height: 1.5rem;
padding: 0.5rem;
border-radius: 0.4rem;
background-color: #a5d8ff;
color: #ffffff;
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.025em;
}
:global(.dark) .tag {
background-color: #1e3a8a;
color: #93c5fd;
}
.chatTag {
color: #4b5563;
background-color: #f3f4f6;
text-transform: none;
cursor: pointer;
transition: all 0.15s ease;
}
.chatTag:hover {
background-color: #e5e7eb;
}
:global(.dark) .chatTag {
color: #9ca3af;
background-color: #374151;
}
:global(.dark) .chatTag:hover {
background-color: #4b5563;
color: #626262;
background-color: #d1d1d1;
}
.chatId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Courier New', monospace;
font-size: 0.7rem;
}
.cardTitleContainer {
@@ -110,33 +65,9 @@
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.cardText {
color: #1e293b;
font-size: 0.875rem;
line-height: 1.7;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
overflow-wrap: anywhere;
hyphens: auto;
max-width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', sans-serif;
}
:global(.dark) .cardText {
color: #e2e8f0;
}
.timestamp {
color: #64748b;
font-size: 0.75rem;
white-space: nowrap;
}
:global(.dark) .timestamp {
margin-top: 0.4rem;
color: #64748b;
}

View File

@@ -509,17 +509,6 @@ export default function ExternalKBForm({
</Select>
</FormControl>
<FormMessage />
<p className="text-sm text-muted-foreground">
{t('knowledge.retrieverInstallInfo')}{' '}
<a
href="https://space.langbot.app/market?category=KnowledgeRetriever"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:no-underline"
>
{t('knowledge.retrieverMarketLink')}
</a>
</p>
</FormItem>
)}
/>

View File

@@ -154,7 +154,7 @@ export default function PipelineDialog({
// 编辑流水线时的对话框
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex">
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] h-[75vh] flex">
<SidebarProvider className="items-start w-full flex h-full min-h-0">
<Sidebar
collapsible="none"

View File

@@ -20,13 +20,6 @@ import { toast } from 'sonner';
import AtBadge from './AtBadge';
import { WebSocketClient } from '@/app/infra/websocket/WebSocketClient';
import ImagePreviewDialog from './ImagePreviewDialog';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import '@/styles/github-markdown.css';
interface DebugDialogProps {
open: boolean;
@@ -57,9 +50,7 @@ export default function DebugDialog({
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
const [showImagePreview, setShowImagePreview] = useState(false);
const [quotedMessage, setQuotedMessage] = useState<Message | null>(null);
const [rawModeMessages, setRawModeMessages] = useState<Set<string>>(
new Set(),
);
const [hoveredMessageId, setHoveredMessageId] = useState<number | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
@@ -204,8 +195,6 @@ export default function DebugDialog({
// 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接
useEffect(() => {
if (open) {
// 清空当前消息,避免显示旧的消息
setMessages([]);
loadMessages(selectedPipelineId);
initWebSocket(selectedPipelineId);
}
@@ -565,111 +554,7 @@ export default function DebugDialog({
return t('bots.earlier');
};
// Generate a unique key for a message
const getMessageKey = (message: Message): string => {
return `${message.id}-${message.timestamp}`;
};
// Toggle raw mode for a message (by default, messages are in markdown mode)
const toggleRawMode = (message: Message) => {
const key = getMessageKey(message);
setRawModeMessages((prev) => {
const newSet = new Set(prev);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
};
// Check if message has any Plain text content
const hasPlainText = (message: Message): boolean => {
return message.message_chain.some((c) => c.type === 'Plain');
};
// Extract plain text from message chain
const getPlainText = (message: Message): string => {
return message.message_chain
.filter((c) => c.type === 'Plain')
.map((c) => (c as Plain).text)
.join('');
};
const renderMessageContent = (message: Message) => {
const key = getMessageKey(message);
const isRawMode = rawModeMessages.has(key);
// By default, render with markdown if there's plain text (unless raw mode is enabled)
if (!isRawMode && hasPlainText(message)) {
const plainText = getPlainText(message);
const nonPlainComponents = message.message_chain.filter(
(c) => c.type !== 'Plain' && c.type !== 'Source',
);
return (
<div className="text-base leading-relaxed align-middle">
{/* Render non-Plain components first */}
{nonPlainComponents.map((component, index) =>
renderMessageComponent(component, index),
)}
{/* Render Plain text as markdown */}
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
rehypeHighlight,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: {
className: ['anchor'],
},
},
],
]}
components={{
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
ol: ({ children }) => (
<ol className="list-decimal">{children}</ol>
),
li: ({ children }) => <li className="ml-4">{children}</li>,
img: ({ src, alt, ...props }) => {
const imageSrc = src || '';
if (typeof imageSrc !== 'string') {
return (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
);
}
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}}
>
{plainText}
</ReactMarkdown>
</div>
</div>
);
}
return (
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
{message.message_chain.map((component, index) =>
@@ -734,109 +619,68 @@ export default function DebugDialog({
<div
key={message.id + message.timestamp}
className={cn(
'flex',
'flex group',
message.role === 'user' ? 'justify-end' : 'justify-start',
)}
onMouseEnter={() => setHoveredMessageId(message.id)}
onMouseLeave={() => setHoveredMessageId(null)}
>
<div
className={cn(
'max-w-3xl px-5 py-3 rounded-2xl',
message.role === 'user'
? 'user-message-bubble bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
'relative flex items-end gap-2',
message.role === 'user' ? 'flex-row-reverse' : 'flex-row',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'text-xs mt-2 flex items-center justify-between gap-2',
'max-w-md px-5 py-3 rounded-2xl',
message.role === 'user'
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400',
? 'bg-[#2288ee] text-white rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
)}
>
<div className="flex items-center gap-2">
{renderMessageContent(message)}
<div
className={cn(
'text-xs mt-2 flex items-center justify-between gap-2',
message.role === 'user'
? 'text-white/70'
: 'text-gray-500 dark:text-gray-400',
)}
>
<span>
{message.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</span>
{hasPlainText(message) && (
<button
onClick={() => toggleRawMode(message)}
className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors',
message.role === 'user'
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
)}
title={
rawModeMessages.has(getMessageKey(message))
? t('pipelines.debugDialog.showMarkdown')
: t('pipelines.debugDialog.showRaw')
}
>
{rawModeMessages.has(getMessageKey(message)) ? (
<span className="flex items-center gap-0.5">
<svg
className="w-3 h-3"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z" />
</svg>
MD
</span>
) : (
<span className="flex items-center gap-0.5">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
{t('pipelines.debugDialog.showRaw')}
</span>
)}
</button>
)}
<button
onClick={() => setQuotedMessage(message)}
className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5',
message.role === 'user'
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
)}
title={t('pipelines.debugDialog.reply')}
>
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{t('pipelines.debugDialog.reply')}
</button>
<span className="text-[10px]">
{formatTimestamp(getMessageTimestamp(message))}
</span>
</div>
<span className="text-[10px]">
{formatTimestamp(getMessageTimestamp(message))}
</span>
</div>
{hoveredMessageId === message.id && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => setQuotedMessage(message)}
>
<svg
className="w-3 h-3 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{t('pipelines.debugDialog.reply')}
</Button>
)}
</div>
</div>
))

View File

@@ -8,7 +8,7 @@ import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { getAPILanguageCode } from '@/i18n/I18nProvider';
import '@/styles/github-markdown.css';
import './github-markdown.css';
export default function PluginReadme({
pluginAuthor,

View File

@@ -24,9 +24,6 @@ import {
Power,
Github,
ChevronLeft,
Code,
Copy,
Bug,
} from 'lucide-react';
import {
DropdownMenu,
@@ -41,11 +38,6 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -113,11 +105,6 @@ export default function PluginConfigPage() {
);
const [isEditMode, setIsEditMode] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [debugInfo, setDebugInfo] = useState<{
debug_url: string;
plugin_debug_key: string;
} | null>(null);
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
useEffect(() => {
const fetchPluginSystemStatus = async () => {
@@ -387,22 +374,6 @@ export default function PluginConfigPage() {
[uploadPluginFile, isPluginSystemReady, t],
);
const handleShowDebugInfo = async () => {
try {
const info = await httpClient.getPluginDebugInfo();
setDebugInfo(info);
setDebugPopoverOpen(true);
} catch (error) {
console.error('Failed to fetch debug info:', error);
toast.error(t('plugins.failedToGetDebugInfo'));
}
};
const handleCopyDebugInfo = (text: string) => {
navigator.clipboard.writeText(text);
toast.success(t('plugins.copiedToClipboard'));
};
const renderPluginDisabledState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<Power className="w-16 h-16 text-gray-400 mb-4" />
@@ -495,92 +466,7 @@ export default function PluginConfigPage() {
</TabsTrigger>
</TabsList>
<div className="flex flex-row justify-end items-center gap-2">
{activeTab === 'installed' && (
<Popover
open={debugPopoverOpen}
onOpenChange={setDebugPopoverOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="px-4 py-5 cursor-pointer"
onClick={handleShowDebugInfo}
>
<Code className="w-4 h-4 mr-2" />
{t('plugins.debugInfo')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[380px]" align="end">
<div className="space-y-3">
{/* Header with icon and title */}
<div className="flex items-center gap-2 pb-2 border-b">
<Bug className="w-4 h-4" />
<h4 className="font-semibold text-sm">
{t('plugins.debugInfoTitle')}
</h4>
</div>
{/* Debug URL row */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugUrl')}:
</label>
<Input
value={debugInfo?.debug_url || ''}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
handleCopyDebugInfo(debugInfo?.debug_url || '')
}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</div>
{/* Debug Key row */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugKey')}:
</label>
<Input
value={
debugInfo?.plugin_debug_key ||
t('plugins.noDebugKey')
}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
handleCopyDebugInfo(
debugInfo?.plugin_debug_key || '',
)
}
disabled={!debugInfo?.plugin_debug_key}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</div>
{!debugInfo?.plugin_debug_key && (
<p className="text-xs text-muted-foreground ml-[58px]">
{t('plugins.debugKeyDisabled')}
</p>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
<div className="flex flex-row justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="px-6 py-4 cursor-pointer">

View File

@@ -63,7 +63,6 @@ export interface KnowledgeBase {
description: string;
embedding_model_uuid: string;
created_at?: string;
updated_at?: string;
top_k: number;
}
@@ -143,7 +142,6 @@ export interface Bot {
use_pipeline_uuid?: string;
created_at?: string;
updated_at?: string;
adapter_runtime_values?: object;
}
export interface ApiRespKnowledgeBases {

View File

@@ -639,13 +639,6 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/system/status/plugin-system');
}
public getPluginDebugInfo(): Promise<{
debug_url: string;
plugin_debug_key: string;
}> {
return this.get('/api/v1/plugins/debug-info');
}
// ============ User API ============
public checkIfInited(): Promise<{ initialized: boolean }> {
return this.get('/api/v1/user/init');

View File

@@ -79,10 +79,8 @@ export class WebSocketClient {
// 构建WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// extract host from process.env.NEXT_PUBLIC_API_BASE_URL
// 如果环境变量未定义,使用当前页面的 host (适配生产环境)
const host =
process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] ||
window.location.host;
process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] || '';
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
this.ws = new WebSocket(url);
@@ -151,28 +149,12 @@ export class WebSocketClient {
break;
case 'response':
// 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session都忽略
if (!data.session_type || data.session_type !== this.sessionType) {
// 忽略不匹配的 session_type 消息
console.debug(
`忽略不匹配的消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`,
);
break;
}
if (data.data) {
this.onMessageCallback?.(data.data);
}
break;
case 'user_message':
// 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session都忽略
if (!data.session_type || data.session_type !== this.sessionType) {
// 忽略不匹配的 session_type 消息
console.debug(
`忽略不匹配的用户消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`,
);
break;
}
// 用户消息广播(包括自己发送的消息)
if (data.data) {
this.onMessageCallback?.(data.data);

View File

@@ -41,7 +41,6 @@ const enUS = {
addRound: 'Add Round',
copy: 'Copy',
copySuccess: 'Copy Successfully',
copyFailed: 'Copy Failed',
test: 'Test',
forgotPassword: 'Forgot Password?',
loading: 'Loading...',
@@ -188,10 +187,6 @@ const enUS = {
log: 'Log',
configuration: 'Configuration',
logs: 'Logs',
webhookUrl: 'Webhook Callback URL',
webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint:
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
},
plugins: {
title: 'Extensions',
@@ -236,15 +231,6 @@ const enUS = {
failedToGetStatus: 'Failed to get plugin system status',
pluginSystemNotReady:
'Plugin system is not ready, cannot perform this operation',
debugInfo: 'Debug Info',
debugInfoTitle: 'Plugin Debug Information',
debugUrl: 'Debug URL',
debugKey: 'Debug Key',
noDebugKey: '(Not Set)',
debugKeyDisabled:
'Debug key is not set, plugin debugging does not require authentication',
failedToGetDebugInfo: 'Failed to get debug information',
copiedToClipboard: 'Copied to clipboard',
deleting: 'Deleting...',
deletePlugin: 'Delete Plugin',
cancel: 'Cancel',
@@ -539,8 +525,6 @@ const enUS = {
imageUploadFailed: 'Image upload failed',
reply: 'Reply',
replyTo: 'Reply to',
showMarkdown: 'Show Markdown',
showRaw: 'Show Raw',
},
},
knowledge: {
@@ -619,8 +603,6 @@ const enUS = {
retriever: 'Retriever',
selectRetriever: 'Select a retriever...',
retrieverConfiguration: 'Retriever Configuration',
retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',
retrieverMarketLink: 'here',
},
register: {
title: 'Initialize LangBot 👋',

View File

@@ -42,7 +42,6 @@ const jaJP = {
addRound: 'ラウンドを追加',
copy: 'コピー',
copySuccess: 'コピーに成功しました',
copyFailed: 'コピーに失敗しました',
test: 'テスト',
forgotPassword: 'パスワードを忘れた?',
loading: '読み込み中...',
@@ -190,10 +189,6 @@ const jaJP = {
log: 'ログ',
configuration: '設定',
logs: 'ログ',
webhookUrl: 'Webhook コールバック URL',
webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint:
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
},
plugins: {
title: '拡張機能',
@@ -238,15 +233,6 @@ const jaJP = {
failedToGetStatus: 'プラグインシステム状態の取得に失敗しました',
pluginSystemNotReady:
'プラグインシステムが準備されていません。この操作を実行できません',
debugInfo: 'デバッグ情報',
debugInfoTitle: 'プラグインデバッグ情報',
debugUrl: 'デバッグURL',
debugKey: 'デバッグキー',
noDebugKey: '(未設定)',
debugKeyDisabled:
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
failedToGetDebugInfo: 'デバッグ情報の取得に失敗しました',
copiedToClipboard: 'クリップボードにコピーしました',
deleting: '削除中...',
deletePlugin: 'プラグインを削除',
cancel: 'キャンセル',
@@ -543,8 +529,6 @@ const jaJP = {
imageUploadFailed: '画像のアップロードに失敗しました',
reply: '返信',
replyTo: '返信先',
showMarkdown: 'Markdownで表示',
showRaw: '原文で表示',
},
},
knowledge: {
@@ -624,8 +608,6 @@ const jaJP = {
retriever: '検索器',
selectRetriever: '検索器を選択...',
retrieverConfiguration: '検索器設定',
retrieverInstallInfo: 'ナレッジ検索器プラグインは',
retrieverMarketLink: 'こちらからインストールできます',
},
register: {
title: 'LangBot を初期化 👋',

View File

@@ -41,7 +41,6 @@ const zhHans = {
addRound: '添加回合',
copy: '复制',
copySuccess: '复制成功',
copyFailed: '复制失败',
test: '测试',
forgotPassword: '忘记密码?',
loading: '加载中...',
@@ -183,10 +182,6 @@ const zhHans = {
log: '日志',
configuration: '配置',
logs: '日志',
webhookUrl: 'Webhook 回调地址',
webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint:
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
},
plugins: {
title: '插件扩展',
@@ -227,14 +222,6 @@ const zhHans = {
loadingStatus: '正在检查插件系统状态...',
failedToGetStatus: '获取插件系统状态失败',
pluginSystemNotReady: '插件系统未就绪,无法执行此操作',
debugInfo: '调试信息',
debugInfoTitle: '插件调试信息',
debugUrl: '调试地址',
debugKey: '调试密钥',
noDebugKey: '(未设置)',
debugKeyDisabled: '未设置调试密钥,插件调试无需认证',
failedToGetDebugInfo: '获取调试信息失败',
copiedToClipboard: '已复制到剪贴板',
deleting: '删除中...',
deletePlugin: '删除插件',
cancel: '取消',
@@ -521,8 +508,6 @@ const zhHans = {
imageUploadFailed: '图片上传失败',
reply: '回复',
replyTo: '回复给',
showMarkdown: '渲染',
showRaw: '原文',
},
},
knowledge: {
@@ -596,8 +581,6 @@ const zhHans = {
retriever: '检索器',
selectRetriever: '选择一个检索器...',
retrieverConfiguration: '检索器配置',
retrieverInstallInfo: '您可以从',
retrieverMarketLink: '此处安装知识检索器插件',
},
register: {
title: '初始化 LangBot 👋',

View File

@@ -41,7 +41,6 @@ const zhHant = {
addRound: '新增回合',
copy: '複製',
copySuccess: '複製成功',
copyFailed: '複製失敗',
test: '測試',
forgotPassword: '忘記密碼?',
loading: '載入中...',
@@ -183,10 +182,6 @@ const zhHant = {
log: '日誌',
configuration: '設定',
logs: '日誌',
webhookUrl: 'Webhook 回調位址',
webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint:
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
},
plugins: {
title: '外掛擴展',
@@ -227,14 +222,6 @@ const zhHant = {
loadingStatus: '正在檢查外掛系統狀態...',
failedToGetStatus: '取得外掛系統狀態失敗',
pluginSystemNotReady: '外掛系統未就緒,無法執行此操作',
debugInfo: '偵錯資訊',
debugInfoTitle: '外掛偵錯資訊',
debugUrl: '偵錯位址',
debugKey: '偵錯金鑰',
noDebugKey: '(未設定)',
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證',
failedToGetDebugInfo: '取得偵錯資訊失敗',
copiedToClipboard: '已複製到剪貼簿',
deleting: '刪除中...',
deletePlugin: '刪除外掛',
cancel: '取消',
@@ -519,8 +506,6 @@ const zhHant = {
imageUploadFailed: '圖片上傳失敗',
reply: '回覆',
replyTo: '回覆給',
showMarkdown: '渲染',
showRaw: '原文',
},
},
knowledge: {
@@ -594,8 +579,6 @@ const zhHant = {
retriever: '檢索器',
selectRetriever: '選擇一個檢索器...',
retrieverConfiguration: '檢索器配置',
retrieverInstallInfo: '您可以從',
retrieverMarketLink: '此處安裝知識檢索器插件',
},
register: {
title: '初始化 LangBot 👋',