mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-05 17:17:14 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d |
@@ -1,5 +1,5 @@
|
||||
name: 漏洞反馈
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
|
||||
title: "[Bug]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details
|
||||
title: "[Bug]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
|
||||
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
|
||||
## Where to Look
|
||||
|
||||
- Architecture map: `ARCHITECTURE.md`.
|
||||
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
||||
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
|
||||
- API-key auth: `docs/API_KEY_AUTH.md`.
|
||||
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
|
||||
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
|
||||
|
||||
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Website</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Features</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||
@@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||
|
||||
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||
[→ Learn more about all features](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -89,7 +89,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||
|
||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||
[→ View all integrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官网</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">扩展市场</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -89,7 +89,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -152,7 +152,7 @@ docker compose --profile all up -d
|
||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||
|
||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
### TTS(语音合成)
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Inicio</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Características</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||
|
||||
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||
|
||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Accueil</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||
|
||||
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||
[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||
|
||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">ホーム</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/features">機能</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> |
|
||||
<a href="https://langbot.app/docs/ja/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||
|
||||
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features)
|
||||
|
||||
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||
|
||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">홈</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">기능</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">문서</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||
|
||||
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||
|
||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Главная</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||
|
||||
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||
|
||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官網</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">外掛市場</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||
|
||||
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -90,7 +90,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -165,7 +165,7 @@ docker compose --profile all up -d
|
||||
|-----------|------|
|
||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||
|
||||
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Trang chủ</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||
|
||||
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||
[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||
|
||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Docker Compose configuration for LangBot
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Kubernetes Deployment for LangBot
|
||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||
#
|
||||
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
||||
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
|
||||
#
|
||||
# Usage:
|
||||
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
||||
|
||||
@@ -218,8 +218,8 @@ metadata:
|
||||
spec:
|
||||
categories: [popular, global]
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
config:
|
||||
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||
- { name: callback_url, type: string, required: false, default: "" }
|
||||
|
||||
@@ -243,7 +243,7 @@ For large datasets:
|
||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||
- OceanBase Documentation: https://oceanbase.ai
|
||||
- LangBot Documentation: https://docs.langbot.app
|
||||
- LangBot Documentation: https://langbot.app/docs
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
|
||||
They show the whole loop: signing a request, pushing a message, and receiving
|
||||
multi-part replies on a callback endpoint.
|
||||
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
|
||||
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||
|
||||
## Files
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||
1→M 的多段回复。
|
||||
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
|
||||
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
@@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot**
|
||||
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||
a single `<script>` tag.
|
||||
|
||||
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
|
||||
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
||||
网站上的那个组件。
|
||||
|
||||
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
|
||||
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.9"
|
||||
version = "4.10.10"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.5.6",
|
||||
"langbot-plugin==0.5.7",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -114,7 +114,7 @@ seekdb = [
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://langbot.app"
|
||||
Documentation = "https://docs.langbot.app"
|
||||
Documentation = "https://langbot.app/docs"
|
||||
Repository = "https://github.com/langbot-app/LangBot"
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||
## Kubernetes
|
||||
|
||||
See `docker/kubernetes.yaml` and the deployment guide at
|
||||
https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
|
||||
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
|
||||
|
||||
## config.yaml (generated at `data/config.yaml` on first run)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ asciiart = r"""
|
||||
|___/
|
||||
|
||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||
📖 Documentation 文档地址: https://docs.langbot.app
|
||||
📖 Documentation 文档地址: https://langbot.app/docs
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from .. import group
|
||||
from .....entity.errors import account as account_errors
|
||||
from ...context import RequestContext
|
||||
from .....cloud.launch import SpaceLaunchError
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
|
||||
@@ -144,13 +143,6 @@ class UserRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
|
||||
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
|
||||
cloud_entry = quart.request.args.get('cloud_entry') == '1'
|
||||
if (
|
||||
cloud_entry
|
||||
and not launch_workspace_uuid
|
||||
and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
):
|
||||
return self.success(data={'authorize_url': self.ap.space_service.get_cloud_entry_url()})
|
||||
if launch_workspace_uuid:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
return self.fail(1, 'Space launch requires Cloud mode')
|
||||
@@ -194,6 +186,9 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback'
|
||||
)
|
||||
launch_assertion = json_data.get('launch_assertion')
|
||||
workspace_uuid = json_data.get('workspace_uuid')
|
||||
|
||||
@@ -207,8 +202,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
if not state:
|
||||
return self.fail(1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||
# Exchange code for tokens
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
@@ -226,24 +224,36 @@ class UserRouterGroup(group.RouterGroup):
|
||||
code,
|
||||
workspace_uuids,
|
||||
workspace_created_ats,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
# Authenticate and create/update local user
|
||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth Workspace binding mismatch')
|
||||
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
|
||||
if cloud_mode:
|
||||
if not target_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
|
||||
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||
|
||||
# Authenticate only after the signed, exact Workspace delta has
|
||||
# established the Account and membership runtime shadow rows.
|
||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||
access_token, refresh_token, expires_in
|
||||
)
|
||||
|
||||
if launch_workspace_uuid:
|
||||
if target_workspace_uuid:
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
user_obj.uuid,
|
||||
launch_workspace_uuid,
|
||||
target_workspace_uuid,
|
||||
)
|
||||
except Exception:
|
||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||
@@ -375,12 +385,17 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
|
||||
)
|
||||
|
||||
if not code:
|
||||
return self.http_status(400, -1, 'Missing authorization code')
|
||||
|
||||
if not state:
|
||||
return self.http_status(400, -1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||
@@ -393,7 +408,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||
|
||||
try:
|
||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
|
||||
updated_user = await self.ap.user_service.bind_space_account(
|
||||
user_obj.user, code, redirect_uri=redirect_uri
|
||||
)
|
||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||
return self.success(
|
||||
data={
|
||||
@@ -436,49 +454,18 @@ class UserRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
projection_service = self.ap.directory_projection_service
|
||||
access = None
|
||||
# A first Cloud launch creates the personal Workspace immediately
|
||||
# before redirecting here. Pull a bounded number of signed event
|
||||
# pages until both the Account and its target Workspace membership
|
||||
# are visible instead of rejecting during the background-sync window.
|
||||
for attempt in range(4):
|
||||
if account is not None:
|
||||
self.ap.user_service._require_active_account(account)
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
break
|
||||
except WorkspaceNotFoundError:
|
||||
if projection_service is None:
|
||||
raise
|
||||
elif projection_service is None:
|
||||
break
|
||||
|
||||
if attempt == 3:
|
||||
break
|
||||
await projection_service.sync_once()
|
||||
raise SpaceLaunchError('Cloud directory projection is unavailable')
|
||||
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
|
||||
if access is None and projection_service is not None:
|
||||
# The target event may be deeper than the bounded incremental
|
||||
# page budget. One authoritative signed snapshot catches this
|
||||
# process up without turning the callback into unbounded polling.
|
||||
await projection_service.refresh_snapshot()
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is not None:
|
||||
self.ap.user_service._require_active_account(account)
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
if access is None: # pragma: no cover - bounded loop resolves or raises.
|
||||
raise SpaceLaunchError('Launch Workspace is not projected into Core')
|
||||
self.ap.user_service._require_active_account(account)
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
return self.success(
|
||||
data={
|
||||
|
||||
@@ -137,7 +137,16 @@ class BotService:
|
||||
|
||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||
|
||||
try:
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
except Exception:
|
||||
# The bot row was already inserted above; without this rollback a
|
||||
# failing adapter constructor (e.g. a missing optional credential
|
||||
# key) would leave a permanently disabled orphan bot in the DB.
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
|
||||
)
|
||||
raise
|
||||
|
||||
return bot_data['uuid']
|
||||
|
||||
|
||||
@@ -119,21 +119,18 @@ class SpaceService:
|
||||
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
params = {'redirect_uri': redirect_uri}
|
||||
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||
if state:
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
|
||||
def get_cloud_entry_url(self) -> str:
|
||||
"""Return the Space-owned Cloud selector for a Cloud Account login."""
|
||||
|
||||
return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta'
|
||||
|
||||
async def exchange_oauth_code(
|
||||
self,
|
||||
code: str,
|
||||
workspace_uuids: list[str] | None = None,
|
||||
workspace_created_ats: dict[str, int] | None = None,
|
||||
*,
|
||||
redirect_uri: str = '',
|
||||
) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
from langbot.pkg.utils import constants
|
||||
@@ -146,6 +143,7 @@ class SpaceService:
|
||||
f'{space_url}/api/v1/accounts/oauth/token',
|
||||
json={
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'instance_id': constants.instance_id,
|
||||
# Sending an explicit empty list tells new Space servers not to
|
||||
# synthesize a legacy instance-derived Workspace binding.
|
||||
|
||||
@@ -774,7 +774,7 @@ class UserService:
|
||||
f'email:{normalized_email}',
|
||||
)
|
||||
|
||||
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
||||
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||
"""Bind Space account to existing local account"""
|
||||
local_account = await self.get_user_by_email(user_email)
|
||||
if local_account is None:
|
||||
@@ -794,12 +794,13 @@ class UserService:
|
||||
code,
|
||||
[binding.workspace_uuid],
|
||||
{binding.workspace_uuid: created_ts},
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
else:
|
||||
# Compatibility for early/bootstrap call sites that have not wired
|
||||
# WorkspaceService yet; old Space servers still derive the legacy
|
||||
# Workspace identity from instance_id when the field is omitted.
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
|
||||
@@ -173,6 +173,77 @@ class DirectoryProjectionService:
|
||||
async with self._sync_lock:
|
||||
await self._sync_once()
|
||||
|
||||
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
|
||||
"""Synchronously project an exact Workspace set without moving the event cursor."""
|
||||
|
||||
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
|
||||
if not requested:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
|
||||
if len(requested) > self.event_limit:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
|
||||
async with self._sync_lock:
|
||||
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
|
||||
await self._apply_targeted_delta(delta, requested)
|
||||
|
||||
async def _apply_targeted_delta(
|
||||
self,
|
||||
delta: DirectoryDelta,
|
||||
requested_workspace_uuids: tuple[str, ...],
|
||||
) -> None:
|
||||
if not isinstance(delta, DirectoryDelta):
|
||||
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
|
||||
workspace_count, membership_count = self._validate_batch_capacity(
|
||||
delta.workspaces,
|
||||
full_snapshot=False,
|
||||
)
|
||||
delta = DirectoryDelta.model_validate(delta.model_dump())
|
||||
if delta.instance_uuid != self.instance_uuid:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
|
||||
requested = set(requested_workspace_uuids)
|
||||
if set(delta.requested_workspace_uuids) != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
|
||||
if {workspace.uuid for workspace in delta.workspaces} != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
|
||||
|
||||
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
|
||||
if not callable(directory_uow):
|
||||
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
|
||||
|
||||
async with directory_uow(self.instance_uuid) as uow:
|
||||
session = uow.session
|
||||
state = await session.scalar(
|
||||
sqlalchemy.select(DirectoryProjectionState)
|
||||
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if state is None:
|
||||
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
|
||||
snapshot = DirectorySnapshot(
|
||||
instance_uuid=self.instance_uuid,
|
||||
cursor=state.cursor,
|
||||
generated_at=delta.generated_at,
|
||||
workspaces=delta.workspaces,
|
||||
)
|
||||
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
|
||||
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
|
||||
active_workspace_count = await self._enforce_active_workspace_capacity(session)
|
||||
await session.flush()
|
||||
|
||||
await self._update_entitlement_workspace_activity(
|
||||
snapshot.workspaces,
|
||||
requested_workspace_uuids=requested,
|
||||
)
|
||||
self._publish_runtime_execution_projection(
|
||||
snapshot.workspaces,
|
||||
affected_workspace_uuids=requested,
|
||||
)
|
||||
self._request_model_catalog_sync()
|
||||
self._record_batch_cardinality(
|
||||
active_workspaces=active_workspace_count,
|
||||
workspaces=workspace_count,
|
||||
memberships=membership_count,
|
||||
)
|
||||
|
||||
async def _sync_once(self) -> None:
|
||||
cursor = self._consumer_cursor
|
||||
if cursor is None:
|
||||
@@ -723,7 +794,13 @@ class DirectoryProjectionService:
|
||||
for row in inbox_rows:
|
||||
row.applied_at = now
|
||||
|
||||
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
|
||||
async def _apply_accounts(
|
||||
self,
|
||||
session: Any,
|
||||
snapshot: DirectorySnapshot,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
) -> dict[str, User]:
|
||||
selected: dict[str, DirectoryMember] = {}
|
||||
emails: dict[str, str] = {}
|
||||
for workspace in snapshot.workspaces:
|
||||
@@ -788,6 +865,12 @@ class DirectoryProjectionService:
|
||||
continue
|
||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
||||
if preserve_existing:
|
||||
# A targeted Workspace fetch has no independently monotonic
|
||||
# Account revision. It may create a missing runtime shadow, but
|
||||
# ordered event/snapshot projection remains the only updater of
|
||||
# existing Account identity and status fields.
|
||||
continue
|
||||
if account.projection_revision > snapshot.cursor:
|
||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||
projected_account = self._account_projection(member)
|
||||
|
||||
@@ -635,9 +635,9 @@ class Application:
|
||||
frontend_path = paths.get_frontend_path()
|
||||
|
||||
if not os.path.exists(frontend_path):
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
|
||||
self.logger.warning(
|
||||
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
|
||||
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||
en: https://link.langbot.app/en/platforms/dingtalk
|
||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
|
||||
@@ -24,9 +24,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/discord
|
||||
en: https://link.langbot.app/en/platforms/discord
|
||||
ja: https://link.langbot.app/ja/platforms/discord
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
|
||||
@@ -18,9 +18,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/kook
|
||||
en: https://link.langbot.app/en/platforms/kook
|
||||
ja: https://link.langbot.app/ja/platforms/kook
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -19,9 +19,9 @@ spec:
|
||||
- china
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/lark
|
||||
en: https://link.langbot.app/en/platforms/lark
|
||||
ja: https://link.langbot.app/ja/platforms/lark
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||
config:
|
||||
- name: domain
|
||||
label:
|
||||
|
||||
@@ -22,9 +22,9 @@ spec:
|
||||
categories:
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/line
|
||||
en: https://link.langbot.app/en/platforms/line
|
||||
ja: https://link.langbot.app/ja/platforms/line
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||
en: https://langbot.app/docs/en/usage/platforms/line
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||
en: https://link.langbot.app/en/platforms/officialaccount
|
||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
|
||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
token=config.get('token', ''),
|
||||
logger=logger,
|
||||
unified_mode=enable_webhook,
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
|
||||
@@ -21,9 +21,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/satori
|
||||
en: https://link.langbot.app/en/platforms/satori
|
||||
ja: https://link.langbot.app/ja/platforms/satori
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/readme
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: platform
|
||||
label:
|
||||
|
||||
@@ -24,9 +24,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/slack
|
||||
en: https://link.langbot.app/en/platforms/slack
|
||||
ja: https://link.langbot.app/ja/platforms/slack
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -24,9 +24,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/telegram
|
||||
en: https://link.langbot.app/en/platforms/telegram
|
||||
ja: https://link.langbot.app/ja/platforms/telegram
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/telegram
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wechatpad
|
||||
en: https://link.langbot.app/en/platforms/wechatpad
|
||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: wechatpad_url
|
||||
label:
|
||||
|
||||
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||
if content['type'] == 'image':
|
||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
||||
await self.bot.send_image(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'voice':
|
||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
||||
await self.bot.send_voice(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'file':
|
||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
||||
await self.bot.send_file(user_id, agent_id, content['media_id'])
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecom
|
||||
en: https://link.langbot.app/en/platforms/wecom
|
||||
ja: https://link.langbot.app/ja/platforms/wecom
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
||||
en: https://link.langbot.app/en/platforms/wecombot
|
||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
||||
en: https://link.langbot.app/en/platforms/wecomcs
|
||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 获取输出键名,默认为response
|
||||
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')
|
||||
self.response_handling = self.pipeline_config['ai']['n8n-service-api'].get('response-handling', 'reply')
|
||||
if self.response_handling not in {'reply', 'ignore'}:
|
||||
raise ValueError(f'Invalid n8n response-handling: {self.response_handling}')
|
||||
|
||||
# 获取认证类型,默认为none
|
||||
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
|
||||
@@ -262,7 +265,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
if self.response_handling == 'ignore':
|
||||
status_ok = 200 <= response.status < 300
|
||||
else:
|
||||
status_ok = response.status == 200
|
||||
if not status_ok:
|
||||
error_text = (
|
||||
await httpclient.read_limited(
|
||||
response,
|
||||
@@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
if self.response_handling == 'ignore':
|
||||
response.release()
|
||||
self.ap.logger.debug('n8n async webhook accepted; response body ignored')
|
||||
return
|
||||
|
||||
async for chunk in self._process_response(response):
|
||||
if is_stream:
|
||||
yield chunk
|
||||
|
||||
@@ -83,7 +83,7 @@ class VersionManager:
|
||||
try:
|
||||
if await self.is_new_version_available():
|
||||
return (
|
||||
'New version available. Update guide: https://link.langbot.app/en/docs/update',
|
||||
'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
|
||||
logging.INFO,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 4px 20px;">
|
||||
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<img src="https://langbot.app/docs/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
"header-name": "",
|
||||
"header-value": "",
|
||||
"timeout": 120,
|
||||
"output-key": "response"
|
||||
"output-key": "response",
|
||||
"response-handling": "reply"
|
||||
},
|
||||
"langflow-api": {
|
||||
"base-url": "http://localhost:7860",
|
||||
|
||||
@@ -475,6 +475,25 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: 'response'
|
||||
- name: response-handling
|
||||
label:
|
||||
en_US: Webhook Response Handling
|
||||
zh_Hans: Webhook 响应处理方式
|
||||
description:
|
||||
en_US: Choose whether LangBot forwards the n8n webhook response to the chat user. Ignore mode requires the n8n Webhook node to use Respond Immediately.
|
||||
zh_Hans: 选择是否将 n8n Webhook 响应转发给聊天用户。忽略模式要求 n8n Webhook 节点使用“立即响应”。
|
||||
type: select
|
||||
required: false
|
||||
default: 'reply'
|
||||
options:
|
||||
- name: reply
|
||||
label:
|
||||
en_US: Forward as chat reply
|
||||
zh_Hans: 转发为聊天回复
|
||||
- name: ignore
|
||||
label:
|
||||
en_US: Ignore response body (asynchronous workflow)
|
||||
zh_Hans: 忽略响应正文(异步工作流)
|
||||
- name: coze-api
|
||||
label:
|
||||
en_US: coze API
|
||||
|
||||
@@ -11,7 +11,6 @@ import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -71,7 +70,6 @@ async def space_oauth_api():
|
||||
application.space_service.get_oauth_authorize_url = Mock(
|
||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||
)
|
||||
application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta')
|
||||
application.space_service.exchange_oauth_code = AsyncMock(
|
||||
return_value={
|
||||
'access_token': 'space-access-token',
|
||||
@@ -129,7 +127,7 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oauth_api):
|
||||
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
|
||||
@@ -143,9 +141,9 @@ async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oau
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta')
|
||||
application.space_service.get_cloud_entry_url.assert_called_once_with()
|
||||
application.user_service.issue_space_oauth_state.assert_not_awaited()
|
||||
authorize_url = (await response.get_json())['data']['authorize_url']
|
||||
assert authorize_url.startswith('https://space.example/authorize?state=')
|
||||
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -272,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
|
||||
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
|
||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
|
||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
||||
json={
|
||||
'code': 'v4_oauth-code',
|
||||
'state': 'opaque-login-state',
|
||||
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||
},
|
||||
)
|
||||
|
||||
assert (await missing.get_json())['code'] == 1
|
||||
@@ -283,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||
'oauth-code',
|
||||
'v4_oauth-code',
|
||||
[WORKSPACE_UUID],
|
||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||
redirect_uri='https://oss.example/auth/space/callback',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'code contract' in payload['msg']
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
calls: list[str] = []
|
||||
application.directory_projection_service = SimpleNamespace(
|
||||
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
|
||||
)
|
||||
application.space_service.exchange_oauth_code.return_value = {
|
||||
'access_token': 'space-access-token',
|
||||
'refresh_token': 'space-refresh-token',
|
||||
'expires_in': 3600,
|
||||
'cloud_workspace_uuid': WORKSPACE_UUID,
|
||||
}
|
||||
|
||||
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
|
||||
|
||||
async def authenticate(*_args):
|
||||
calls.append('authenticate')
|
||||
return 'space-login-token', authenticated_account
|
||||
|
||||
application.user_service.authenticate_space_user.side_effect = authenticate
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert calls == ['reconcile', 'authenticate']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Cloud Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||
launch_workspace_uuid=WORKSPACE_UUID
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||
launch_workspace_uuid=WORKSPACE_UUID
|
||||
)
|
||||
application.space_service.exchange_oauth_code.return_value = {
|
||||
'access_token': 'space-access-token',
|
||||
'refresh_token': 'space-refresh-token',
|
||||
'expires_in': 3600,
|
||||
'cloud_workspace_uuid': 'workspace-from-another-flow',
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
payload = await response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert payload['code'] == 1
|
||||
assert 'Workspace binding' in payload['msg']
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -299,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -398,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
||||
|
||||
rejected = await client.post(
|
||||
'/api/v1/user/bind-space',
|
||||
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||
json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||
)
|
||||
response = await client.post(
|
||||
'/api/v1/user/bind-space',
|
||||
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
|
||||
json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'},
|
||||
)
|
||||
|
||||
assert rejected.status_code == 401
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
||||
application.user_service.verify_jwt_token.assert_not_awaited()
|
||||
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
|
||||
application.user_service.bind_space_account.assert_awaited_once_with(
|
||||
'owner@example.com',
|
||||
'v4_oauth-code',
|
||||
redirect_uri='http://localhost/auth/space/callback?mode=bind',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -417,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
application, client = space_oauth_api
|
||||
application.user_service.consume_space_oauth_state.reset_mock()
|
||||
application.space_service.exchange_oauth_code.reset_mock()
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
@@ -440,7 +581,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(space_oauth_api):
|
||||
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_account = SimpleNamespace(
|
||||
uuid='account-a',
|
||||
@@ -448,8 +589,8 @@ async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting
|
||||
account_type='space',
|
||||
status='active',
|
||||
)
|
||||
application.user_service.get_user_by_uuid = AsyncMock(side_effect=[None, None, projected_account])
|
||||
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock())
|
||||
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
@@ -461,61 +602,5 @@ async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert application.directory_projection_service.sync_once.await_count == 2
|
||||
assert application.user_service.get_user_by_uuid.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access]
|
||||
)
|
||||
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.directory_projection_service.sync_once.assert_awaited_once_with()
|
||||
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_falls_back_to_snapshot_when_event_backlog_exceeds_page_budget(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
side_effect=[
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
WorkspaceNotFoundError('Workspace not found'),
|
||||
projected_access,
|
||||
]
|
||||
)
|
||||
application.directory_projection_service = SimpleNamespace(
|
||||
sync_once=AsyncMock(),
|
||||
refresh_snapshot=AsyncMock(),
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert application.directory_projection_service.sync_once.await_count == 3
|
||||
application.directory_projection_service.refresh_snapshot.assert_awaited_once_with()
|
||||
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 5
|
||||
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import sqlalchemy
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
@@ -449,10 +450,58 @@ class TestBotServiceCreateBot:
|
||||
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
||||
insert_values = insert_statement.compile().params
|
||||
assert insert_values['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid'
|
||||
assert insert_values['use_pipeline_name'] == 'Default Pipeline'
|
||||
assert bot_uuid is not None # Verify UUID was returned
|
||||
|
||||
async def test_create_bot_rolls_back_insert_when_load_bot_fails(self):
|
||||
"""Deletes the inserted row when the adapter fails to load.
|
||||
|
||||
Regression: a failing adapter constructor (e.g. KeyError on a missing
|
||||
optional credential key) used to leave a permanently disabled orphan
|
||||
bot in the DB — the insert was already committed and the HTTP layer
|
||||
surfaced a 500 without any cleanup.
|
||||
"""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
ap.platform_mgr.load_bot = AsyncMock(side_effect=KeyError('token'))
|
||||
|
||||
pipeline_result = Mock()
|
||||
pipeline_result.first = Mock(return_value=None)
|
||||
bot_result = Mock()
|
||||
bot_result.first = Mock(return_value=_create_mock_bot())
|
||||
|
||||
executed_statements = []
|
||||
|
||||
async def mock_execute(query):
|
||||
executed_statements.append(query)
|
||||
if len(executed_statements) <= 2:
|
||||
return pipeline_result # 1: limitation bots query, 2: pipeline query
|
||||
if len(executed_statements) == 3:
|
||||
return Mock() # insert
|
||||
return bot_result # get_bot after insert
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'})
|
||||
|
||||
service = BotService(ap)
|
||||
|
||||
# Execute & Verify: the adapter error propagates
|
||||
with pytest.raises(KeyError, match='token'):
|
||||
await service.create_bot(
|
||||
WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
|
||||
)
|
||||
|
||||
# And the inserted row is rolled back via a DELETE on the new uuid
|
||||
# (no limitation query runs because max_bots=-1)
|
||||
assert len(executed_statements) == 4 # pipeline select, insert, bot select, delete
|
||||
delete_statement = executed_statements[-1]
|
||||
assert isinstance(delete_statement, sqlalchemy.sql.dml.Delete)
|
||||
compiled = delete_statement.compile()
|
||||
assert compiled.params['uuid_1'] is not None
|
||||
|
||||
|
||||
class TestBotServiceUpdateBot:
|
||||
"""Tests for update_bot method."""
|
||||
|
||||
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
|
||||
result = service.get_oauth_authorize_url('http://localhost/callback')
|
||||
|
||||
# Verify
|
||||
assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
|
||||
query = parse_qs(urlsplit(result).query)
|
||||
assert query['redirect_uri'] == ['http://localhost/callback']
|
||||
assert query['code_contract'] == ['redirect-v1']
|
||||
assert 'https://space.langbot.app/auth/authorize' in result
|
||||
|
||||
def test_get_oauth_authorize_url_with_state(self):
|
||||
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
|
||||
'auth_code',
|
||||
['workspace-1'],
|
||||
{'workspace-1': 1_700_000_000},
|
||||
redirect_uri='https://oss.example/auth/space/callback',
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result['access_token'] == 'new_access_token'
|
||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||
'code': 'auth_code',
|
||||
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||
'instance_id': constants.instance_id,
|
||||
'workspace_uuids': ['workspace-1'],
|
||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||
@@ -846,10 +850,7 @@ class TestSpaceServiceGetModelSelection:
|
||||
if response_shape == 'models-envelope':
|
||||
data = {'models': models}
|
||||
elif response_shape == 'availability-wrapper':
|
||||
data = [
|
||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||
for index, model in enumerate(models)
|
||||
]
|
||||
data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)]
|
||||
else:
|
||||
data = models
|
||||
payload = {'code': 0, 'data': data}
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
@@ -215,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
|
||||
request_sync.assert_called_once_with()
|
||||
|
||||
|
||||
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
|
||||
application, session_factory = projection_context
|
||||
provider = _Provider(
|
||||
[_snapshot(7, workspaces=[])],
|
||||
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||
)
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||
|
||||
async with session_factory() as session:
|
||||
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||
membership = await session.scalar(
|
||||
sqlalchemy.select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_uuid == WORKSPACE_UUID,
|
||||
WorkspaceMembership.account_uuid == ACCOUNT_UUID,
|
||||
)
|
||||
)
|
||||
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||
assert account is not None
|
||||
assert workspace is not None and workspace.name == 'JIT Workspace'
|
||||
assert membership is not None and membership.status == 'active'
|
||||
assert state is not None and state.cursor == 7
|
||||
assert provider.delta_calls == 1
|
||||
assert provider.after_cursors == []
|
||||
|
||||
|
||||
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
|
||||
application, session_factory = projection_context
|
||||
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
|
||||
update={
|
||||
'members': [
|
||||
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
|
||||
]
|
||||
}
|
||||
)
|
||||
provider = _Provider(
|
||||
[_snapshot(7)],
|
||||
deltas=[_delta(workspaces=[targeted_workspace])],
|
||||
)
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
|
||||
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||
|
||||
async with session_factory() as session:
|
||||
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||
assert account is not None and account.user == 'Workspace Owner'
|
||||
assert account.projection_revision == 7
|
||||
assert workspace is not None and workspace.name == 'Renamed Workspace'
|
||||
assert state is not None and state.cursor == 7
|
||||
|
||||
|
||||
async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context):
|
||||
application, _session_factory = projection_context
|
||||
provider = _Provider(
|
||||
[_snapshot(7, workspaces=[])],
|
||||
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||
)
|
||||
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||
await service.initialize()
|
||||
service._reconcile_entitlement_snapshot_set = AsyncMock()
|
||||
service._update_entitlement_workspace_activity = AsyncMock()
|
||||
service._publish_runtime_execution_projection = Mock()
|
||||
|
||||
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||
|
||||
service._reconcile_entitlement_snapshot_set.assert_not_awaited()
|
||||
service._update_entitlement_workspace_activity.assert_awaited_once()
|
||||
assert service._update_entitlement_workspace_activity.await_args.kwargs == {
|
||||
'requested_workspace_uuids': {WORKSPACE_UUID},
|
||||
}
|
||||
service._publish_runtime_execution_projection.assert_called_once()
|
||||
assert service._publish_runtime_execution_projection.call_args.kwargs == {
|
||||
'affected_workspace_uuids': {WORKSPACE_UUID},
|
||||
}
|
||||
|
||||
|
||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||
application, session_factory = projection_context
|
||||
reconcile_execution_projection = Mock()
|
||||
|
||||
@@ -55,7 +55,7 @@ finally:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
|
||||
def make_runner(output_key: str = 'response', response_handling: str = 'reply') -> N8nServiceAPIRunner:
|
||||
ap = Mock()
|
||||
ap.logger = Mock()
|
||||
pipeline_config = {
|
||||
@@ -63,6 +63,7 @@ def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
|
||||
'n8n-service-api': {
|
||||
'webhook-url': 'http://test-n8n/webhook',
|
||||
'output-key': output_key,
|
||||
'response-handling': response_handling,
|
||||
'auth-type': 'none',
|
||||
}
|
||||
}
|
||||
@@ -287,6 +288,7 @@ def make_http_session_mock(response_bytes: bytes, status: int = 200):
|
||||
"""Mock httpclient.get_session() returning a session whose post() yields response_bytes."""
|
||||
mock_response = make_mock_response([response_bytes], status=status)
|
||||
mock_response.status = status
|
||||
mock_response.headers = {}
|
||||
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
@@ -314,6 +316,91 @@ async def test_call_webhook_nonstream_adapter_plain_json():
|
||||
assert results[0].content == 'result text'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status', [200, 201, 202, 204])
|
||||
@pytest.mark.parametrize(
|
||||
'response_body',
|
||||
[
|
||||
b'{"message":"Workflow was started"}',
|
||||
b'{"response":"must not be forwarded"}',
|
||||
b'plain acknowledgement',
|
||||
],
|
||||
)
|
||||
async def test_call_webhook_ignore_response_body(response_body: bytes, status: int):
|
||||
"""Ignore mode accepts any HTTP 2xx response without emitting chat output."""
|
||||
runner = make_runner(response_handling='ignore')
|
||||
query = make_query(is_stream=False)
|
||||
http_session = make_http_session_mock(response_body, status=status)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
results = []
|
||||
async for message in runner._call_webhook(query):
|
||||
results.append(message)
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_ignore_releases_without_reading_response_body():
|
||||
"""Ignore mode returns after the success status without waiting for the body."""
|
||||
runner = make_runner(response_handling='ignore')
|
||||
query = make_query(is_stream=False)
|
||||
mock_response = make_mock_response([], status=202)
|
||||
mock_response.headers = {}
|
||||
mock_response.release = Mock()
|
||||
|
||||
async def fail_if_read(_size):
|
||||
raise AssertionError('ignore mode must not read the response body')
|
||||
yield b''
|
||||
|
||||
mock_response.content.iter_chunked = fail_if_read
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session = Mock()
|
||||
mock_session.post = Mock(return_value=mock_cm)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=mock_session):
|
||||
results = [message async for message in runner._call_webhook(query)]
|
||||
|
||||
assert results == []
|
||||
mock_response.release.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status', [201, 202, 204])
|
||||
async def test_call_webhook_reply_mode_preserves_http_200_contract(status: int):
|
||||
"""Reply mode remains backward compatible and rejects non-200 statuses."""
|
||||
runner = make_runner(response_handling='reply')
|
||||
query = make_query(is_stream=False)
|
||||
http_session = make_http_session_mock(b'', status=status)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
with pytest.raises(N8nAPIError, match=f'n8n webhook call failed: {status}'):
|
||||
async for _ in runner._call_webhook(query):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_ignore_mode_preserves_http_error():
|
||||
"""Ignore mode must not swallow a failed n8n webhook response."""
|
||||
runner = make_runner(response_handling='ignore')
|
||||
query = make_query(is_stream=False)
|
||||
http_session = make_http_session_mock(b'{"error":"unavailable"}', status=500)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
with pytest.raises(N8nAPIError, match='n8n webhook call exception'):
|
||||
async for _ in runner._call_webhook(query):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_response_handling_is_rejected():
|
||||
"""Configuration errors should fail fast instead of silently changing reply behavior."""
|
||||
with pytest.raises(ValueError, match='Invalid n8n response-handling'):
|
||||
make_runner(response_handling='unexpected')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_stream_adapter_stream_format():
|
||||
"""Stream adapter + stream format → MessageChunks, last is_final."""
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for WecomAdapter.send_message content-key handling."""
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.sources.wecom import WecomAdapter
|
||||
|
||||
|
||||
class StubWecomClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def get_media_id(self, msg):
|
||||
return 'MEDIA_ID_123'
|
||||
|
||||
async def send_private_msg(self, user_id, agent_id, text):
|
||||
self.calls.append(('text', user_id, agent_id, text))
|
||||
|
||||
async def send_image(self, user_id, agent_id, media_id):
|
||||
self.calls.append(('image', user_id, agent_id, media_id))
|
||||
|
||||
async def send_voice(self, user_id, agent_id, media_id):
|
||||
self.calls.append(('voice', user_id, agent_id, media_id))
|
||||
|
||||
async def send_file(self, user_id, agent_id, media_id):
|
||||
self.calls.append(('file', user_id, agent_id, media_id))
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
adapter = WecomAdapter.model_construct(bot=StubWecomClient())
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('part', 'expected_type'),
|
||||
[
|
||||
(platform_message.Image(url='https://example.com/x.jpg'), 'image'),
|
||||
(platform_message.Voice(url='https://example.com/x.amr'), 'voice'),
|
||||
(platform_message.File(url='https://example.com/x.pdf', name='x.pdf'), 'file'),
|
||||
],
|
||||
)
|
||||
async def test_send_message_dispatches_media_by_id(part, expected_type):
|
||||
adapter = _make_adapter()
|
||||
chain = platform_message.MessageChain([part])
|
||||
|
||||
await adapter.send_message('person', 'USER1|1000001', chain)
|
||||
|
||||
assert adapter.bot.calls == [(expected_type, 'USER1', 1000001, 'MEDIA_ID_123')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_text_still_works():
|
||||
adapter = _make_adapter()
|
||||
chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
|
||||
await adapter.send_message('person', 'USER1|1000001', chain)
|
||||
|
||||
assert adapter.bot.calls == [('text', 'USER1', 1000001, 'hello')]
|
||||
@@ -113,7 +113,7 @@ async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_lin
|
||||
|
||||
html = service._html("RockChinQ's Workspace", link)
|
||||
|
||||
assert 'https://docs.langbot.app/langbot-logo.png' in html
|
||||
assert 'https://langbot.app/docs/langbot-logo.png' in html
|
||||
assert '>LangBot<' in html
|
||||
assert 'Workspace invitation' in html
|
||||
assert 'Open invitation link' in html
|
||||
|
||||
@@ -2008,7 +2008,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.10.9"
|
||||
version = "4.10.10"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -2129,7 +2129,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.6" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.7" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2196,7 +2196,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.6"
|
||||
version = "0.5.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2217,9 +2217,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Debug LangBot Frontend
|
||||
|
||||
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
|
||||
Please refer to the [Development Guide](https://langbot.app/docs/en/develop/dev-config) for more information.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -43,17 +43,24 @@ const pendingSpaceOAuthLogins = new Map<
|
||||
function getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode: string,
|
||||
state: string,
|
||||
redirectUri: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<SpaceOAuthLoginResult> {
|
||||
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
||||
const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
||||
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
const requestPromise = httpClient
|
||||
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
|
||||
.exchangeSpaceOAuthCode(
|
||||
authCode,
|
||||
state,
|
||||
redirectUri,
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
)
|
||||
.finally(() => {
|
||||
pendingSpaceOAuthLogins.delete(requestKey);
|
||||
});
|
||||
@@ -95,6 +102,7 @@ function SpaceOAuthCallbackContent() {
|
||||
const response = await getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode,
|
||||
state,
|
||||
`${window.location.origin}/auth/space/callback`,
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
);
|
||||
@@ -195,7 +203,11 @@ function SpaceOAuthCallbackContent() {
|
||||
async (authCode: string, state: string) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await httpClient.bindSpaceAccount(authCode, state);
|
||||
const response = await httpClient.bindSpaceAccount(
|
||||
authCode,
|
||||
state,
|
||||
`${window.location.origin}/auth/space/callback?mode=bind`,
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const COMMON_N8N_CONFIG_FIELDS = new Set([
|
||||
'webhook-url',
|
||||
'auth-type',
|
||||
'timeout',
|
||||
'output-key',
|
||||
'response-handling',
|
||||
]);
|
||||
|
||||
export function shouldShowN8nConfigField(
|
||||
fieldName: string,
|
||||
authType: string,
|
||||
): boolean {
|
||||
if (COMMON_N8N_CONFIG_FIELDS.has(fieldName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(authType === 'basic' && fieldName.startsWith('basic-')) ||
|
||||
(authType === 'jwt' && fieldName.startsWith('jwt-')) ||
|
||||
(authType === 'header' && fieldName.startsWith('header-'))
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import { shouldShowN8nConfigField } from '@/app/home/components/dynamic-form/N8nAuthFieldVisibility';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -181,29 +182,9 @@ export default function N8nAuthFormComponent({
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
// 根据认证类型过滤表单项
|
||||
const filteredConfigList = itemConfigList.filter((config) => {
|
||||
// 始终显示webhook-url、auth-type、timeout和output-key
|
||||
if (
|
||||
['webhook-url', 'auth-type', 'timeout', 'output-key'].includes(
|
||||
config.name,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据认证类型显示相应的表单项
|
||||
if (authType === 'basic' && config.name.startsWith('basic-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'jwt' && config.name.startsWith('jwt-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'header' && config.name.startsWith('header-')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const filteredConfigList = itemConfigList.filter((config) =>
|
||||
shouldShowN8nConfigField(config.name, authType),
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
|
||||
@@ -2195,12 +2195,12 @@ export default function HomeSidebar({
|
||||
localStorage.getItem('langbot_language');
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
window.open(
|
||||
'https://link.langbot.app/zh/docs/guide',
|
||||
'https://langbot.app/docs/zh/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
'https://link.langbot.app/en/docs/guide',
|
||||
'https://langbot.app/docs/en/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/bots',
|
||||
description: t('bots.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/platforms',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/platforms',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/platforms',
|
||||
en_US: 'https://langbot.app/docs/en/usage/platforms/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/platforms/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/platforms/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -62,9 +62,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/pipelines',
|
||||
description: t('pipelines.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/pipelines',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/pipelines',
|
||||
en_US: 'https://langbot.app/docs/en/usage/pipelines/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/pipelines/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/pipelines/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -75,9 +75,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/knowledge',
|
||||
description: t('knowledge.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/knowledge',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/knowledge',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/knowledge',
|
||||
en_US: 'https://langbot.app/docs/en/usage/knowledge/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/knowledge/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/knowledge/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -89,9 +89,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/extensions',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
|
||||
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
@@ -102,9 +102,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/add-extension',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
|
||||
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
|
||||
@@ -34,11 +34,11 @@ export default function NewVersionDialog({
|
||||
const getUpdateDocsUrl = () => {
|
||||
const language = i18n.language;
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
return 'https://link.langbot.app/zh/docs/update';
|
||||
return 'https://langbot.app/docs/zh/deploy/update';
|
||||
} else if (language === 'ja-JP') {
|
||||
return 'https://link.langbot.app/ja/docs/update';
|
||||
return 'https://langbot.app/docs/ja/deploy/update';
|
||||
} else {
|
||||
return 'https://link.langbot.app/en/docs/update';
|
||||
return 'https://langbot.app/docs/en/deploy/update';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1365,6 +1365,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
public async bindSpaceAccount(
|
||||
code: string,
|
||||
state: string,
|
||||
redirectUri: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
user: string;
|
||||
@@ -1372,7 +1373,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const response = await this.instance.post(
|
||||
'/api/v1/user/bind-space',
|
||||
{ code, state },
|
||||
{ code, state, redirect_uri: redirectUri },
|
||||
{ skipWorkspace: true } as RequestConfig,
|
||||
);
|
||||
if (response.data.code !== 0) {
|
||||
@@ -1385,18 +1386,12 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
// ============ Space OAuth API (Redirect Flow) ============
|
||||
public getSpaceAuthorizeUrl(
|
||||
redirectUri: string,
|
||||
options?: { cloudEntry?: boolean },
|
||||
): Promise<{
|
||||
public getSpaceAuthorizeUrl(redirectUri: string): Promise<{
|
||||
authorize_url: string;
|
||||
}> {
|
||||
return this.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
{
|
||||
redirect_uri: redirectUri,
|
||||
...(options?.cloudEntry ? { cloud_entry: '1' } : {}),
|
||||
},
|
||||
{ redirect_uri: redirectUri },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
@@ -1414,6 +1409,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
public async exchangeSpaceOAuthCode(
|
||||
code: string,
|
||||
state: string,
|
||||
redirectUri: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<{
|
||||
@@ -1428,6 +1424,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
{
|
||||
code,
|
||||
state,
|
||||
redirect_uri: redirectUri,
|
||||
workspace_uuid: workspaceUuid,
|
||||
launch_assertion: launchAssertion,
|
||||
},
|
||||
|
||||
@@ -202,13 +202,7 @@ export default function Login() {
|
||||
try {
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback`;
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri, {
|
||||
// Cloud Accounts must be launched from Space so a first visit can
|
||||
// lazily create and project the personal Workspace. Invitation login
|
||||
// remains on the OAuth callback path because it targets the invited
|
||||
// Workspace instead.
|
||||
cloudEntry: !getPendingInvitationToken(),
|
||||
});
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
|
||||
@@ -76,7 +76,8 @@ const enUS = {
|
||||
privacyPolicy: 'Privacy Policy',
|
||||
and: 'and',
|
||||
dataCollectionPolicy: 'Data Collection Policy',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'Loading...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
|
||||
@@ -79,7 +79,8 @@ const esES = {
|
||||
privacyPolicy: 'Política de privacidad',
|
||||
and: 'y',
|
||||
dataCollectionPolicy: 'Política de recopilación de datos',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'Cargando...',
|
||||
fieldRequired: 'Este campo es obligatorio',
|
||||
or: 'o',
|
||||
|
||||
@@ -77,7 +77,8 @@ const jaJP = {
|
||||
privacyPolicy: 'プライバシーポリシー',
|
||||
and: 'および',
|
||||
dataCollectionPolicy: 'データ収集ポリシー',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/ja/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/ja/insight/data-collection-policy',
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
|
||||
@@ -76,7 +76,8 @@ const ruRU = {
|
||||
privacyPolicy: 'Политикой конфиденциальности',
|
||||
and: 'и',
|
||||
dataCollectionPolicy: 'Политикой сбора данных',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'Загрузка...',
|
||||
fieldRequired: 'Это поле обязательно для заполнения',
|
||||
or: 'или',
|
||||
|
||||
@@ -76,7 +76,8 @@ const thTH = {
|
||||
privacyPolicy: 'นโยบายความเป็นส่วนตัว',
|
||||
and: 'และ',
|
||||
dataCollectionPolicy: 'นโยบายการเก็บรวบรวมข้อมูล',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'กำลังโหลด...',
|
||||
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
|
||||
or: 'หรือ',
|
||||
|
||||
@@ -77,7 +77,8 @@ const viVN = {
|
||||
privacyPolicy: 'Chính sách bảo mật',
|
||||
and: 'và',
|
||||
dataCollectionPolicy: 'Chính sách thu thập dữ liệu',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'Đang tải...',
|
||||
fieldRequired: 'Trường này là bắt buộc',
|
||||
or: 'hoặc',
|
||||
|
||||
@@ -75,7 +75,8 @@ const zhHans = {
|
||||
privacyPolicy: '隐私政策',
|
||||
and: '和',
|
||||
dataCollectionPolicy: '数据收集政策',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/zh/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/zh/insight/data-collection-policy',
|
||||
loading: '加载中...',
|
||||
fieldRequired: '此字段为必填项',
|
||||
or: '或',
|
||||
|
||||
@@ -75,7 +75,8 @@ const zhHant = {
|
||||
privacyPolicy: '隱私政策',
|
||||
and: '和',
|
||||
dataCollectionPolicy: '數據收集政策',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/zh/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/zh/insight/data-collection-policy',
|
||||
loading: '載入中...',
|
||||
fieldRequired: '此欄位為必填',
|
||||
or: '或',
|
||||
|
||||
@@ -7,11 +7,13 @@ const source = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('normal Cloud login enters through the Space Workspace launcher', () => {
|
||||
assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/);
|
||||
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri,\s*\{/);
|
||||
test('normal Cloud login uses the standard Space OAuth callback path', () => {
|
||||
assert.doesNotMatch(source, /cloudEntry/);
|
||||
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
|
||||
});
|
||||
|
||||
test('invitation login remains on the OAuth callback path', () => {
|
||||
assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/);
|
||||
test('invitation login uses the same OAuth callback before accepting the invitation', () => {
|
||||
assert.doesNotMatch(source, /cloudEntry/);
|
||||
assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/);
|
||||
assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/components/dynamic-form/N8nAuthFieldVisibility.ts',
|
||||
);
|
||||
|
||||
function loadVisibilityPolicy() {
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('N8nAuthFieldVisibility must not have runtime imports');
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports;
|
||||
}
|
||||
|
||||
test('shows response handling with the other common n8n fields', () => {
|
||||
const { shouldShowN8nConfigField } = loadVisibilityPolicy();
|
||||
|
||||
for (const field of [
|
||||
'webhook-url',
|
||||
'auth-type',
|
||||
'timeout',
|
||||
'output-key',
|
||||
'response-handling',
|
||||
]) {
|
||||
assert.equal(shouldShowN8nConfigField(field, 'none'), true, field);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows only fields for the selected n8n authentication method', () => {
|
||||
const { shouldShowN8nConfigField } = loadVisibilityPolicy();
|
||||
|
||||
assert.equal(shouldShowN8nConfigField('basic-username', 'basic'), true);
|
||||
assert.equal(shouldShowN8nConfigField('basic-password', 'jwt'), false);
|
||||
assert.equal(shouldShowN8nConfigField('jwt-secret', 'jwt'), true);
|
||||
assert.equal(shouldShowN8nConfigField('header-name', 'header'), true);
|
||||
assert.equal(shouldShowN8nConfigField('unrelated-field', 'none'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user