mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-07 18:17:14 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc32eb3ca0 | |||
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a | |||
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 | |||
| 7aab0cee07 | |||
| 1b7ae791b3 | |||
| e69a80f5e9 | |||
| bafdaf0033 | |||
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da |
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
@@ -22,7 +22,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Bug report
|
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]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
|
|||||||
## Where to Look
|
## Where to Look
|
||||||
|
|
||||||
- Architecture map: `ARCHITECTURE.md`.
|
- Architecture map: `ARCHITECTURE.md`.
|
||||||
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
|
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
|
||||||
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
|
||||||
- API-key auth: `docs/API_KEY_AUTH.md`.
|
- API-key auth: `docs/API_KEY_AUTH.md`.
|
||||||
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
|
- 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.
|
- 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)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Website</a> |
|
<a href="https://langbot.app">Website</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Features</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</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/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</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.
|
- **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.
|
- **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/).
|
📍 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://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 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)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官网</a> |
|
<a href="https://langbot.app">官网</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</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/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">扩展市场</a> |
|
<a href="https://space.langbot.app">扩展市场</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **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/)。
|
📍 实践指南:[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://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Inicio</a> |
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Características</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</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://space.langbot.app">Mercado de Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</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.
|
- **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.
|
- **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/).
|
📍 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://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 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)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Accueil</a> |
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</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://space.langbot.app">Marché des Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</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.
|
- **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.
|
- **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/).
|
📍 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://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
| [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)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
<a href="https://langbot.app/docs/ja/insight/features">機能</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> |
|
||||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
<a href="https://langbot.app/docs/ja/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
- **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/)。
|
📍 実践ガイド: [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://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
| [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)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">홈</a> |
|
<a href="https://langbot.app">홈</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
<a href="https://langbot.app/docs/en/insight/features">기능</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">문서</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. 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/).
|
📍 실전 가이드: [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://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
| [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)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Главная</a> |
|
<a href="https://langbot.app">Главная</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование 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/).
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 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://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
| [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)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官網</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> |
|
||||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">外掛市場</a> |
|
<a href="https://space.langbot.app">外掛市場</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
- **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/)。
|
📍 實踐指南:[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://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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://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)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Trang chủ</a> |
|
<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://langbot.app/docs/en/insight/features">Tính năng</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> |
|
||||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</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.
|
- **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ệ.
|
- **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/).
|
📍 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://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](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 | ✅ |
|
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 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
|
# 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"
|
version: "3"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Kubernetes Deployment for LangBot
|
# Kubernetes Deployment for LangBot
|
||||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
# 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:
|
# Usage:
|
||||||
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ Each endpoint accepts **either**:
|
|||||||
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
|
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
|
||||||
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
|
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
|
||||||
|
|
||||||
|
### Inspecting API Key Identity
|
||||||
|
|
||||||
|
`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"instance_uuid": "...",
|
||||||
|
"workspace_uuid": "...",
|
||||||
|
"api_key_id": "...",
|
||||||
|
"permissions": ["..."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Example: Model Management
|
## Example: Model Management
|
||||||
|
|
||||||
### List All LLM Models
|
### List All LLM Models
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
categories: [popular, global]
|
categories: [popular, global]
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||||
en: https://docs.langbot.app/en/platforms/http-bot
|
en: https://langbot.app/docs/en/platforms/http-bot
|
||||||
config:
|
config:
|
||||||
- { name: inbound_secret, type: string, required: true, default: "" }
|
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||||
- { name: callback_url, type: string, required: false, default: "" }
|
- { name: callback_url, type: string, required: false, default: "" }
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ For large datasets:
|
|||||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||||
- OceanBase Documentation: https://oceanbase.ai
|
- OceanBase Documentation: https://oceanbase.ai
|
||||||
- LangBot Documentation: https://docs.langbot.app
|
- LangBot Documentation: https://langbot.app/docs
|
||||||
|
|
||||||
## License
|
## 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
|
They show the whole loop: signing a request, pushing a message, and receiving
|
||||||
multi-part replies on a callback endpoint.
|
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).
|
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||||
1→M 的多段回复。
|
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)。
|
机器可读的接口契约:[`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
|
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||||
a single `<script>` tag.
|
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
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
(`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]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.8"
|
version = "4.10.10"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"langbot-plugin==0.5.5",
|
"langbot-plugin==0.5.7",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -114,7 +114,7 @@ seekdb = [
|
|||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://langbot.app/docs"
|
||||||
Repository = "https://github.com/langbot-app/LangBot"
|
Repository = "https://github.com/langbot-app/LangBot"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -1349,7 +1349,8 @@
|
|||||||
"local-agent",
|
"local-agent",
|
||||||
"tools",
|
"tools",
|
||||||
"e2b",
|
"e2b",
|
||||||
"nsjail"
|
"nsjail",
|
||||||
|
"host"
|
||||||
],
|
],
|
||||||
"automation": "",
|
"automation": "",
|
||||||
"setup_automation": [],
|
"setup_automation": [],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
|||||||
## Kubernetes
|
## Kubernetes
|
||||||
|
|
||||||
See `docker/kubernetes.yaml` and the deployment guide at
|
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)
|
## config.yaml (generated at `data/config.yaml` on first run)
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Key settings:
|
|||||||
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
||||||
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
||||||
| `box.enabled` | Master switch for the Box sandbox runtime |
|
| `box.enabled` | Master switch for the Box sandbox runtime |
|
||||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
|
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
|
||||||
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
||||||
|
|
||||||
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||||
@@ -75,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
|||||||
with `--standalone-runtime`.
|
with `--standalone-runtime`.
|
||||||
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
||||||
`langbot_box:5410`.
|
`langbot_box:5410`.
|
||||||
|
- `box.backend: host` runs commands directly as the Box Runtime system user.
|
||||||
|
It is never auto-selected, provides no sandbox isolation, and is only for
|
||||||
|
trusted local development. A WebSocket-controlled host backend requires
|
||||||
|
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
|
||||||
|
|
||||||
## Global API key — enabling for agents/automation
|
## Global API key — enabling for agents/automation
|
||||||
|
|
||||||
@@ -93,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
|
|||||||
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
||||||
usually means the user isn't in the `docker` group →
|
usually means the user isn't in the `docker` group →
|
||||||
`sudo usermod -aG docker <user>` and restart in a new shell.
|
`sudo usermod -aG docker <user>` and restart in a new shell.
|
||||||
|
- Do not use `box.backend: host` as a production fallback. It cannot enforce
|
||||||
|
image, filesystem, network, PID, CPU, memory, or storage isolation.
|
||||||
- Box root host/container path mismatch breaks sandbox container creation.
|
- Box root host/container path mismatch breaks sandbox container creation.
|
||||||
- Don't commit a non-empty `api.global_api_key` to version control.
|
- Don't commit a non-empty `api.global_api_key` to version control.
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Two kinds of key are accepted:
|
|||||||
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
|
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
|
||||||
scopes do not authorize a tool gets `403 Forbidden`.
|
scopes do not authorize a tool gets `403 Forbidden`.
|
||||||
|
|
||||||
|
To inspect key identity and permissions, call `GET /api/v1/system/context` with the API key.
|
||||||
|
|
||||||
## Client configuration
|
## Client configuration
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -75,6 +77,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
|||||||
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||||
|
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||||
|
already have a default pipeline.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ tags:
|
|||||||
- tools
|
- tools
|
||||||
- e2b
|
- e2b
|
||||||
- nsjail
|
- nsjail
|
||||||
|
- host
|
||||||
skills:
|
skills:
|
||||||
- langbot-env-setup
|
- langbot-env-setup
|
||||||
- langbot-testing
|
- langbot-testing
|
||||||
@@ -23,7 +24,7 @@ env:
|
|||||||
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
||||||
preconditions:
|
preconditions:
|
||||||
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
||||||
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
|
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
|
||||||
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
||||||
steps:
|
steps:
|
||||||
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
||||||
@@ -33,7 +34,7 @@ steps:
|
|||||||
checks:
|
checks:
|
||||||
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
||||||
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
||||||
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
|
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
|
||||||
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
||||||
- "Box status: recent_error_count is 0 after the run."
|
- "Box status: recent_error_count is 0 after the run."
|
||||||
evidence_required:
|
evidence_required:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
||||||
|
|
||||||
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag
|
|||||||
- `BOX_BACKEND=e2b` when validating E2B.
|
- `BOX_BACKEND=e2b` when validating E2B.
|
||||||
- `BOX_BACKEND=nsjail` when validating nsjail.
|
- `BOX_BACKEND=nsjail` when validating nsjail.
|
||||||
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
||||||
|
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
|
||||||
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
||||||
4. Confirm Debug Chat uses a model with function-calling ability.
|
4. Confirm Debug Chat uses a model with function-calling ability.
|
||||||
5. Confirm backend logs say native sandbox tools are available.
|
5. Confirm backend logs say native sandbox tools are available.
|
||||||
@@ -71,7 +72,7 @@ Backend logs should show:
|
|||||||
- `register_skill`
|
- `register_skill`
|
||||||
- `activate`
|
- `activate`
|
||||||
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
||||||
- `backend=e2b`, `backend=nsjail`, or the expected local backend
|
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
|
||||||
|
|
||||||
After the run, verify the skill store through the UI or API:
|
After the run, verify the skill store through the UI or API:
|
||||||
|
|
||||||
@@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
|
|||||||
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
||||||
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
||||||
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
||||||
|
- The host backend does not honor sandbox image, network, rootfs, process, or
|
||||||
|
resource isolation. Use a disposable workspace and low-privilege account.
|
||||||
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
||||||
|
|
||||||
## Related Troubleshooting
|
## Related Troubleshooting
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
|
|||||||
date: 2026-05-18
|
date: 2026-05-18
|
||||||
symptoms:
|
symptoms:
|
||||||
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
||||||
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
|
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
|
||||||
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
||||||
patterns:
|
patterns:
|
||||||
- "Native sandbox tools ... are NOT available"
|
- "Native sandbox tools ... are NOT available"
|
||||||
@@ -19,6 +19,7 @@ fix_steps:
|
|||||||
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
||||||
- "For E2B, verify the key without printing it and confirm any required template setting."
|
- "For E2B, verify the key without printing it and confirm any required template setting."
|
||||||
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
||||||
|
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
|
||||||
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
||||||
related_cases:
|
related_cases:
|
||||||
- sandbox-skill-authoring-e2e
|
- sandbox-skill-authoring-e2e
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ asciiart = r"""
|
|||||||
|___/
|
|___/
|
||||||
|
|
||||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||||
📖 Documentation 文档地址: https://docs.langbot.app
|
📖 Documentation 文档地址: https://langbot.app/docs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
|
||||||
import typing
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import typing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from .errors import DifyAPIError
|
from .errors import DifyAPIError
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
|
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
|
||||||
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
|
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
|
||||||
@@ -15,6 +16,32 @@ _MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
|
|||||||
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
|
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_sse_data(line: bytes) -> dict[str, typing.Any] | None:
|
||||||
|
data = line[5:].strip()
|
||||||
|
if not data or data == b'[DONE]':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(data.decode('utf-8'))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise DifyAPIError('Dify SSE data line is not valid JSON') from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise DifyAPIError('Dify SSE event is not a JSON object')
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_upload_response(body: bytes) -> dict[str, typing.Any]:
|
||||||
|
try:
|
||||||
|
response = json.loads(body)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise DifyAPIError('Dify upload response is not valid JSON') from exc
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise DifyAPIError('Dify upload response is not a JSON object')
|
||||||
|
payload = response.get('data', response)
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']:
|
||||||
|
raise DifyAPIError('Dify upload response does not contain a valid file id')
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
async def _read_limited_response(
|
async def _read_limited_response(
|
||||||
response: httpx.Response,
|
response: httpx.Response,
|
||||||
*,
|
*,
|
||||||
@@ -56,16 +83,16 @@ async def _iter_sse_json(
|
|||||||
line = raw_line.rstrip(b'\r').strip()
|
line = raw_line.rstrip(b'\r').strip()
|
||||||
if not line or not line.startswith(b'data:'):
|
if not line or not line.startswith(b'data:'):
|
||||||
continue
|
continue
|
||||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
payload = _decode_sse_data(line)
|
||||||
if isinstance(payload, dict):
|
if payload is not None:
|
||||||
yield payload
|
yield payload
|
||||||
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
|
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
|
||||||
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
|
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
|
||||||
|
|
||||||
line = bytes(buffer).rstrip(b'\r').strip()
|
line = bytes(buffer).rstrip(b'\r').strip()
|
||||||
if line.startswith(b'data:'):
|
if line.startswith(b'data:'):
|
||||||
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
|
payload = _decode_sse_data(line)
|
||||||
if isinstance(payload, dict):
|
if payload is not None:
|
||||||
yield payload
|
yield payload
|
||||||
|
|
||||||
|
|
||||||
@@ -242,7 +269,7 @@ class AsyncDifyServiceClient:
|
|||||||
file: httpx._types.FileTypes,
|
file: httpx._types.FileTypes,
|
||||||
user: str,
|
user: str,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
) -> str:
|
) -> dict[str, typing.Any]:
|
||||||
# 处理 Path 对象
|
# 处理 Path 对象
|
||||||
if isinstance(file, Path):
|
if isinstance(file, Path):
|
||||||
if not file.exists():
|
if not file.exists():
|
||||||
@@ -271,6 +298,6 @@ class AsyncDifyServiceClient:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
) as response:
|
) as response:
|
||||||
body = await _read_limited_response(response)
|
body = await _read_limited_response(response)
|
||||||
if response.status_code != 201:
|
if response.status_code not in (200, 201):
|
||||||
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
|
||||||
return json.loads(body)
|
return _decode_upload_response(body)
|
||||||
|
|||||||
@@ -697,9 +697,10 @@ class DingTalkClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_access_token()
|
await self.get_access_token()
|
||||||
|
|
||||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
template_params = dict(card_param_map or {})
|
||||||
if card_data_config is not None:
|
if card_data_config is not None:
|
||||||
cardData['config'] = json.dumps(card_data_config)
|
template_params['config'] = card_data_config
|
||||||
|
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||||
|
|
||||||
body: dict = {
|
body: dict = {
|
||||||
'cardTemplateId': card_template_id,
|
'cardTemplateId': card_template_id,
|
||||||
|
|||||||
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
|||||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def _send_markdown_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown message to a C2C user or QQ group."""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported Markdown target type: {target_type}')
|
||||||
|
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
'msg_type': 2,
|
||||||
|
'markdown': {'content': content},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if msg_id:
|
||||||
|
data['msg_id'] = msg_id
|
||||||
|
if event_id:
|
||||||
|
data['event_id'] = event_id
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
if response.status_code != 200:
|
||||||
|
response_data = await httpclient.parse_json_response(response)
|
||||||
|
await self.logger.error(f'Failed to send Markdown message: {response_data}')
|
||||||
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def send_private_markdown_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown C2C message."""
|
||||||
|
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
|
async def send_group_markdown_msg(
|
||||||
|
self,
|
||||||
|
group_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown QQ group message."""
|
||||||
|
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
async def send_group_text_msg(
|
async def send_group_text_msg(
|
||||||
self,
|
self,
|
||||||
group_openid: str,
|
group_openid: str,
|
||||||
|
|||||||
@@ -936,6 +936,13 @@ class WecomBotWsClient:
|
|||||||
'chat_type': message_data.get('type', 'single'),
|
'chat_type': message_data.get('type', 'single'),
|
||||||
}
|
}
|
||||||
self._prune_stream_state()
|
self._prune_stream_state()
|
||||||
|
# Send an initial empty stream frame so the WeCom client
|
||||||
|
# shows its built-in loading spinner while the pipeline
|
||||||
|
# processes the message (e.g. RAG retrieval).
|
||||||
|
try:
|
||||||
|
await self.reply_stream(req_id, stream_id, '', finish=False)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
|
||||||
message_data['stream_id'] = stream_id
|
message_data['stream_id'] = stream_id
|
||||||
message_data['req_id'] = req_id
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,34 @@ class WecomCSClient:
|
|||||||
raise Exception('Failed to send message')
|
raise Exception('Failed to send message')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@_bounded_token_retry
|
||||||
|
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||||
|
payload = {
|
||||||
|
'touser': external_userid,
|
||||||
|
'open_kfid': open_kfid,
|
||||||
|
'msgid': msgid,
|
||||||
|
'msgtype': 'image',
|
||||||
|
'image': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
|
||||||
|
data = await httpclient.parse_json_response(response)
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送图片失败:{data}')
|
||||||
|
raise Exception('Failed to send image message')
|
||||||
|
return data
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
return await self._handle_callback_internal(request)
|
return await self._handle_callback_internal(request)
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
start_time_str = quart.request.args.get('startTime')
|
start_time_str = quart.request.args.get('startTime')
|
||||||
end_time_str = quart.request.args.get('endTime')
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
user_query = quart.request.args.get('userQuery')
|
||||||
is_active_str = quart.request.args.get('isActive')
|
is_active_str = quart.request.args.get('isActive')
|
||||||
limit = int(quart.request.args.get('limit', 100))
|
limit = int(quart.request.args.get('limit', 100))
|
||||||
offset = int(quart.request.args.get('offset', 0))
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
|
user_query=user_query,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||||
"""Get detailed analysis for a specific session"""
|
"""Get detailed analysis for a specific session"""
|
||||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||||
|
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||||
|
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||||
|
request_context,
|
||||||
|
session_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
# Always return success with the analysis data
|
# Always return success with the analysis data
|
||||||
# The frontend will handle the 'found: false' case
|
# The frontend will handle the 'found: false' case
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
pipeline_data = await quart.request.json
|
||||||
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||||
|
request_context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
return self.success(data={'uuid': pipeline_uuid})
|
return self.success(data={'uuid': pipeline_uuid})
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return self.success(data={'sent': True})
|
return self.success(data={'sent': True})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<bot_uuid>/test-inbound',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
|
json_data = await quart.request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||||
|
request_context,
|
||||||
|
bot_uuid,
|
||||||
|
str(json_data.get('message') or ''),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<bot_uuid>/admins',
|
'/<bot_uuid>/admins',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ from .....workspace.invitation_delivery import InvitationDeliveryService
|
|||||||
@group.group_class('system', '/api/v1/system')
|
@group.group_class('system', '/api/v1/system')
|
||||||
class SystemRouterGroup(group.RouterGroup):
|
class SystemRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'instance_uuid': request_context.instance_uuid,
|
||||||
|
'workspace_uuid': request_context.workspace_uuid,
|
||||||
|
'api_key_id': request_context.principal.api_key_uuid,
|
||||||
|
'permissions': sorted(request_context.workspace.permissions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
# Read wizard_status and wizard_progress from metadata table
|
# Read wizard_status and wizard_progress from metadata table
|
||||||
@@ -206,6 +217,20 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/wizard/recommended-model',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
"""Resolve Space's best available chat model to this Workspace."""
|
||||||
|
try:
|
||||||
|
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(503, -1, str(exc))
|
||||||
|
return self.success(data=model)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/tasks',
|
'/tasks',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
code = json_data.get('code')
|
code = json_data.get('code')
|
||||||
state = json_data.get('state')
|
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')
|
launch_assertion = json_data.get('launch_assertion')
|
||||||
workspace_uuid = json_data.get('workspace_uuid')
|
workspace_uuid = json_data.get('workspace_uuid')
|
||||||
|
|
||||||
@@ -199,8 +202,11 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(1, 'Missing authorization code')
|
return self.fail(1, 'Missing authorization code')
|
||||||
if not state:
|
if not state:
|
||||||
return self.fail(1, 'Missing state parameter')
|
return self.fail(1, 'Missing state parameter')
|
||||||
|
if not str(code).startswith('v4_'):
|
||||||
|
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||||
|
|
||||||
try:
|
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')
|
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||||
@@ -218,24 +224,36 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
code,
|
code,
|
||||||
workspace_uuids,
|
workspace_uuids,
|
||||||
workspace_created_ats,
|
workspace_created_ats,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
return self.fail(1, 'Failed to get access token from Space')
|
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(
|
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||||
access_token, refresh_token, expires_in
|
access_token, refresh_token, expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
if launch_workspace_uuid:
|
if target_workspace_uuid:
|
||||||
try:
|
try:
|
||||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||||
user_obj.uuid,
|
user_obj.uuid,
|
||||||
launch_workspace_uuid,
|
target_workspace_uuid,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||||
@@ -367,12 +385,17 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
code = json_data.get('code')
|
code = json_data.get('code')
|
||||||
state = json_data.get('state')
|
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:
|
if not code:
|
||||||
return self.http_status(400, -1, 'Missing authorization code')
|
return self.http_status(400, -1, 'Missing authorization code')
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
return self.http_status(400, -1, 'Missing state parameter')
|
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:
|
try:
|
||||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||||
@@ -385,7 +408,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||||
|
|
||||||
try:
|
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)
|
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
@@ -428,6 +454,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
projection_service = self.ap.directory_projection_service
|
||||||
|
if projection_service is None:
|
||||||
|
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'])
|
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||||
if account is None:
|
if account is None:
|
||||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
|||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
from ....utils import httpclient
|
||||||
|
from ....platform.sources import http_bot_signing
|
||||||
|
|
||||||
|
|
||||||
class BotService:
|
class BotService:
|
||||||
@@ -80,6 +83,7 @@ class BotService:
|
|||||||
'wecomcs',
|
'wecomcs',
|
||||||
'LINE',
|
'LINE',
|
||||||
'lark',
|
'lark',
|
||||||
|
'http_bot',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
@@ -133,7 +137,16 @@ class BotService:
|
|||||||
|
|
||||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||||
|
|
||||||
await self.ap.platform_mgr.load_bot(context, bot)
|
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']
|
return bot_data['uuid']
|
||||||
|
|
||||||
@@ -216,6 +229,53 @@ class BotService:
|
|||||||
|
|
||||||
return [log.to_json() for log in logs], total_count
|
return [log.to_json() for log in logs], total_count
|
||||||
|
|
||||||
|
async def send_http_bot_test_message(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
bot_uuid: str,
|
||||||
|
message: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||||
|
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||||
|
if bot is None:
|
||||||
|
raise WorkspaceNotFoundError('Bot not found')
|
||||||
|
if bot.get('adapter') != 'http_bot':
|
||||||
|
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||||
|
if not bot.get('enable'):
|
||||||
|
raise ValueError('Bot must be enabled before sending a test message')
|
||||||
|
|
||||||
|
text = message.strip()
|
||||||
|
if not text or len(text) > 2000:
|
||||||
|
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||||
|
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||||
|
config = bot.get('adapter_config') or {}
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if config.get('signature_required', True):
|
||||||
|
secret = str(config.get('inbound_secret') or '')
|
||||||
|
if not secret:
|
||||||
|
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||||
|
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||||
|
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||||
|
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||||
|
|
||||||
|
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.post(
|
||||||
|
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
) as response:
|
||||||
|
result = await httpclient.read_json_limited(response)
|
||||||
|
if response.status not in {200, 202}:
|
||||||
|
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||||
|
return result.get('data') or {}
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
|
|||||||
@@ -1257,6 +1257,7 @@ class MonitoringService:
|
|||||||
pipeline_ids: list[str] | None = None,
|
pipeline_ids: list[str] | None = None,
|
||||||
start_time: datetime.datetime | None = None,
|
start_time: datetime.datetime | None = None,
|
||||||
end_time: datetime.datetime | None = None,
|
end_time: datetime.datetime | None = None,
|
||||||
|
user_query: str | None = None,
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -1274,6 +1275,14 @@ class MonitoringService:
|
|||||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||||
if end_time:
|
if end_time:
|
||||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||||
|
if user_query and user_query.strip():
|
||||||
|
user_pattern = f'%{user_query.strip()}%'
|
||||||
|
conditions.append(
|
||||||
|
sqlalchemy.or_(
|
||||||
|
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
||||||
|
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||||
|
|
||||||
@@ -1365,6 +1374,8 @@ class MonitoringService:
|
|||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get bounded session details with full statistics computed in SQL."""
|
"""Get bounded session details with full statistics computed in SQL."""
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
@@ -1478,12 +1489,17 @@ class MonitoringService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
tool_stats = tool_stats_result.one()
|
tool_stats = tool_stats_result.one()
|
||||||
|
tool_conditions = [
|
||||||
|
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||||
|
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||||
|
]
|
||||||
|
if start_time is not None:
|
||||||
|
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||||
|
if end_time is not None:
|
||||||
|
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
||||||
tool_query = (
|
tool_query = (
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||||
.where(
|
.where(*tool_conditions)
|
||||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
|
||||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
|
||||||
)
|
|
||||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||||
.limit(detail_limit + 1)
|
.limit(detail_limit + 1)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
|||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import user
|
from ....entity.persistence import user
|
||||||
from ....entity.dto.space_model import SpaceModel
|
from ....entity.dto.space_model import SpaceModel
|
||||||
|
from ....entity.dto.space_model import SpaceModelSelection
|
||||||
|
from ....entity.persistence import model as persistence_model
|
||||||
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
|
||||||
|
|
||||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||||
@@ -116,7 +119,7 @@ class SpaceService:
|
|||||||
|
|
||||||
space_config = self._get_space_config()
|
space_config = self._get_space_config()
|
||||||
authorize_url = space_config['oauth_authorize_url']
|
authorize_url = space_config['oauth_authorize_url']
|
||||||
params = {'redirect_uri': redirect_uri}
|
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||||
if state:
|
if state:
|
||||||
params['state'] = state
|
params['state'] = state
|
||||||
return f'{authorize_url}?{urlencode(params)}'
|
return f'{authorize_url}?{urlencode(params)}'
|
||||||
@@ -126,6 +129,8 @@ class SpaceService:
|
|||||||
code: str,
|
code: str,
|
||||||
workspace_uuids: list[str] | None = None,
|
workspace_uuids: list[str] | None = None,
|
||||||
workspace_created_ats: dict[str, int] | None = None,
|
workspace_created_ats: dict[str, int] | None = None,
|
||||||
|
*,
|
||||||
|
redirect_uri: str = '',
|
||||||
) -> typing.Dict:
|
) -> typing.Dict:
|
||||||
"""Exchange OAuth authorization code for tokens"""
|
"""Exchange OAuth authorization code for tokens"""
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
@@ -138,6 +143,7 @@ class SpaceService:
|
|||||||
f'{space_url}/api/v1/accounts/oauth/token',
|
f'{space_url}/api/v1/accounts/oauth/token',
|
||||||
json={
|
json={
|
||||||
'code': code,
|
'code': code,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
# Sending an explicit empty list tells new Space servers not to
|
# Sending an explicit empty list tells new Space servers not to
|
||||||
# synthesize a legacy instance-derived Workspace binding.
|
# synthesize a legacy instance-derived Workspace binding.
|
||||||
@@ -238,3 +244,76 @@ class SpaceService:
|
|||||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||||
models_data = data.get('data', {}).get('models', [])
|
models_data = data.get('data', {}).get('models', [])
|
||||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||||
|
|
||||||
|
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||||
|
"""Return Space models in the availability-ranked selection order."""
|
||||||
|
space_url = self._get_space_config()['url']
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(
|
||||||
|
f'{space_url}/api/v1/models/selection',
|
||||||
|
params={'category': category},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
error = await httpclient.read_text_limited(response)
|
||||||
|
raise ValueError(f'Failed to get model selection: {error}')
|
||||||
|
payload = await httpclient.read_json_limited(response)
|
||||||
|
if payload.get('code') != 0:
|
||||||
|
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||||
|
|
||||||
|
data = payload.get('data', [])
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get('models', data.get('items', []))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError('Failed to get model selection: invalid response')
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for selection in data:
|
||||||
|
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||||
|
models.append(selection['model'])
|
||||||
|
else:
|
||||||
|
models.append(selection)
|
||||||
|
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||||
|
|
||||||
|
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||||
|
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||||
|
selection = await self.get_model_selection('chat')
|
||||||
|
if not selection:
|
||||||
|
raise ValueError('No recommended chat model is available')
|
||||||
|
recommended = selection[0]
|
||||||
|
|
||||||
|
async def find_local_model():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.LLMModel)
|
||||||
|
.join(
|
||||||
|
persistence_model.ModelProvider,
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
sqlalchemy.or_(
|
||||||
|
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||||
|
persistence_model.LLMModel.name == recommended.model_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
local_model = await find_local_model()
|
||||||
|
if local_model is None:
|
||||||
|
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||||
|
# the recommendation was published after this process started.
|
||||||
|
from ..context import ExecutionContext
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
local_model = await find_local_model()
|
||||||
|
|
||||||
|
if local_model is None:
|
||||||
|
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||||
|
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ class UserService:
|
|||||||
f'email:{normalized_email}',
|
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"""
|
"""Bind Space account to existing local account"""
|
||||||
local_account = await self.get_user_by_email(user_email)
|
local_account = await self.get_user_by_email(user_email)
|
||||||
if local_account is None:
|
if local_account is None:
|
||||||
@@ -794,12 +794,13 @@ class UserService:
|
|||||||
code,
|
code,
|
||||||
[binding.workspace_uuid],
|
[binding.workspace_uuid],
|
||||||
{binding.workspace_uuid: created_ts},
|
{binding.workspace_uuid: created_ts},
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Compatibility for early/bootstrap call sites that have not wired
|
# Compatibility for early/bootstrap call sites that have not wired
|
||||||
# WorkspaceService yet; old Space servers still derive the legacy
|
# WorkspaceService yet; old Space servers still derive the legacy
|
||||||
# Workspace identity from instance_id when the field is omitted.
|
# 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')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
|||||||
@@ -147,7 +147,16 @@ class LangBotMCPServer:
|
|||||||
)
|
)
|
||||||
async def create_pipeline(pipeline_data: dict) -> str:
|
async def create_pipeline(pipeline_data: dict) -> str:
|
||||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
return _dump(
|
||||||
|
{
|
||||||
|
'uuid': await ap.pipeline_service.create_pipeline(
|
||||||
|
context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||||
|
|||||||
@@ -455,7 +455,9 @@ class BoxService:
|
|||||||
|
|
||||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
if self._cloud_managed:
|
if self._cloud_managed:
|
||||||
if self._admission is None:
|
if self._admission is None:
|
||||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||||
@@ -565,7 +567,9 @@ class BoxService:
|
|||||||
skip_host_mount_validation: bool = False,
|
skip_host_mount_validation: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||||
await self._require_validated_workspace_sandbox(execution_context)
|
await self._require_validated_workspace_sandbox(execution_context)
|
||||||
@@ -2142,5 +2146,8 @@ class BoxService:
|
|||||||
if backend_name:
|
if backend_name:
|
||||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||||
else:
|
else:
|
||||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
payload['connector_error'] = (
|
||||||
|
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||||
|
'Trusted local development may explicitly select the unsafe host backend.'
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
|
|||||||
# The database cursor remains the shared projection high-water mark,
|
# The database cursor remains the shared projection high-water mark,
|
||||||
# while this cursor tracks what this process has actually observed.
|
# while this cursor tracks what this process has actually observed.
|
||||||
self._consumer_cursor: int | None = None
|
self._consumer_cursor: int | None = None
|
||||||
|
self._sync_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
"""Block Cloud startup until one full signed snapshot is committed."""
|
||||||
|
|
||||||
|
async with self._sync_lock:
|
||||||
|
await self._refresh_snapshot()
|
||||||
|
|
||||||
|
async def refresh_snapshot(self) -> None:
|
||||||
|
"""Refresh from one full signed snapshot within the sync single-flight."""
|
||||||
|
|
||||||
|
async with self._sync_lock:
|
||||||
|
await self._refresh_snapshot()
|
||||||
|
|
||||||
|
async def _refresh_snapshot(self) -> None:
|
||||||
last_superseded: _DirectorySnapshotSuperseded | None = None
|
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||||
for _attempt in range(5):
|
for _attempt in range(5):
|
||||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||||
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
|
|||||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||||
|
|
||||||
async def sync_once(self) -> None:
|
async def sync_once(self) -> None:
|
||||||
|
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
|
cursor = self._consumer_cursor
|
||||||
if cursor is None:
|
if cursor is None:
|
||||||
await self.initialize()
|
await self._refresh_snapshot()
|
||||||
return
|
return
|
||||||
batch = await self.provider.fetch_events(
|
batch = await self.provider.fetch_events(
|
||||||
self.instance_uuid,
|
self.instance_uuid,
|
||||||
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
|
|||||||
for row in inbox_rows:
|
for row in inbox_rows:
|
||||||
row.applied_at = now
|
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] = {}
|
selected: dict[str, DirectoryMember] = {}
|
||||||
emails: dict[str, str] = {}
|
emails: dict[str, str] = {}
|
||||||
for workspace in snapshot.workspaces:
|
for workspace in snapshot.workspaces:
|
||||||
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
|
|||||||
continue
|
continue
|
||||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
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:
|
if account.projection_revision > snapshot.cursor:
|
||||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||||
projected_account = self._account_projection(member)
|
projected_account = self._account_projection(member)
|
||||||
|
|||||||
@@ -635,9 +635,9 @@ class Application:
|
|||||||
frontend_path = paths.get_frontend_path()
|
frontend_path = paths.get_frontend_path()
|
||||||
|
|
||||||
if not os.path.exists(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(
|
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
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceModelSelection(pydantic.BaseModel):
|
||||||
|
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
model_id: str
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
@@ -82,7 +83,7 @@ def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -
|
|||||||
|
|
||||||
|
|
||||||
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
|
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
|
||||||
with _open_read_only(path) as connection:
|
with contextlib.closing(_open_read_only(path)) as connection:
|
||||||
_verify_connection(connection, expected_revision)
|
_verify_connection(connection, expected_revision)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,12 +120,16 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
|||||||
|
|
||||||
|
|
||||||
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
"""Sync a file, tolerating delayed visibility after replace on bind mounts.
|
||||||
|
|
||||||
|
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
|
||||||
|
access to the file descriptor).
|
||||||
|
"""
|
||||||
|
|
||||||
descriptor: int | None = None
|
descriptor: int | None = None
|
||||||
for attempt in range(reopen_attempts):
|
for attempt in range(reopen_attempts):
|
||||||
try:
|
try:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
descriptor = os.open(path, os.O_RDWR)
|
||||||
break
|
break
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
if attempt + 1 >= reopen_attempts:
|
if attempt + 1 >= reopen_attempts:
|
||||||
@@ -138,13 +143,37 @@ def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _fsync_directory(path: pathlib.Path) -> None:
|
def _fsync_directory(path: pathlib.Path) -> None:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
if os.name == 'nt':
|
||||||
|
# Windows cannot fsync directory handles opened through os.open.
|
||||||
|
return
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
finally:
|
finally:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_stale_temporary_files(
|
||||||
|
directory: pathlib.Path,
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
suffix: str,
|
||||||
|
) -> None:
|
||||||
|
"""Remove temporary files left by an interrupted backup or restore."""
|
||||||
|
|
||||||
|
for candidate in directory.iterdir():
|
||||||
|
if candidate.is_dir() or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except PermissionError:
|
||||||
|
# Another process may still own this file. Do not turn harmless
|
||||||
|
# cleanup into a migration failure; its unique name cannot collide.
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
def _create_backup(
|
def _create_backup(
|
||||||
database_path: pathlib.Path,
|
database_path: pathlib.Path,
|
||||||
source_revision: str,
|
source_revision: str,
|
||||||
@@ -153,6 +182,11 @@ def _create_backup(
|
|||||||
backup_directory = database_path.parent / 'migration-backups'
|
backup_directory = database_path.parent / 'migration-backups'
|
||||||
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
os.chmod(backup_directory, 0o700)
|
os.chmod(backup_directory, 0o700)
|
||||||
|
_remove_stale_temporary_files(
|
||||||
|
backup_directory,
|
||||||
|
prefix=f'.{database_path.stem}-pre-',
|
||||||
|
suffix='.creating',
|
||||||
|
)
|
||||||
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
|
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
|
||||||
stem = (
|
stem = (
|
||||||
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
|
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
|
||||||
@@ -169,11 +203,8 @@ def _create_backup(
|
|||||||
temporary_path = pathlib.Path(temporary_name)
|
temporary_path = pathlib.Path(temporary_name)
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
_open_read_only(database_path) as source,
|
contextlib.closing(_open_read_only(database_path)) as source,
|
||||||
sqlite3.connect(
|
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||||
temporary_path,
|
|
||||||
timeout=30,
|
|
||||||
) as destination,
|
|
||||||
):
|
):
|
||||||
source.execute('PRAGMA busy_timeout = 30000')
|
source.execute('PRAGMA busy_timeout = 30000')
|
||||||
source.backup(destination)
|
source.backup(destination)
|
||||||
@@ -221,6 +252,11 @@ async def create_verified_backup(
|
|||||||
|
|
||||||
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
||||||
_verify_file(backup.backup_path, backup.source_revision)
|
_verify_file(backup.backup_path, backup.source_revision)
|
||||||
|
_remove_stale_temporary_files(
|
||||||
|
backup.database_path.parent,
|
||||||
|
prefix=f'.{backup.database_path.name}.',
|
||||||
|
suffix='.restoring',
|
||||||
|
)
|
||||||
descriptor, temporary_name = tempfile.mkstemp(
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
prefix=f'.{backup.database_path.name}.',
|
prefix=f'.{backup.database_path.name}.',
|
||||||
suffix='.restoring',
|
suffix='.restoring',
|
||||||
@@ -230,11 +266,8 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
|
|||||||
temporary_path = pathlib.Path(temporary_name)
|
temporary_path = pathlib.Path(temporary_name)
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
_open_read_only(backup.backup_path) as source,
|
contextlib.closing(_open_read_only(backup.backup_path)) as source,
|
||||||
sqlite3.connect(
|
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
|
||||||
temporary_path,
|
|
||||||
timeout=30,
|
|
||||||
) as destination,
|
|
||||||
):
|
):
|
||||||
source.backup(destination)
|
source.backup(destination)
|
||||||
destination.commit()
|
destination.commit()
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
|||||||
'now': sqlalchemy.sql.functions.now,
|
'now': sqlalchemy.sql.functions.now,
|
||||||
'sum': sqlalchemy.sql.functions.sum,
|
'sum': sqlalchemy.sql.functions.sum,
|
||||||
}
|
}
|
||||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
|
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
|
||||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||||
sqlalchemy.sql.dml.UpdateBase,
|
sqlalchemy.sql.dml.UpdateBase,
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ from .. import entities
|
|||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||||
|
|
||||||
|
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||||
|
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||||
|
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||||
|
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||||
|
|
||||||
|
|
||||||
@filter_model.filter_class('ban-word-filter')
|
@filter_model.filter_class('ban-word-filter')
|
||||||
class BanWordFilter(filter_model.ContentFilter):
|
class BanWordFilter(filter_model.ContentFilter):
|
||||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||||
|
words = self.ap.sensitive_meta.data.get('words') or []
|
||||||
|
mask = self.ap.sensitive_meta.data['mask']
|
||||||
|
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
found, message = await mask_patterns(
|
found, current = await mask_patterns(
|
||||||
self.ap.sensitive_meta.data['words'],
|
words,
|
||||||
message,
|
message,
|
||||||
mask=self.ap.sensitive_meta.data['mask'],
|
mask=mask,
|
||||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
mask_word=mask_word,
|
||||||
|
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||||
)
|
)
|
||||||
except SafeRegexError as exc:
|
except SafeRegexError as exc:
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
|
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||||
replacement=message,
|
replacement=current,
|
||||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||||
console_notice='',
|
console_notice='',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/discord
|
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||||
en: https://link.langbot.app/en/platforms/discord
|
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||||
ja: https://link.langbot.app/ja/platforms/discord
|
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||||
en: https://docs.langbot.app/en/platforms/http-bot
|
en: https://langbot.app/docs/en/platforms/http-bot
|
||||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/kook
|
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||||
en: https://link.langbot.app/en/platforms/kook
|
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||||
ja: https://link.langbot.app/ja/platforms/kook
|
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
|
|||||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
||||||
|
|
||||||
|
|
||||||
|
def _lark_final_layout_texts(
|
||||||
|
*,
|
||||||
|
resume_from: bool,
|
||||||
|
text_message: str,
|
||||||
|
pre_pause_cached: str | None,
|
||||||
|
resume_cached: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Return (main_text, resume_placeholder_text) for the final card update.
|
||||||
|
|
||||||
|
Non-resume round: the full reply belongs in the main streaming element
|
||||||
|
only — also rendering the resume placeholder duplicates the reply, since
|
||||||
|
both hold the same accumulated text. Resume round (Dify HITL): keep the
|
||||||
|
pre-pause text in the main element and the resumed text in the
|
||||||
|
placeholder, as they are distinct segments.
|
||||||
|
"""
|
||||||
|
if resume_from:
|
||||||
|
# An empty pre-pause cache is valid (Dify paused before emitting any
|
||||||
|
# text); only a missing entry (None) falls back to the full text.
|
||||||
|
main_text = text_message if pre_pause_cached is None else pre_pause_cached
|
||||||
|
return main_text, resume_cached
|
||||||
|
return text_message, ''
|
||||||
|
|
||||||
|
|
||||||
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||||
field_type = _dify_field_type(field)
|
field_type = _dify_field_type(field)
|
||||||
if field_type == 'file':
|
if field_type == 'file':
|
||||||
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||||
else:
|
else:
|
||||||
# Normal finish: keep pre-pause + resume content visible,
|
# Normal finish: remove buttons/notice and finalize the card.
|
||||||
# remove buttons/notice, drop the resume placeholder.
|
main_text, resume_text = _lark_final_layout_texts(
|
||||||
|
resume_from=resume_from,
|
||||||
|
text_message=text_message,
|
||||||
|
pre_pause_cached=self.card_pre_pause_text.get(card_id),
|
||||||
|
resume_cached=resume_cached,
|
||||||
|
)
|
||||||
await self._update_card_layout(
|
await self._update_card_layout(
|
||||||
card_id=card_id,
|
card_id=card_id,
|
||||||
message_source=message_source,
|
message_source=message_source,
|
||||||
text_message=pre_pause,
|
text_message=main_text,
|
||||||
sequence=final_seq,
|
sequence=final_seq,
|
||||||
form_data=None,
|
form_data=None,
|
||||||
notice_text=selected_notice if resume_from else '',
|
notice_text=selected_notice if resume_from else '',
|
||||||
resume_placeholder_text=resume_cached,
|
resume_placeholder_text=resume_text,
|
||||||
)
|
)
|
||||||
self._drop_card_state(card_id)
|
self._drop_card_state(card_id)
|
||||||
self.card_id_dict.pop(message_id, None)
|
self.card_id_dict.pop(message_id, None)
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ spec:
|
|||||||
- china
|
- china
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/lark
|
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: domain
|
- name: domain
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
|||||||
ImageMessageContent,
|
ImageMessageContent,
|
||||||
VideoMessageContent,
|
VideoMessageContent,
|
||||||
AudioMessageContent,
|
AudioMessageContent,
|
||||||
|
UserMentionee,
|
||||||
)
|
)
|
||||||
|
|
||||||
# from linebot import WebhookParser
|
# from linebot import WebhookParser
|
||||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, bot_account_id: str = ''):
|
||||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
self.bot_account_id = bot_account_id
|
||||||
|
|
||||||
|
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||||
lb_msg_list = []
|
lb_msg_list = []
|
||||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||||
|
|
||||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||||
|
|
||||||
if isinstance(message.message, TextMessageContent):
|
if isinstance(message.message, TextMessageContent):
|
||||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
lb_msg_list.extend(
|
||||||
|
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
|
||||||
|
)
|
||||||
elif isinstance(message.message, AudioMessageContent):
|
elif isinstance(message.message, AudioMessageContent):
|
||||||
pass
|
pass
|
||||||
elif isinstance(message.message, VideoMessageContent):
|
elif isinstance(message.message, VideoMessageContent):
|
||||||
@@ -86,22 +91,60 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||||
return platform_message.MessageChain(lb_msg_list)
|
return platform_message.MessageChain(lb_msg_list)
|
||||||
|
|
||||||
|
def _build_text_components(self, text: str, mention) -> list:
|
||||||
|
"""Build message components from text, inserting At components for mentions.
|
||||||
|
|
||||||
|
LINE provides mention positions (index/length) and is_self per mentionee in the
|
||||||
|
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
|
||||||
|
'at-bot' group respond rule work for LINE, consistent with other adapters.
|
||||||
|
"""
|
||||||
|
components: list = []
|
||||||
|
if not mention or not mention.mentionees:
|
||||||
|
if text:
|
||||||
|
components.append(platform_message.Plain(text=text))
|
||||||
|
return components
|
||||||
|
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
|
||||||
|
cursor = 0
|
||||||
|
for start, end, mentionee in segments:
|
||||||
|
if start < cursor:
|
||||||
|
start, end = cursor, min(end, len(text))
|
||||||
|
if start < cursor or end <= start or end > len(text):
|
||||||
|
continue
|
||||||
|
if start > cursor:
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||||
|
if isinstance(mentionee, UserMentionee):
|
||||||
|
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
|
||||||
|
if not target:
|
||||||
|
target = text[start:end]
|
||||||
|
else:
|
||||||
|
target = text[start:end]
|
||||||
|
# At.__str__ already prepends '@', so strip one from the LINE text token.
|
||||||
|
display = text[start:end].lstrip('@')
|
||||||
|
components.append(platform_message.At(target=str(target), display=display))
|
||||||
|
cursor = end
|
||||||
|
if cursor < len(text):
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:]))
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
def __init__(self, bot_account_id: str = ''):
|
||||||
|
self.bot_account_id = bot_account_id
|
||||||
|
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
event: platform_events.MessageEvent,
|
event: platform_events.MessageEvent,
|
||||||
) -> MessageEvent:
|
) -> MessageEvent:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
|
||||||
|
|
||||||
if event.source.type == 'user':
|
if event.source.type == 'user':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.message.id,
|
id=event.source.user_id,
|
||||||
nickname=event.source.user_id,
|
nickname=event.source.user_id,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
@@ -110,13 +153,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# 'group' and 'room' sources carry the stable chat id under different
|
||||||
|
# field names; user_id may be absent for some members, so fall back
|
||||||
|
# to the group/room id rather than the per-message id.
|
||||||
|
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
|
||||||
|
member_id = event.source.user_id or group_id
|
||||||
|
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.event.sender.sender_id.open_id,
|
id=member_id,
|
||||||
member_name=event.event.sender.sender_id.union_id,
|
member_name=member_id,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.message.id,
|
id=group_id,
|
||||||
name='',
|
name='',
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
@@ -163,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
listeners={},
|
listeners={},
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
seq=1,
|
seq=1,
|
||||||
event_converter=LINEEventConverter(),
|
event_converter=LINEEventConverter(bot_account_id),
|
||||||
message_converter=LINEMessageConverter(),
|
message_converter=LINEMessageConverter(bot_account_id),
|
||||||
line_webhook=line_webhook,
|
line_webhook=line_webhook,
|
||||||
parser=parser,
|
parser=parser,
|
||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/line
|
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||||
en: https://link.langbot.app/en/platforms/line
|
en: https://langbot.app/docs/en/usage/platforms/line
|
||||||
ja: https://link.langbot.app/ja/platforms/line
|
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||||
en: https://link.langbot.app/en/platforms/officialaccount
|
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(
|
||||||
app_id=config['appid'],
|
app_id=config['appid'],
|
||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config.get('token', ''),
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=enable_webhook,
|
unified_mode=enable_webhook,
|
||||||
)
|
)
|
||||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content_type = content.get('type', 'text')
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
if content_type == 'text':
|
if content_type == 'text':
|
||||||
if target_type == 'c2c':
|
if target_type in {'c2c', 'group'}:
|
||||||
await self.bot.send_private_text_msg(
|
await self._send_c2c_or_group_text_reply(
|
||||||
|
target_type,
|
||||||
target_id,
|
target_id,
|
||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
msg_id=qq_official_event.d_id,
|
||||||
)
|
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif content_type == 'image':
|
elif content_type == 'image':
|
||||||
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _send_c2c_or_group_text_reply(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
msg_id: typing.Optional[str] = None,
|
||||||
|
event_id: typing.Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a text reply using the configured C2C/group render mode."""
|
||||||
|
use_markdown = self.config.get('enable-markdown-rendering', False)
|
||||||
|
if target_type == 'c2c':
|
||||||
|
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
|
||||||
|
await send(
|
||||||
|
user_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
|
||||||
|
await send(
|
||||||
|
group_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||||
ctx['session_started'] = True
|
ctx['session_started'] = True
|
||||||
|
|
||||||
# 发送内容 = 全量累积文本
|
# `replace` mode requires every update to contain the previously
|
||||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
# delivered content as its prefix. `sent_length` only tells us whether
|
||||||
# - 首次:发送全部文本,建立会话
|
# a non-final snapshot has new content; it must not truncate the
|
||||||
# - 后续:只能发送新增部分(append 行为)
|
# content sent to QQ.
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||||
if not content_to_send and not is_final:
|
|
||||||
return
|
return
|
||||||
|
content_to_send = ctx['accumulated_text']
|
||||||
|
|
||||||
input_state = 10 if is_final else 1
|
input_state = 10 if is_final else 1
|
||||||
|
|
||||||
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_type == 'c2c':
|
await self._send_c2c_or_group_text_reply(
|
||||||
await self.bot.send_private_text_msg(
|
target_type,
|
||||||
user_openid=target_id,
|
target_id,
|
||||||
content=text,
|
text,
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
msg_seq=msg_seq,
|
msg_seq=msg_seq,
|
||||||
)
|
)
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
group_openid=target_id,
|
|
||||||
content=text,
|
|
||||||
event_id=event_id,
|
|
||||||
msg_seq=msg_seq,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||||
config:
|
config:
|
||||||
- name: __system.outbound_ips
|
- name: __system.outbound_ips
|
||||||
label:
|
label:
|
||||||
@@ -95,6 +95,18 @@ spec:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
- name: enable-markdown-rendering
|
||||||
|
label:
|
||||||
|
en_US: Enable Markdown Rendering
|
||||||
|
zh_Hans: 启用 Markdown 渲染
|
||||||
|
zh_Hant: 啟用 Markdown 渲染
|
||||||
|
description:
|
||||||
|
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
|
||||||
|
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
|
||||||
|
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- protocol
|
- protocol
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/satori
|
zh: https://langbot.app/docs/zh/usage/platforms/readme
|
||||||
en: https://link.langbot.app/en/platforms/satori
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/satori
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: platform
|
- name: platform
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/slack
|
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||||
en: https://link.langbot.app/en/platforms/slack
|
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||||
ja: https://link.langbot.app/ja/platforms/slack
|
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- global
|
- global
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/telegram
|
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||||
en: https://link.langbot.app/en/platforms/telegram
|
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||||
ja: https://link.langbot.app/ja/platforms/telegram
|
ja: https://langbot.app/docs/ja/usage/platforms/telegram
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wechatpad
|
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
|
||||||
en: https://link.langbot.app/en/platforms/wechatpad
|
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||||
config:
|
config:
|
||||||
- name: wechatpad_url
|
- name: wechatpad_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||||
if content['type'] == 'image':
|
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':
|
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':
|
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(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ spec:
|
|||||||
- popular
|
- popular
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecom
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||||
en: https://link.langbot.app/en/platforms/wecom
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||||
ja: https://link.langbot.app/ja/platforms/wecom
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||||
config:
|
config:
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
msgid = f'{uuid.uuid4().hex}'
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_text_msg(
|
await self.bot.send_text_msg(
|
||||||
open_kfid=open_kfid,
|
open_kfid=open_kfid,
|
||||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
msgid=msgid,
|
msgid=msgid,
|
||||||
content=content['content'],
|
content=content['content'],
|
||||||
)
|
)
|
||||||
|
elif content['type'] == 'image':
|
||||||
|
await self.bot.send_image_msg(
|
||||||
|
open_kfid=open_kfid,
|
||||||
|
external_userid=external_userid,
|
||||||
|
msgid=msgid,
|
||||||
|
media_id=content['media_id'],
|
||||||
|
)
|
||||||
|
|
||||||
def set_bot_uuid(self, bot_uuid: str):
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spec:
|
|||||||
categories:
|
categories:
|
||||||
- china
|
- china
|
||||||
help_links:
|
help_links:
|
||||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||||
en: https://link.langbot.app/en/platforms/wecomcs
|
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||||
config:
|
config:
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1913,9 +1913,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None:
|
||||||
runtime_handler = self._runtime_handler()
|
runtime_handler = self._runtime_handler()
|
||||||
binding = await self._target_binding(author, plugin_name)
|
try:
|
||||||
|
binding = await self._target_binding(author, plugin_name)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace':
|
||||||
|
return None
|
||||||
|
raise
|
||||||
with runtime_handler.installation_scope(binding):
|
with runtime_handler.installation_scope(binding):
|
||||||
return await runtime_handler.get_plugin_info(author, plugin_name)
|
return await runtime_handler.get_plugin_info(author, plugin_name)
|
||||||
|
|
||||||
|
|||||||
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
levels = ['provider_default', 'disabled', 'enabled']
|
levels = ['provider_default', 'disabled', 'enabled']
|
||||||
elif family == 'doubao':
|
elif family == 'doubao':
|
||||||
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
elif family == 'ollama':
|
elif family in ('ollama', 'ollama_chat'):
|
||||||
levels = ['provider_default']
|
levels = ['provider_default']
|
||||||
levels.append('disabled')
|
levels.append('disabled')
|
||||||
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
||||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
converted_parts = []
|
converted_parts = []
|
||||||
for part in content:
|
for part in content:
|
||||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||||
part['image_url'] = {'url': part['image_base64']}
|
# History trimming (SessionManager) clears image_base64
|
||||||
part['type'] = 'image_url'
|
# on past turns and exclude_none serialization drops
|
||||||
del part['image_base64']
|
# the key entirely, so the replayed part may carry no
|
||||||
|
# payload. Prefer the base64 payload; fall back to an
|
||||||
|
# image_url that survived on the same element; drop
|
||||||
|
# hollow parts instead of raising KeyError (#2469).
|
||||||
|
image_b64 = part.get('image_base64')
|
||||||
|
fallback_url = None
|
||||||
|
if not image_b64:
|
||||||
|
raw_image_url = part.get('image_url')
|
||||||
|
if isinstance(raw_image_url, dict):
|
||||||
|
fallback_url = raw_image_url.get('url')
|
||||||
|
if image_b64 or fallback_url:
|
||||||
|
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||||
|
part['type'] = 'image_url'
|
||||||
|
part.pop('image_base64', None)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
# OpenAI-compatible chat models reject non-image file parts
|
# OpenAI-compatible chat models reject non-image file parts
|
||||||
# (audio/document base64 or url). These originate from Voice /
|
# (audio/document base64 or url). These originate from Voice /
|
||||||
# File attachments — including ones replayed from conversation
|
# File attachments — including ones replayed from conversation
|
||||||
@@ -1330,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
extra_args: dict[str, typing.Any] = {},
|
extra_args: dict[str, typing.Any] = {},
|
||||||
) -> tuple[list[list[float]], dict]:
|
) -> tuple[list[list[float]], dict]:
|
||||||
"""Invoke embedding and return vectors with usage info."""
|
"""Invoke embedding and return vectors with usage info."""
|
||||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
# litellm's embedding routing has no "ollama_chat" branch (that provider
|
||||||
|
# exists only for /api/chat completions) — embeddings still go through
|
||||||
|
# the plain "ollama" provider. Requesters configured for ollama_chat
|
||||||
|
# (to get native tool-calling on the chat path) must fall back to
|
||||||
|
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
|
||||||
|
# provider for this endpoint".
|
||||||
|
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
|
||||||
|
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
|
||||||
api_key = model.provider.token_mgr.get_token()
|
api_key = model.provider.token_mgr.get_token()
|
||||||
|
|
||||||
args = {
|
args = {
|
||||||
@@ -1526,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get(models_url, headers=headers)
|
response = await client.get(models_url, headers=headers)
|
||||||
|
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
|
||||||
|
# Some OpenAI-compatible servers (notably a bare Ollama host,
|
||||||
|
# e.g. http://host:11434) expose the model list under /v1/models
|
||||||
|
# rather than /models. Providers whose configured base_url
|
||||||
|
# already ends in /v1 keep their original (working) URL.
|
||||||
|
response = await client.get(f'{base_url}/v1/models', headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = await httpclient.parse_json_response(response)
|
payload = await httpclient.parse_json_response(response)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ metadata:
|
|||||||
zh_Hans: Ollama
|
zh_Hans: Ollama
|
||||||
icon: ollama.svg
|
icon: ollama.svg
|
||||||
spec:
|
spec:
|
||||||
litellm_provider: ollama
|
litellm_provider: ollama_chat
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
and len(func_ret) > 0
|
and len(func_ret) > 0
|
||||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||||
):
|
):
|
||||||
tool_content = func_ret
|
# OpenAI-compatible APIs require tool-message content to be a
|
||||||
|
# string; a raw list of ContentElement causes HTTP 500 (#2457).
|
||||||
|
tool_content = '\n'.join(str(ce) for ce in func_ret)
|
||||||
else:
|
else:
|
||||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
# 获取输出键名,默认为response
|
# 获取输出键名,默认为response
|
||||||
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', '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
|
# 获取认证类型,默认为none
|
||||||
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', '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(
|
async with session.post(
|
||||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||||
) as response:
|
) 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 = (
|
error_text = (
|
||||||
await httpclient.read_limited(
|
await httpclient.read_limited(
|
||||||
response,
|
response,
|
||||||
@@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
|||||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||||
raise Exception(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):
|
async for chunk in self._process_response(response):
|
||||||
if is_stream:
|
if is_stream:
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
||||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||||
|
'Trusted local development may explicitly select box.backend=host. '
|
||||||
'The LLM will not have access to code execution or file operation tools.'
|
'The LLM will not have access to code execution or file operation tools.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
else:
|
else:
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
'Skill tools (activate/register_skill) are NOT available. '
|
'Skill tools (activate/register_skill) are NOT available. '
|
||||||
'No sandbox backend (Docker/nsjail/E2B) is ready.'
|
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||||
|
'Trusted local development may explicitly select box.backend=host.'
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _check_sandbox_available(self) -> bool:
|
async def _check_sandbox_available(self) -> bool:
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
|||||||
"""Raised when the regex engine exhausts the operation CPU budget."""
|
"""Raised when the regex engine exhausts the operation CPU budget."""
|
||||||
|
|
||||||
|
|
||||||
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
|
def _validate_patterns(
|
||||||
|
patterns: Sequence[str],
|
||||||
|
*,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if max_pattern_count < 1:
|
||||||
|
raise ValueError('max_pattern_count must be positive')
|
||||||
|
if len(patterns) > max_pattern_count:
|
||||||
|
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
||||||
normalized = tuple(patterns)
|
normalized = tuple(patterns)
|
||||||
if len(normalized) > MAX_PATTERN_COUNT:
|
|
||||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
|
||||||
for pattern in normalized:
|
for pattern in normalized:
|
||||||
if not isinstance(pattern, str):
|
if not isinstance(pattern, str):
|
||||||
raise SafeRegexError('Regex patterns must be strings')
|
raise SafeRegexError('Regex patterns must be strings')
|
||||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
|
max_pattern_count: int,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
normalized_patterns = _validate_patterns(patterns)
|
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||||
_validate_input(value)
|
_validate_input(value)
|
||||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||||
|
|
||||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
|||||||
mask=mask,
|
mask=mask,
|
||||||
mask_word=mask_word,
|
mask_word=mask_word,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_pattern_count=max_pattern_count,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class VersionManager:
|
|||||||
try:
|
try:
|
||||||
if await self.is_new_version_available():
|
if await self.is_new_version_available():
|
||||||
return (
|
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,
|
logging.INFO,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
||||||
return (
|
return (
|
||||||
'You have been invited to LangBot Cloud\n\n'
|
'You have been invited to join a Workspace in LangBot\n\n'
|
||||||
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
||||||
f'Accept invitation: {invitation_link}\n\n'
|
f'Accept invitation: {invitation_link}\n\n'
|
||||||
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
||||||
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Join {escaped_workspace} on LangBot Cloud</title>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Join {escaped_workspace} in LangBot</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||||
<tr><td align="center">
|
<tr>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
|
<td align="center" style="padding:48px 16px;">
|
||||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
<tr>
|
||||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
<td style="padding:0 4px 20px;">
|
||||||
</td></tr>
|
<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;">
|
||||||
<tr><td style="padding:36px;">
|
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
|
</td>
|
||||||
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
|
</tr>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
<tr>
|
||||||
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
|
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||||
</td></tr></table>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
|
<tr>
|
||||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
|
<td style="padding:42px 42px 38px;">
|
||||||
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
|
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||||
</td></tr>
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">You’re invited to collaborate</h1>
|
||||||
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
|
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
|
||||||
</table>
|
|
||||||
</td></tr>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 18px;">
|
||||||
|
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
|
||||||
|
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="28" style="height:28px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#2563eb;border-radius:8px;">
|
||||||
|
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="32" style="height:32px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:22px;">
|
||||||
|
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
|
||||||
|
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
|
||||||
|
Sent by LangBot<br>
|
||||||
|
If you were not expecting this invitation, you can safely ignore this email.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>'''
|
</html>'''
|
||||||
|
|||||||
@@ -331,7 +331,10 @@ box:
|
|||||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||||
# be listed read-only and http/sse MCP servers continue to work.
|
# be listed read-only and http/sse MCP servers continue to work.
|
||||||
enabled: true
|
enabled: true
|
||||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||||
|
# isolation. It is never auto-selected and is only for trusted local
|
||||||
|
# development. Can be written via BOX__BACKEND.
|
||||||
|
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
|
||||||
runtime:
|
runtime:
|
||||||
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
||||||
# runtimes. To protect an exposed endpoint, set the same strong secret
|
# runtimes. To protect an exposed endpoint, set the same strong secret
|
||||||
|
|||||||
@@ -80,7 +80,8 @@
|
|||||||
"header-name": "",
|
"header-name": "",
|
||||||
"header-value": "",
|
"header-value": "",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"output-key": "response"
|
"output-key": "response",
|
||||||
|
"response-handling": "reply"
|
||||||
},
|
},
|
||||||
"langflow-api": {
|
"langflow-api": {
|
||||||
"base-url": "http://localhost:7860",
|
"base-url": "http://localhost:7860",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
// Read config from script tag data attributes
|
// Read config from script tag data attributes
|
||||||
var scriptEl = document.currentScript;
|
var scriptEl = document.currentScript;
|
||||||
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
||||||
|
var scriptTestNotice = scriptEl
|
||||||
|
? scriptEl.getAttribute("data-test-notice")
|
||||||
|
: null;
|
||||||
|
|
||||||
// ========== i18n ==========
|
// ========== i18n ==========
|
||||||
var I18N = {
|
var I18N = {
|
||||||
@@ -192,6 +195,7 @@
|
|||||||
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
||||||
.lb-header-btn:hover { opacity: 1; }\
|
.lb-header-btn:hover { opacity: 1; }\
|
||||||
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
||||||
|
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
|
||||||
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
||||||
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
||||||
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
||||||
@@ -638,9 +642,10 @@
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (
|
if (
|
||||||
prevContent === content ||
|
prevContent &&
|
||||||
prevContent.indexOf(content) >= 0 ||
|
(prevContent === content ||
|
||||||
content.indexOf(prevContent) >= 0
|
prevContent.indexOf(content) >= 0 ||
|
||||||
|
content.indexOf(prevContent) >= 0)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1240,6 +1245,14 @@
|
|||||||
// Root container
|
// Root container
|
||||||
var root = document.createElement("div");
|
var root = document.createElement("div");
|
||||||
root.id = "langbot-widget-root";
|
root.id = "langbot-widget-root";
|
||||||
|
root.langbotDestroy = function () {
|
||||||
|
wsDisconnect();
|
||||||
|
if (state.historyReloadTimer) {
|
||||||
|
clearTimeout(state.historyReloadTimer);
|
||||||
|
state.historyReloadTimer = null;
|
||||||
|
}
|
||||||
|
root.remove();
|
||||||
|
};
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
|
|
||||||
var shadow = root.attachShadow({ mode: "open" });
|
var shadow = root.attachShadow({ mode: "open" });
|
||||||
@@ -1328,6 +1341,14 @@
|
|||||||
header.appendChild(headerActions);
|
header.appendChild(headerActions);
|
||||||
panel.appendChild(header);
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
if (scriptTestNotice) {
|
||||||
|
var testNotice = document.createElement("div");
|
||||||
|
testNotice.className = "lb-test-notice";
|
||||||
|
testNotice.setAttribute("role", "note");
|
||||||
|
testNotice.textContent = scriptTestNotice;
|
||||||
|
panel.appendChild(testNotice);
|
||||||
|
}
|
||||||
|
|
||||||
// Messages area
|
// Messages area
|
||||||
var messages = document.createElement("div");
|
var messages = document.createElement("div");
|
||||||
messages.className = "lb-messages";
|
messages.className = "lb-messages";
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ stages:
|
|||||||
zh_Hans: API 密钥
|
zh_Hans: API 密钥
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 'your-api-key'
|
default: ''
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
@@ -475,6 +475,25 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: 'response'
|
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
|
- name: coze-api
|
||||||
label:
|
label:
|
||||||
en_US: coze API
|
en_US: coze API
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
|
||||||
|
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
|
||||||
|
|
||||||
|
response = await quart_test_client.get(
|
||||||
|
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
|
||||||
|
assert kwargs['bot_ids'] == ['bot-1']
|
||||||
|
assert kwargs['user_query'] == 'alice'
|
||||||
|
assert kwargs['limit'] == 20
|
||||||
|
assert kwargs['offset'] == 40
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||||
class TestMonitoringErrorsEndpoint:
|
class TestMonitoringErrorsEndpoint:
|
||||||
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
|
|||||||
"""Tests for detail endpoints."""
|
"""Tests for detail endpoints."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_session_analysis(self, quart_test_client):
|
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
|
||||||
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
||||||
response = await quart_test_client.get(
|
response = await quart_test_client.get(
|
||||||
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
|
'/api/v1/monitoring/sessions/sess-1/analysis'
|
||||||
|
'?startTime=2026-08-31T16%3A00%3A00.000Z'
|
||||||
|
'&endTime=2026-09-01T15%3A59%3A59.999Z',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
|
||||||
|
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
|
||||||
|
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_message_details(self, quart_test_client):
|
async def test_get_message_details(self, quart_test_client):
|
||||||
|
|||||||
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
|
|||||||
assert data['code'] == 0
|
assert data['code'] == 0
|
||||||
assert 'uuid' in data['data']
|
assert 'uuid' in data['data']
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||||
|
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||||
|
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||||
|
|
||||||
|
response = await quart_test_client.post(
|
||||||
|
'/api/v1/pipelines',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||||
|
assert call.kwargs == {'default': True}
|
||||||
|
assert call.args[1]['is_default'] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_pipeline_success(self, quart_test_client):
|
async def test_update_pipeline_success(self, quart_test_client):
|
||||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ async def space_oauth_api():
|
|||||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||||
)
|
)
|
||||||
application = Mock()
|
application = Mock()
|
||||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||||
|
application.directory_projection_service = None
|
||||||
application.persistence_mgr = None
|
application.persistence_mgr = None
|
||||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||||
@@ -125,6 +126,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space/authorize-url',
|
||||||
|
query_string={
|
||||||
|
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||||
|
'cloud_entry': '1',
|
||||||
|
},
|
||||||
|
headers={'Origin': 'http://localhost'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -249,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):
|
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
||||||
application, client = 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(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/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
|
assert (await missing.get_json())['code'] == 1
|
||||||
@@ -260,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'
|
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.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(
|
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||||
'oauth-code',
|
'v4_oauth-code',
|
||||||
[WORKSPACE_UUID],
|
[WORKSPACE_UUID],
|
||||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
{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
|
@pytest.mark.asyncio
|
||||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -276,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
|||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/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
|
assert response.status_code == 200
|
||||||
@@ -375,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
|||||||
|
|
||||||
rejected = await client.post(
|
rejected = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/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(
|
response = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/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 rejected.status_code == 401
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
||||||
application.user_service.verify_jwt_token.assert_not_awaited()
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -394,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
application.user_service.consume_space_oauth_state.reset_mock()
|
application.user_service.consume_space_oauth_state.reset_mock()
|
||||||
application.space_service.exchange_oauth_code.reset_mock()
|
application.space_service.exchange_oauth_code.reset_mock()
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
@@ -414,3 +578,29 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
)
|
)
|
||||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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',
|
||||||
|
user='owner@example.com',
|
||||||
|
account_type='space',
|
||||||
|
status='active',
|
||||||
|
)
|
||||||
|
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',
|
||||||
|
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.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||||
|
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||||
|
|||||||
@@ -440,6 +440,70 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac
|
|||||||
assert (await forbidden.get_json())['code'] == 'permission_denied'
|
assert (await forbidden.get_json())['code'] == 'permission_denied'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_key_context_returns_bound_identity_without_workspace_permission(workspace_api):
|
||||||
|
application, client, _, owner_token = workspace_api
|
||||||
|
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
|
||||||
|
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
|
||||||
|
|
||||||
|
create_response = await client.post(
|
||||||
|
'/api/v1/apikeys',
|
||||||
|
headers=_auth(owner_token, workspace_uuid),
|
||||||
|
json={'name': 'Context probe', 'scopes': []},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
created = (await create_response.get_json())['data']['key']
|
||||||
|
|
||||||
|
missing_auth = await client.get('/api/v1/system/context')
|
||||||
|
assert missing_auth.status_code == 401
|
||||||
|
|
||||||
|
invalid_auth = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'X-API-Key': 'lbk_invalid'},
|
||||||
|
)
|
||||||
|
assert invalid_auth.status_code == 401
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={
|
||||||
|
'X-API-Key': created['key'],
|
||||||
|
'X-Workspace-Id': 'caller-selected-workspace-must-be-ignored',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data'] == {
|
||||||
|
'instance_uuid': application.workspace_service.instance_uuid,
|
||||||
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'api_key_id': created['uuid'],
|
||||||
|
'permissions': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
bearer_response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'Authorization': f'Bearer {created["key"]}'},
|
||||||
|
)
|
||||||
|
assert bearer_response.status_code == 200
|
||||||
|
assert (await bearer_response.get_json())['data']['api_key_id'] == created['uuid']
|
||||||
|
|
||||||
|
jwt_response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'Authorization': f'Bearer {owner_token}'},
|
||||||
|
)
|
||||||
|
assert jwt_response.status_code == 401
|
||||||
|
|
||||||
|
revoke_response = await client.delete(
|
||||||
|
f'/api/v1/apikeys/{created["id"]}',
|
||||||
|
headers=_auth(owner_token, workspace_uuid),
|
||||||
|
)
|
||||||
|
assert revoke_response.status_code == 200
|
||||||
|
|
||||||
|
revoked_response = await client.get(
|
||||||
|
'/api/v1/system/context',
|
||||||
|
headers={'X-API-Key': created['key']},
|
||||||
|
)
|
||||||
|
assert revoked_response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
|
async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
|
||||||
workspace_api,
|
workspace_api,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class _CapacityPluginRuntimeHandler:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.bindings: dict[str, typing.Any] = {}
|
self.bindings: dict[str, typing.Any] = {}
|
||||||
self.reconciled: tuple[typing.Any, ...] = ()
|
self.reconciled: tuple[typing.Any, ...] = ()
|
||||||
|
self.reconcile_timeout: float | None = None
|
||||||
|
|
||||||
def register_installation_binding(
|
def register_installation_binding(
|
||||||
self,
|
self,
|
||||||
@@ -132,8 +133,14 @@ class _CapacityPluginRuntimeHandler:
|
|||||||
def unregister_installation_binding(self, binding) -> None:
|
def unregister_installation_binding(self, binding) -> None:
|
||||||
self.bindings.pop(binding.installation_uuid, None)
|
self.bindings.pop(binding.installation_uuid, None)
|
||||||
|
|
||||||
async def reconcile_plugin_installations(self, desired_states) -> dict:
|
async def reconcile_plugin_installations(
|
||||||
|
self,
|
||||||
|
desired_states,
|
||||||
|
*,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> dict:
|
||||||
self.reconciled = tuple(desired_states)
|
self.reconciled = tuple(desired_states)
|
||||||
|
self.reconcile_timeout = timeout
|
||||||
return {
|
return {
|
||||||
'applied': [],
|
'applied': [],
|
||||||
'removed': [],
|
'removed': [],
|
||||||
@@ -1034,6 +1041,7 @@ class TestPostgreSQLTenantRuntime:
|
|||||||
assert not mcp_loader._hosted_mcp_tasks
|
assert not mcp_loader._hosted_mcp_tasks
|
||||||
assert len(plugin_handler.reconciled) == workspace_count
|
assert len(plugin_handler.reconciled) == workspace_count
|
||||||
assert len(plugin_handler.bindings) == workspace_count
|
assert len(plugin_handler.bindings) == workspace_count
|
||||||
|
assert plugin_handler.reconcile_timeout == 300.0
|
||||||
assert all(count == workspace_count for count in statement_counts.values()), statement_counts
|
assert all(count == workspace_count for count in statement_counts.values()), statement_counts
|
||||||
if max_elapsed is not None:
|
if max_elapsed is not None:
|
||||||
assert elapsed <= max_elapsed
|
assert elapsed <= max_elapsed
|
||||||
|
|||||||
@@ -39,6 +39,35 @@ def _assert_verified_backup(payload: dict) -> None:
|
|||||||
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
||||||
|
|
||||||
|
|
||||||
|
def _temporary_sqlite_files(root: pathlib.Path) -> list[pathlib.Path]:
|
||||||
|
return [*root.rglob('*.creating'), *root.rglob('*.restoring')]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backup_removes_stale_temporary_file_from_interrupted_run(tmp_path):
|
||||||
|
database_path = tmp_path / 'legacy-stale-backup.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
|
try:
|
||||||
|
await create_legacy_resource_schema(engine, instance_uuid='stale-backup')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||||
|
backup_directory = tmp_path / 'migration-backups'
|
||||||
|
backup_directory.mkdir()
|
||||||
|
stale_path = backup_directory / '.legacy-stale-backup-pre-0009-old.creating'
|
||||||
|
unrelated_path = backup_directory / '.another-database-pre-0009-old.creating'
|
||||||
|
stale_path.write_bytes(b'interrupted backup')
|
||||||
|
unrelated_path.write_bytes(b'unrelated backup')
|
||||||
|
|
||||||
|
await sqlite_migration_backup.create_verified_backup(
|
||||||
|
engine,
|
||||||
|
source_revision='0008_mcp_resource_prefs',
|
||||||
|
target_revision='0009_workspace_tenancy',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not stale_path.exists()
|
||||||
|
assert unrelated_path.read_bytes() == b'unrelated backup'
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
||||||
database_path = tmp_path / 'legacy-with-backups.db'
|
database_path = tmp_path / 'legacy-with-backups.db'
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
@@ -59,6 +88,7 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
|
|||||||
}
|
}
|
||||||
for payload in payloads:
|
for payload in payloads:
|
||||||
_assert_verified_backup(payload)
|
_assert_verified_backup(payload)
|
||||||
|
assert _temporary_sqlite_files(tmp_path) == []
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
@@ -100,6 +130,7 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
assert restored[0]['status'] == 'restored_after_failure'
|
assert restored[0]['status'] == 'restored_after_failure'
|
||||||
assert restored[0]['source_revision'] == '0009_workspace_tenancy'
|
assert restored[0]['source_revision'] == '0009_workspace_tenancy'
|
||||||
_assert_verified_backup(restored[0])
|
_assert_verified_backup(restored[0])
|
||||||
|
assert _temporary_sqlite_files(tmp_path) == []
|
||||||
|
|
||||||
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
|
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
|
||||||
await _manager(engine)._run_alembic_migrations()
|
await _manager(engine)._run_alembic_migrations()
|
||||||
@@ -108,6 +139,41 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restore_publish_failure_preserves_current_database(tmp_path, monkeypatch):
|
||||||
|
database_path = tmp_path / 'restore-publish-failure.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
|
try:
|
||||||
|
await create_legacy_resource_schema(engine, instance_uuid='restore-publish-failure')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||||
|
backup = await sqlite_migration_backup.create_verified_backup(
|
||||||
|
engine,
|
||||||
|
source_revision='0008_mcp_resource_prefs',
|
||||||
|
target_revision='0009_workspace_tenancy',
|
||||||
|
)
|
||||||
|
stale_restore_path = tmp_path / f'.{database_path.name}.interrupted.restoring'
|
||||||
|
stale_restore_path.write_bytes(b'interrupted restore')
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.execute(sa.text("UPDATE alembic_version SET version_num = 'failed-revision'"))
|
||||||
|
await engine.dispose()
|
||||||
|
database_before_restore = database_path.read_bytes()
|
||||||
|
real_replace = os.replace
|
||||||
|
|
||||||
|
def fail_restore_publish(source, destination):
|
||||||
|
if pathlib.Path(destination) == database_path:
|
||||||
|
raise OSError('simulated atomic publish failure')
|
||||||
|
return real_replace(source, destination)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sqlite_migration_backup.os, 'replace', fail_restore_publish)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match='atomic publish failure'):
|
||||||
|
await sqlite_migration_backup.restore_verified_backup(engine, backup)
|
||||||
|
|
||||||
|
assert database_path.read_bytes() == database_before_restore
|
||||||
|
assert _temporary_sqlite_files(tmp_path) == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
||||||
database_path = tmp_path / 'legacy-bind-mount.db'
|
database_path = tmp_path / 'legacy-bind-mount.db'
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import json
|
||||||
|
import sqlalchemy
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.bot import BotService
|
from langbot.pkg.api.http.service.bot import BotService
|
||||||
@@ -241,6 +243,29 @@ class TestBotServiceGetRuntimeBotInfo:
|
|||||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||||
|
|
||||||
|
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(
|
||||||
|
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||||
|
),
|
||||||
|
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'name': 'HTTP Bot',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||||
|
|
||||||
|
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||||
|
'https://bot.example.com/bots/http-bot-uuid'
|
||||||
|
)
|
||||||
|
|
||||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -425,10 +450,58 @@ class TestBotServiceCreateBot:
|
|||||||
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
||||||
insert_values = insert_statement.compile().params
|
insert_values = insert_statement.compile().params
|
||||||
assert insert_values['workspace_uuid'] == WORKSPACE_UUID
|
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
|
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:
|
class TestBotServiceUpdateBot:
|
||||||
"""Tests for update_bot method."""
|
"""Tests for update_bot method."""
|
||||||
@@ -605,6 +678,77 @@ class TestBotServiceListEventLogs:
|
|||||||
assert total == 5
|
assert total == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotServiceHttpBotInboundTest:
|
||||||
|
async def test_sends_signed_message_through_public_ingress(self):
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||||
|
)
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'http-bot-uuid',
|
||||||
|
'adapter': 'http_bot',
|
||||||
|
'adapter_config': {
|
||||||
|
'signature_required': True,
|
||||||
|
'inbound_secret': 'test-secret',
|
||||||
|
},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = MagicMock(status=202)
|
||||||
|
session = MagicMock()
|
||||||
|
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||||
|
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'session_id': 'wizard-session',
|
||||||
|
'accepted_message_id': 'in-message',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'http-bot-uuid',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['accepted_message_id'] == 'in-message'
|
||||||
|
request = session.post.call_args
|
||||||
|
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||||
|
payload = json.loads(request.kwargs['data'])
|
||||||
|
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||||
|
headers = request.kwargs['headers']
|
||||||
|
assert headers['X-LB-Timestamp']
|
||||||
|
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||||
|
|
||||||
|
async def test_rejects_non_http_bot(self):
|
||||||
|
service = BotService(SimpleNamespace())
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'telegram-bot',
|
||||||
|
'adapter': 'telegram',
|
||||||
|
'adapter_config': {},
|
||||||
|
'enable': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||||
|
await service.send_http_bot_test_message(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'telegram-bot',
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceSendMessage:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
|||||||
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_search_matches_user_id_or_name_within_workspace(service):
|
||||||
|
context_a = _context(WORKSPACE_A)
|
||||||
|
context_b = _context(WORKSPACE_B)
|
||||||
|
fixtures = [
|
||||||
|
(context_a, 'session-id-match', 'customer-42', 'Alice'),
|
||||||
|
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
|
||||||
|
(context_a, 'session-no-match', 'customer-7', 'Bob'),
|
||||||
|
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
|
||||||
|
]
|
||||||
|
for context, session_id, user_id, user_name in fixtures:
|
||||||
|
await service.record_session_start(
|
||||||
|
context,
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id='same-bot',
|
||||||
|
bot_name='Same Bot',
|
||||||
|
pipeline_id='same-pipeline',
|
||||||
|
pipeline_name='Same Pipeline',
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=user_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
|
||||||
|
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
|
||||||
|
|
||||||
|
assert id_total == 1
|
||||||
|
assert [session['session_id'] for session in by_id] == ['session-id-match']
|
||||||
|
assert name_total == 2
|
||||||
|
assert {session['session_id'] for session in by_name} == {
|
||||||
|
'session-id-match',
|
||||||
|
'session-name-match',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
||||||
context = _context(WORKSPACE_A)
|
context = _context(WORKSPACE_A)
|
||||||
message_id = await _record_message(service, context, 'tool context')
|
message_id = await _record_message(service, context, 'tool context')
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
|
|||||||
result = service.get_oauth_authorize_url('http://localhost/callback')
|
result = service.get_oauth_authorize_url('http://localhost/callback')
|
||||||
|
|
||||||
# Verify
|
# 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
|
assert 'https://space.langbot.app/auth/authorize' in result
|
||||||
|
|
||||||
def test_get_oauth_authorize_url_with_state(self):
|
def test_get_oauth_authorize_url_with_state(self):
|
||||||
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
|
|||||||
'auth_code',
|
'auth_code',
|
||||||
['workspace-1'],
|
['workspace-1'],
|
||||||
{'workspace-1': 1_700_000_000},
|
{'workspace-1': 1_700_000_000},
|
||||||
|
redirect_uri='https://oss.example/auth/space/callback',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert result['access_token'] == 'new_access_token'
|
assert result['access_token'] == 'new_access_token'
|
||||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||||
'code': 'auth_code',
|
'code': 'auth_code',
|
||||||
|
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
'workspace_uuids': ['workspace-1'],
|
'workspace_uuids': ['workspace-1'],
|
||||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||||
@@ -820,6 +824,97 @@ class TestSpaceServiceGetModels:
|
|||||||
await service.get_models()
|
await service.get_models()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpaceServiceGetModelSelection:
|
||||||
|
"""Tests for availability-ranked model selection."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||||
|
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||||
|
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||||
|
service = SpaceService(ap)
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
'uuid': 'best-model',
|
||||||
|
'model_id': 'best-chat-model',
|
||||||
|
'provider': 'provider-1',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'uuid': 'fallback-model',
|
||||||
|
'model_id': 'fallback-chat-model',
|
||||||
|
'provider': 'provider-2',
|
||||||
|
'category': 'chat',
|
||||||
|
'status': 'active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
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)]
|
||||||
|
else:
|
||||||
|
data = models
|
||||||
|
payload = {'code': 0, 'data': data}
|
||||||
|
mock_response = MagicMock(status=200)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||||
|
patch(
|
||||||
|
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||||
|
new=AsyncMock(return_value=payload),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
session = MagicMock()
|
||||||
|
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
get_session.return_value = session
|
||||||
|
|
||||||
|
result = await service.get_model_selection('chat')
|
||||||
|
|
||||||
|
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||||
|
session.get.assert_called_once_with(
|
||||||
|
'https://space.langbot.app/api/v1/models/selection',
|
||||||
|
params={'category': 'chat'},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||||
|
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||||
|
persistence = SimpleNamespace(
|
||||||
|
execute_async=AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_create_mock_result(first_item=None),
|
||||||
|
_create_mock_result(first_item=local_model),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
instance_config=SimpleNamespace(data={}),
|
||||||
|
persistence_mgr=persistence,
|
||||||
|
model_mgr=model_mgr,
|
||||||
|
)
|
||||||
|
service = SpaceService(ap)
|
||||||
|
service.get_model_selection = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||||
|
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
instance_uuid='instance',
|
||||||
|
workspace_uuid='workspace',
|
||||||
|
placement_generation=1,
|
||||||
|
principal=SimpleNamespace(),
|
||||||
|
entitlement_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_recommended_chat_model(context)
|
||||||
|
|
||||||
|
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||||
|
service.get_model_selection.assert_awaited_once_with('chat')
|
||||||
|
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||||
|
assert persistence.execute_async.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
class TestSpaceServiceCreditsCache:
|
class TestSpaceServiceCreditsCache:
|
||||||
"""Tests for credits cache behavior."""
|
"""Tests for credits cache behavior."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
@@ -214,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
|
|||||||
request_sync.assert_called_once_with()
|
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):
|
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
reconcile_execution_projection = Mock()
|
reconcile_execution_projection = Mock()
|
||||||
@@ -735,6 +818,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
|
|||||||
assert second_provider.after_cursors == [1, 2]
|
assert second_provider.after_cursors == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context):
|
||||||
|
application, _session_factory = projection_context
|
||||||
|
|
||||||
|
class _ConcurrentProvider(_Provider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__([_snapshot(1)])
|
||||||
|
self.first_fetch_started = asyncio.Event()
|
||||||
|
self.release_first_fetch = asyncio.Event()
|
||||||
|
self.active_fetches = 0
|
||||||
|
self.max_active_fetches = 0
|
||||||
|
|
||||||
|
async def fetch_events(
|
||||||
|
self,
|
||||||
|
instance_uuid: str,
|
||||||
|
after_cursor: int,
|
||||||
|
limit: int,
|
||||||
|
) -> DirectoryEventBatch:
|
||||||
|
assert instance_uuid == INSTANCE_UUID
|
||||||
|
assert limit == 100
|
||||||
|
self.after_cursors.append(after_cursor)
|
||||||
|
self.active_fetches += 1
|
||||||
|
self.max_active_fetches = max(self.max_active_fetches, self.active_fetches)
|
||||||
|
try:
|
||||||
|
if len(self.after_cursors) == 1:
|
||||||
|
self.first_fetch_started.set()
|
||||||
|
await self.release_first_fetch.wait()
|
||||||
|
cursor = after_cursor + 1
|
||||||
|
return DirectoryEventBatch(
|
||||||
|
instance_uuid=instance_uuid,
|
||||||
|
after_cursor=after_cursor,
|
||||||
|
cursor=cursor,
|
||||||
|
high_water_cursor=cursor,
|
||||||
|
events=(
|
||||||
|
DirectoryEvent(
|
||||||
|
cursor=cursor,
|
||||||
|
uuid=f'40000000-0000-4000-8000-{cursor:012d}',
|
||||||
|
aggregate_uuid=WORKSPACE_UUID,
|
||||||
|
event_type='entitlement.changed',
|
||||||
|
revision=cursor,
|
||||||
|
payload={
|
||||||
|
'workspace_uuid': WORKSPACE_UUID,
|
||||||
|
'entitlement_revision': cursor,
|
||||||
|
},
|
||||||
|
created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.active_fetches -= 1
|
||||||
|
|
||||||
|
provider = _ConcurrentProvider()
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
first = asyncio.create_task(service.sync_once())
|
||||||
|
await provider.first_fetch_started.wait()
|
||||||
|
second = asyncio.create_task(service.sync_once())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
provider.release_first_fetch.set()
|
||||||
|
await asyncio.gather(first, second)
|
||||||
|
|
||||||
|
assert provider.max_active_fetches == 1
|
||||||
|
assert provider.after_cursors == [1, 2]
|
||||||
|
assert service._consumer_cursor == 3
|
||||||
|
|
||||||
|
|
||||||
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
event_two = DirectoryEvent(
|
event_two = DirectoryEvent(
|
||||||
|
|||||||
@@ -964,6 +964,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
|||||||
sa.func.date_trunc('hour', sa.column('timestamp')),
|
sa.func.date_trunc('hour', sa.column('timestamp')),
|
||||||
sa.func.length(sa.literal('value')),
|
sa.func.length(sa.literal('value')),
|
||||||
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
sa.func.nullif(sa.literal('value'), sa.literal('')),
|
||||||
|
sa.func.strftime('%Y-%m-%d %H:00', sa.column('timestamp')),
|
||||||
),
|
),
|
||||||
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
|
||||||
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
|
||||||
@@ -977,6 +978,19 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem
|
|||||||
_validate_scoped_statement_call((statement,), {})
|
_validate_scoped_statement_call((statement,), {})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scoped_session_executes_sqlite_strftime() -> None:
|
||||||
|
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||||
|
try:
|
||||||
|
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
|
||||||
|
result = await uow.session.execute(
|
||||||
|
sa.select(sa.func.strftime('%Y-%m-%d %H:00', sa.literal('2026-08-28 03:45:00')))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scalar_one() == '2026-08-28 03:00'
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_scoped_sql_rejects_public_execution_options() -> None:
|
async def test_scoped_sql_rejects_public_execution_options() -> None:
|
||||||
statement = sa.select(sa.literal(1))
|
statement = sa.select(sa.literal(1))
|
||||||
with pytest.raises(ScopedSessionTransactionError, match='execution options'):
|
with pytest.raises(ScopedSessionTransactionError, match='execution options'):
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""BanWordFilter regression tests for legacy sensitive-word lists.
|
||||||
|
|
||||||
|
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
|
||||||
|
the previous default list (~70 patterns). The filter must keep applying those
|
||||||
|
rules instead of blocking every message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.factories import FakeApp
|
||||||
|
|
||||||
|
|
||||||
|
def _load_banwords():
|
||||||
|
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||||
|
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
|
||||||
|
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||||
|
safe_regex = import_module('langbot.pkg.utils.safe_regex')
|
||||||
|
return banwords, entities, safe_regex
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
|
||||||
|
banwords, entities, _ = _load_banwords()
|
||||||
|
app = FakeApp()
|
||||||
|
app.sensitive_meta = Mock()
|
||||||
|
app.sensitive_meta.data = {
|
||||||
|
'words': words,
|
||||||
|
'mask': mask,
|
||||||
|
'mask_word': mask_word,
|
||||||
|
}
|
||||||
|
return banwords.BanWordFilter(app), entities, app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
|
||||||
|
"""A pre-v4.10.7 word list must not fail closed on every message."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.PASS
|
||||||
|
assert result.replacement == 'hello there, nothing banned'
|
||||||
|
assert result.user_notice == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
|
||||||
|
"""Words past the first 64-pattern batch must still be applied."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide secret-token now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert 'secret-token' not in result.replacement
|
||||||
|
assert '[hidden]' in result.replacement
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_masks_match_in_first_batch():
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'alpha-secret is here')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert result.replacement == '[hidden] is here'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_sensitive_word_regex_still_blocks():
|
||||||
|
filt, entities, _ = _filter_with_words(['(unclosed'])
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'any message')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oversized_word_list_is_blocked():
|
||||||
|
"""Configured rules must never be silently skipped when the list is oversized."""
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_match_beyond_total_cap_cannot_bypass_filter():
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide late-secret now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
@@ -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 = Mock()
|
||||||
ap.logger = Mock()
|
ap.logger = Mock()
|
||||||
pipeline_config = {
|
pipeline_config = {
|
||||||
@@ -63,6 +63,7 @@ def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
|
|||||||
'n8n-service-api': {
|
'n8n-service-api': {
|
||||||
'webhook-url': 'http://test-n8n/webhook',
|
'webhook-url': 'http://test-n8n/webhook',
|
||||||
'output-key': output_key,
|
'output-key': output_key,
|
||||||
|
'response-handling': response_handling,
|
||||||
'auth-type': 'none',
|
'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 httpclient.get_session() returning a session whose post() yields response_bytes."""
|
||||||
mock_response = make_mock_response([response_bytes], status=status)
|
mock_response = make_mock_response([response_bytes], status=status)
|
||||||
mock_response.status = status
|
mock_response.status = status
|
||||||
|
mock_response.headers = {}
|
||||||
|
|
||||||
mock_cm = AsyncMock()
|
mock_cm = AsyncMock()
|
||||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
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'
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_call_webhook_stream_adapter_stream_format():
|
async def test_call_webhook_stream_adapter_stream_format():
|
||||||
"""Stream adapter + stream format → MessageChunks, last is_final."""
|
"""Stream adapter + stream format → MessageChunks, last is_final."""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user