mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-10 20:07:14 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b096167f4 | |||
| 578565dbe2 |
@@ -92,17 +92,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Live Demo
|
|
||||||
|
|
||||||
**Try it now:** https://demo.langbot.dev/
|
|
||||||
|
|
||||||
- Email: `demo@langbot.app`
|
|
||||||
- Password: `langbot123456`
|
|
||||||
|
|
||||||
_Note: Public demo environment. Do not enter sensitive information._
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Supported Platforms
|
## Supported Platforms
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
@@ -178,6 +167,17 @@ LangBot is **agent-friendly by design** — your coding agents (Claude Code, Cod
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Live Demo
|
||||||
|
|
||||||
|
**Try it now:** https://demo.langbot.dev/
|
||||||
|
|
||||||
|
- Email: `demo@langbot.app`
|
||||||
|
- Password: `langbot123456`
|
||||||
|
|
||||||
|
_Note: Public demo environment. Do not enter sensitive information._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
[](https://discord.gg/wdNEHETs87)
|
[](https://discord.gg/wdNEHETs87)
|
||||||
@@ -186,6 +186,12 @@ LangBot is **agent-friendly by design** — your coding agents (Claude Code, Cod
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better:
|
Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better:
|
||||||
|
|||||||
+16
-10
@@ -92,16 +92,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 在线演示
|
|
||||||
|
|
||||||
**立即体验:** https://demo.langbot.dev/
|
|
||||||
- 邮箱:`demo@langbot.app`
|
|
||||||
- 密码:`langbot123456`
|
|
||||||
|
|
||||||
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 支持的平台
|
## 支持的平台
|
||||||
|
|
||||||
| 平台 | 状态 | 备注 |
|
| 平台 | 状态 | 备注 |
|
||||||
@@ -180,6 +170,16 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 在线演示
|
||||||
|
|
||||||
|
**立即体验:** https://demo.langbot.dev/
|
||||||
|
- 邮箱:`demo@langbot.app`
|
||||||
|
- 密码:`langbot123456`
|
||||||
|
|
||||||
|
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 为 AI Agent 而生 🤖
|
## 为 AI Agent 而生 🤖
|
||||||
|
|
||||||
LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、扩展和部署 LangBot:
|
LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、扩展和部署 LangBot:
|
||||||
@@ -203,6 +203,12 @@ LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star 趋势
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 贡献者
|
## 贡献者
|
||||||
|
|
||||||
感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助:
|
感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助:
|
||||||
|
|||||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Demo en Vivo
|
|
||||||
|
|
||||||
**Pruébelo ahora:** https://demo.langbot.dev/
|
|
||||||
- Correo electrónico: `demo@langbot.app`
|
|
||||||
- Contraseña: `langbot123456`
|
|
||||||
|
|
||||||
*Nota: Entorno de demostración público. No ingrese información confidencial.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plataformas Soportadas
|
## Plataformas Soportadas
|
||||||
|
|
||||||
| Plataforma | Estado | Notas |
|
| Plataforma | Estado | Notas |
|
||||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Demo en Vivo
|
||||||
|
|
||||||
|
**Pruébelo ahora:** https://demo.langbot.dev/
|
||||||
|
- Correo electrónico: `demo@langbot.app`
|
||||||
|
- Contraseña: `langbot123456`
|
||||||
|
|
||||||
|
*Nota: Entorno de demostración público. No ingrese información confidencial.*
|
||||||
|
|
||||||
## Diseñado para Agentes de IA 🤖
|
## Diseñado para Agentes de IA 🤖
|
||||||
|
|
||||||
LangBot es **agent-friendly por diseño** —— tus agentes de codificación (Claude Code, Codex, Copilot, Cursor, …) pueden operar, extender y desplegar LangBot con soporte de primera clase:
|
LangBot es **agent-friendly por diseño** —— tus agentes de codificación (Claude Code, Codex, Copilot, Cursor, …) pueden operar, extender y desplegar LangBot con soporte de primera clase:
|
||||||
@@ -184,6 +182,12 @@ LangBot es **agent-friendly por diseño** —— tus agentes de codificación (C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Historial de Stars
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Colaboradores
|
## Colaboradores
|
||||||
|
|
||||||
Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot:
|
Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot:
|
||||||
|
|||||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Démo en Ligne
|
|
||||||
|
|
||||||
**Essayez maintenant :** https://demo.langbot.dev/
|
|
||||||
- Email : `demo@langbot.app`
|
|
||||||
- Mot de passe : `langbot123456`
|
|
||||||
|
|
||||||
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plateformes Supportées
|
## Plateformes Supportées
|
||||||
|
|
||||||
| Plateforme | Statut | Notes |
|
| Plateforme | Statut | Notes |
|
||||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Démo en Ligne
|
||||||
|
|
||||||
|
**Essayez maintenant :** https://demo.langbot.dev/
|
||||||
|
- Email : `demo@langbot.app`
|
||||||
|
- Mot de passe : `langbot123456`
|
||||||
|
|
||||||
|
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
|
||||||
|
|
||||||
## Conçu pour les agents IA 🤖
|
## Conçu pour les agents IA 🤖
|
||||||
|
|
||||||
LangBot est **agent-friendly par conception** —— vos agents de codage (Claude Code, Codex, Copilot, Cursor, …) peuvent exploiter, étendre et déployer LangBot avec un support de premier ordre :
|
LangBot est **agent-friendly par conception** —— vos agents de codage (Claude Code, Codex, Copilot, Cursor, …) peuvent exploiter, étendre et déployer LangBot avec un support de premier ordre :
|
||||||
@@ -184,6 +182,12 @@ LangBot est **agent-friendly par conception** —— vos agents de codage (Claud
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Historique des Stars
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Contributeurs
|
## Contributeurs
|
||||||
|
|
||||||
Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot :
|
Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot :
|
||||||
|
|||||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ライブデモ
|
|
||||||
|
|
||||||
**今すぐ試す:** https://demo.langbot.dev/
|
|
||||||
- メール: `demo@langbot.app`
|
|
||||||
- パスワード: `langbot123456`
|
|
||||||
|
|
||||||
*注意: 公開デモ環境です。機密情報を入力しないでください。*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 対応プラットフォーム
|
## 対応プラットフォーム
|
||||||
|
|
||||||
| プラットフォーム | ステータス | 備考 |
|
| プラットフォーム | ステータス | 備考 |
|
||||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ライブデモ
|
||||||
|
|
||||||
|
**今すぐ試す:** https://demo.langbot.dev/
|
||||||
|
- メール: `demo@langbot.app`
|
||||||
|
- パスワード: `langbot123456`
|
||||||
|
|
||||||
|
*注意: 公開デモ環境です。機密情報を入力しないでください。*
|
||||||
|
|
||||||
## AI エージェントのために 🤖
|
## AI エージェントのために 🤖
|
||||||
|
|
||||||
LangBot は **設計段階からエージェントフレンドリー** です。お使いのコーディングエージェント(Claude Code、Codex、Copilot、Cursor など)が、ファーストクラスのサポートで LangBot を操作・拡張・デプロイできます:
|
LangBot は **設計段階からエージェントフレンドリー** です。お使いのコーディングエージェント(Claude Code、Codex、Copilot、Cursor など)が、ファーストクラスのサポートで LangBot を操作・拡張・デプロイできます:
|
||||||
@@ -184,6 +182,12 @@ LangBot は **設計段階からエージェントフレンドリー** です。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star 推移
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## コントリビューター
|
## コントリビューター
|
||||||
|
|
||||||
LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します:
|
LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します:
|
||||||
|
|||||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 라이브 데모
|
|
||||||
|
|
||||||
**지금 체험:** https://demo.langbot.dev/
|
|
||||||
- 이메일: `demo@langbot.app`
|
|
||||||
- 비밀번호: `langbot123456`
|
|
||||||
|
|
||||||
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 지원 플랫폼
|
## 지원 플랫폼
|
||||||
|
|
||||||
| 플랫폼 | 상태 | 비고 |
|
| 플랫폼 | 상태 | 비고 |
|
||||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 라이브 데모
|
||||||
|
|
||||||
|
**지금 체험:** https://demo.langbot.dev/
|
||||||
|
- 이메일: `demo@langbot.app`
|
||||||
|
- 비밀번호: `langbot123456`
|
||||||
|
|
||||||
|
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
|
||||||
|
|
||||||
## AI 에이전트를 위한 설계 🤖
|
## AI 에이전트를 위한 설계 🤖
|
||||||
|
|
||||||
LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩 에이전트(Claude Code, Codex, Copilot, Cursor 등)가 일급 지원으로 LangBot을 운영·확장·배포할 수 있습니다:
|
LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩 에이전트(Claude Code, Codex, Copilot, Cursor 등)가 일급 지원으로 LangBot을 운영·확장·배포할 수 있습니다:
|
||||||
@@ -184,6 +182,12 @@ LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star 추이
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 기여자
|
## 기여자
|
||||||
|
|
||||||
LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다:
|
LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다:
|
||||||
|
|||||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Демо
|
|
||||||
|
|
||||||
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
|
|
||||||
- Email: `demo@langbot.app`
|
|
||||||
- Пароль: `langbot123456`
|
|
||||||
|
|
||||||
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Поддерживаемые платформы
|
## Поддерживаемые платформы
|
||||||
|
|
||||||
| Платформа | Статус | Примечания |
|
| Платформа | Статус | Примечания |
|
||||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Демо
|
||||||
|
|
||||||
|
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
|
||||||
|
- Email: `demo@langbot.app`
|
||||||
|
- Пароль: `langbot123456`
|
||||||
|
|
||||||
|
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
|
||||||
|
|
||||||
## Создано для ИИ-агентов 🤖
|
## Создано для ИИ-агентов 🤖
|
||||||
|
|
||||||
LangBot **дружелюбен к агентам по своей архитектуре** —— ваши кодинг-агенты (Claude Code, Codex, Copilot, Cursor и др.) могут управлять, расширять и развёртывать LangBot с первоклассной поддержкой:
|
LangBot **дружелюбен к агентам по своей архитектуре** —— ваши кодинг-агенты (Claude Code, Codex, Copilot, Cursor и др.) могут управлять, расширять и развёртывать LangBot с первоклассной поддержкой:
|
||||||
@@ -184,6 +182,12 @@ LangBot **дружелюбен к агентам по своей архитек
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## История Stars
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Участники
|
## Участники
|
||||||
|
|
||||||
Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше:
|
Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше:
|
||||||
|
|||||||
+14
-10
@@ -93,16 +93,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 線上演示
|
|
||||||
|
|
||||||
**立即體驗:** https://demo.langbot.dev/
|
|
||||||
- 信箱:`demo@langbot.app`
|
|
||||||
- 密碼:`langbot123456`
|
|
||||||
|
|
||||||
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 支援的平台
|
## 支援的平台
|
||||||
|
|
||||||
| 平台 | 狀態 | 備註 |
|
| 平台 | 狀態 | 備註 |
|
||||||
@@ -179,6 +169,14 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 線上演示
|
||||||
|
|
||||||
|
**立即體驗:** https://demo.langbot.dev/
|
||||||
|
- 信箱:`demo@langbot.app`
|
||||||
|
- 密碼:`langbot123456`
|
||||||
|
|
||||||
|
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
|
||||||
|
|
||||||
## 為 AI Agent 而生 🤖
|
## 為 AI Agent 而生 🤖
|
||||||
|
|
||||||
LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、擴充和部署 LangBot:
|
LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、擴充和部署 LangBot:
|
||||||
@@ -202,6 +200,12 @@ LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude C
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star 趨勢
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 貢獻者
|
## 貢獻者
|
||||||
|
|
||||||
感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助:
|
感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助:
|
||||||
|
|||||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Demo trực tuyến
|
|
||||||
|
|
||||||
**Thử ngay:** https://demo.langbot.dev/
|
|
||||||
- Email: `demo@langbot.app`
|
|
||||||
- Mật khẩu: `langbot123456`
|
|
||||||
|
|
||||||
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nền tảng được hỗ trợ
|
## Nền tảng được hỗ trợ
|
||||||
|
|
||||||
| Nền tảng | Trạng thái | Ghi chú |
|
| Nền tảng | Trạng thái | Ghi chú |
|
||||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Demo trực tuyến
|
||||||
|
|
||||||
|
**Thử ngay:** https://demo.langbot.dev/
|
||||||
|
- Email: `demo@langbot.app`
|
||||||
|
- Mật khẩu: `langbot123456`
|
||||||
|
|
||||||
|
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
|
||||||
|
|
||||||
## Được xây dựng cho AI Agent 🤖
|
## Được xây dựng cho AI Agent 🤖
|
||||||
|
|
||||||
LangBot **thân thiện với agent ngay từ thiết kế** —— các coding agent của bạn (Claude Code, Codex, Copilot, Cursor, …) có thể vận hành, mở rộng và triển khai LangBot với sự hỗ trợ hạng nhất:
|
LangBot **thân thiện với agent ngay từ thiết kế** —— các coding agent của bạn (Claude Code, Codex, Copilot, Cursor, …) có thể vận hành, mở rộng và triển khai LangBot với sự hỗ trợ hạng nhất:
|
||||||
@@ -184,6 +182,12 @@ LangBot **thân thiện với agent ngay từ thiết kế** —— các coding
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Lịch sử Star
|
||||||
|
|
||||||
|
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Người đóng góp
|
## Người đóng góp
|
||||||
|
|
||||||
Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn:
|
Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.6"
|
version = "4.10.5"
|
||||||
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"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -109,62 +109,6 @@ class AsyncDifyServiceClient:
|
|||||||
if chunk.startswith('data:'):
|
if chunk.startswith('data:'):
|
||||||
yield json.loads(chunk[5:])
|
yield json.loads(chunk[5:])
|
||||||
|
|
||||||
async def workflow_submit(
|
|
||||||
self,
|
|
||||||
form_token: str,
|
|
||||||
workflow_run_id: str,
|
|
||||||
inputs: dict[str, typing.Any],
|
|
||||||
user: str,
|
|
||||||
action: str = '',
|
|
||||||
timeout: float = 120.0,
|
|
||||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
|
||||||
"""Submit human input to resume a paused workflow, then stream events.
|
|
||||||
|
|
||||||
1. POST /form/human_input/{form_token} to submit the form
|
|
||||||
2. GET /workflow/{task_id}/events to stream the resumed workflow events
|
|
||||||
"""
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Authorization': f'Bearer {self.api_key}',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
base_url=self.base_url,
|
|
||||||
trust_env=True,
|
|
||||||
timeout=timeout,
|
|
||||||
) as client:
|
|
||||||
# Step 1: Submit the form
|
|
||||||
payload: dict[str, typing.Any] = {
|
|
||||||
'inputs': inputs if isinstance(inputs, dict) else {},
|
|
||||||
'user': user,
|
|
||||||
'action': action,
|
|
||||||
}
|
|
||||||
|
|
||||||
submit_resp = await client.post(
|
|
||||||
f'/form/human_input/{form_token}',
|
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
if submit_resp.status_code != 200:
|
|
||||||
raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}')
|
|
||||||
|
|
||||||
# Step 2: Stream resumed workflow events
|
|
||||||
async with client.stream(
|
|
||||||
'GET',
|
|
||||||
f'/workflow/{workflow_run_id}/events',
|
|
||||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
|
||||||
params={'user': user},
|
|
||||||
) as r:
|
|
||||||
if r.status_code != 200:
|
|
||||||
body = (await r.aread()).decode(errors='replace')
|
|
||||||
raise DifyAPIError(f'{r.status_code} {body}')
|
|
||||||
async for chunk in r.aiter_lines():
|
|
||||||
if chunk.strip() == '':
|
|
||||||
continue
|
|
||||||
if chunk.startswith('data:'):
|
|
||||||
yield json.loads(chunk[5:])
|
|
||||||
|
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
self,
|
self,
|
||||||
file: httpx._types.FileTypes,
|
file: httpx._types.FileTypes,
|
||||||
|
|||||||
@@ -1,48 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
import typing
|
|
||||||
import uuid
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Awaitable, Callable, Optional
|
from typing import Callable
|
||||||
import dingtalk_stream # type: ignore
|
import dingtalk_stream # type: ignore
|
||||||
import websockets
|
import websockets
|
||||||
from .EchoHandler import EchoTextHandler
|
from .EchoHandler import EchoTextHandler
|
||||||
from .card_callback import DingTalkCardActionHandler
|
|
||||||
from .dingtalkevent import DingTalkEvent
|
from .dingtalkevent import DingTalkEvent
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
|
|
||||||
|
|
||||||
|
|
||||||
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
|
|
||||||
|
|
||||||
|
|
||||||
def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
|
|
||||||
"""DingTalk cardParamMap only accepts string values.
|
|
||||||
|
|
||||||
Keep callers free to pass structured values for template variables such
|
|
||||||
as button groups or select options, then encode them once at the API
|
|
||||||
boundary.
|
|
||||||
"""
|
|
||||||
if not card_param_map:
|
|
||||||
return {}
|
|
||||||
result = {}
|
|
||||||
for key, value in card_param_map.items():
|
|
||||||
if value is None:
|
|
||||||
result[key] = ''
|
|
||||||
elif isinstance(value, str):
|
|
||||||
result[key] = value
|
|
||||||
else:
|
|
||||||
result[key] = json.dumps(value, ensure_ascii=False)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class DingTalkClient:
|
class DingTalkClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -52,7 +21,6 @@ class DingTalkClient:
|
|||||||
robot_code: str,
|
robot_code: str,
|
||||||
markdown_card: bool,
|
markdown_card: bool,
|
||||||
logger: None,
|
logger: None,
|
||||||
card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None,
|
|
||||||
):
|
):
|
||||||
"""初始化 WebSocket 连接并自动启动"""
|
"""初始化 WebSocket 连接并自动启动"""
|
||||||
self.credential = dingtalk_stream.Credential(client_id, client_secret)
|
self.credential = dingtalk_stream.Credential(client_id, client_secret)
|
||||||
@@ -62,14 +30,6 @@ class DingTalkClient:
|
|||||||
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
|
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
|
||||||
self.EchoTextHandler = EchoTextHandler(self)
|
self.EchoTextHandler = EchoTextHandler(self)
|
||||||
self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)
|
self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)
|
||||||
# STREAM-mode card action button click handler. Forwards parsed payload
|
|
||||||
# to the adapter so it can resume paused Dify workflows.
|
|
||||||
self.card_action_callback = card_action_callback
|
|
||||||
self.card_action_handler = DingTalkCardActionHandler(self.client, self._on_card_action)
|
|
||||||
self.client.register_callback_handler(
|
|
||||||
dingtalk_stream.handlers.CallbackHandler.TOPIC_CARD_CALLBACK,
|
|
||||||
self.card_action_handler,
|
|
||||||
)
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -79,24 +39,8 @@ class DingTalkClient:
|
|||||||
self.access_token_expiry_time = ''
|
self.access_token_expiry_time = ''
|
||||||
self.markdown_card = markdown_card
|
self.markdown_card = markdown_card
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
# Legacy access_token used by the OLD oapi.dingtalk.com endpoints
|
|
||||||
# (e.g. /media/upload, which is the only documented way to get an
|
|
||||||
# `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0
|
|
||||||
# token doesn't work there — different auth domain.
|
|
||||||
self.legacy_access_token = ''
|
|
||||||
self.legacy_access_token_expiry_time: typing.Optional[float] = None
|
|
||||||
self._stopped = False # Flag to control the event loop
|
self._stopped = False # Flag to control the event loop
|
||||||
|
|
||||||
async def _on_card_action(self, payload: dict) -> None:
|
|
||||||
"""Dispatch a parsed card-action payload to the adapter callback."""
|
|
||||||
if self.card_action_callback is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await self.card_action_callback(payload)
|
|
||||||
except Exception:
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(f'DingTalk card action callback error: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
async def get_access_token(self):
|
async def get_access_token(self):
|
||||||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
@@ -485,35 +429,18 @@ class DingTalkClient:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|
||||||
# For enterprise-internal robots, robotCode == AppKey (client_id).
|
|
||||||
# The dedicated robot_code field is only required for scenario-group
|
|
||||||
# robots or third-party robots; fall back to client_id when empty so
|
|
||||||
# the common single-bot setup keeps working without manual config.
|
|
||||||
robot_code = self.robot_code or self.key
|
|
||||||
data = {
|
data = {
|
||||||
'robotCode': robot_code,
|
'robotCode': self.robot_code,
|
||||||
'userIds': [target_id],
|
'userIds': [target_id],
|
||||||
'msgKey': 'sampleText',
|
'msgKey': 'sampleText',
|
||||||
'msgParam': json.dumps({'content': content}),
|
'msgParam': json.dumps({'content': content}),
|
||||||
}
|
}
|
||||||
_stdout_logger.info(
|
|
||||||
'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d',
|
|
||||||
robot_code,
|
|
||||||
target_id,
|
|
||||||
len(content),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
_stdout_logger.info(
|
|
||||||
'DingTalk send_proactive_message_to_one response: status=%d body=%s',
|
|
||||||
response.status_code,
|
|
||||||
response.text[:500],
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
_stdout_logger.exception('DingTalk send_proactive_message_to_one error')
|
|
||||||
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||||
|
|
||||||
@@ -529,7 +456,7 @@ class DingTalkClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'robotCode': self.robot_code or self.key,
|
'robotCode': self.robot_code,
|
||||||
'openConversationId': target_id,
|
'openConversationId': target_id,
|
||||||
'msgKey': 'sampleText',
|
'msgKey': 'sampleText',
|
||||||
'msgParam': json.dumps({'content': content}),
|
'msgParam': json.dumps({'content': content}),
|
||||||
@@ -550,334 +477,47 @@ class DingTalkClient:
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
card_auto_layout: bool = False,
|
card_auto_layout: bool = False,
|
||||||
):
|
):
|
||||||
"""Create + deliver the streaming chat card for a chatbot reply.
|
card_data = {}
|
||||||
|
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
||||||
|
card_data['content'] = ''
|
||||||
|
|
||||||
Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
|
# 将用户的消息内容作为卡片的查询参数,方便后续处理
|
||||||
`(None, out_track_id)` to keep call sites compatible with the
|
|
||||||
previous `(card_instance, card_instance_id)` shape — the first slot
|
|
||||||
is unused now that everything is driven by out_track_id.
|
|
||||||
"""
|
|
||||||
out_track_id = uuid.uuid4().hex
|
|
||||||
is_group = str(incoming_message.conversation_type) == '2'
|
|
||||||
if is_group:
|
|
||||||
open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}'
|
|
||||||
else:
|
|
||||||
open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
|
|
||||||
|
|
||||||
card_param_map = {'content': ''}
|
|
||||||
if incoming_message.message_type == 'text':
|
if incoming_message.message_type == 'text':
|
||||||
card_param_map['query'] = incoming_message.get_text_list()[0]
|
card_data['query'] = incoming_message.get_text_list()[0]
|
||||||
else:
|
else:
|
||||||
card_param_map['query'] = '...'
|
card_data['query'] = '...'
|
||||||
|
|
||||||
await self.create_and_deliver_card(
|
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
||||||
card_template_id=temp_card_id,
|
# print(card_instance)
|
||||||
out_track_id=out_track_id,
|
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
||||||
open_space_id=open_space_id,
|
card_instance_id = await card_instance.async_create_and_deliver_card(
|
||||||
is_group=is_group,
|
temp_card_id,
|
||||||
card_param_map=card_param_map,
|
card_data,
|
||||||
card_data_config={'autoLayout': card_auto_layout},
|
|
||||||
)
|
)
|
||||||
return None, out_track_id
|
return card_instance, card_instance_id
|
||||||
|
|
||||||
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
|
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
|
||||||
"""Stream a single chunk into an existing card's `content` field."""
|
content_key = 'content'
|
||||||
try:
|
try:
|
||||||
await self.streaming_update_card(
|
await card_instance.async_streaming(
|
||||||
out_track_id=card_instance_id,
|
card_instance_id,
|
||||||
content_key='content',
|
content_key=content_key,
|
||||||
content_value=content,
|
content_value=content,
|
||||||
append=False,
|
append=False,
|
||||||
finished=is_final,
|
finished=is_final,
|
||||||
failed=False,
|
failed=False,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.logger:
|
|
||||||
self.logger.exception(e)
|
self.logger.exception(e)
|
||||||
await self.streaming_update_card(
|
await card_instance.async_streaming(
|
||||||
out_track_id=card_instance_id,
|
card_instance_id,
|
||||||
content_key='content',
|
content_key=content_key,
|
||||||
content_value='',
|
content_value='',
|
||||||
append=False,
|
append=False,
|
||||||
finished=is_final,
|
finished=is_final,
|
||||||
failed=True,
|
failed=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def create_and_deliver_card(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
card_template_id: str,
|
|
||||||
out_track_id: str,
|
|
||||||
open_space_id: str,
|
|
||||||
is_group: bool,
|
|
||||||
card_param_map: Optional[dict] = None,
|
|
||||||
callback_type: str = 'STREAM',
|
|
||||||
callback_route_key: Optional[str] = None,
|
|
||||||
support_forward: bool = True,
|
|
||||||
dynamic_data_source_configs: Optional[list] = None,
|
|
||||||
card_data_config: Optional[dict] = None,
|
|
||||||
at_user_ids: Optional[dict] = None,
|
|
||||||
recipients: Optional[list] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""POST /v1.0/card/instances/createAndDeliver.
|
|
||||||
|
|
||||||
Mirrors the SDK's `async_create_and_deliver_card` shape but exposes
|
|
||||||
the dynamic-data-source config slot so we can register a pull URL
|
|
||||||
for variable-length button lists.
|
|
||||||
"""
|
|
||||||
if not await self.check_access_token():
|
|
||||||
await self.get_access_token()
|
|
||||||
|
|
||||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
|
||||||
if card_data_config is not None:
|
|
||||||
cardData['config'] = json.dumps(card_data_config)
|
|
||||||
|
|
||||||
body: dict = {
|
|
||||||
'cardTemplateId': card_template_id,
|
|
||||||
'outTrackId': out_track_id,
|
|
||||||
'cardData': cardData,
|
|
||||||
'callbackType': callback_type,
|
|
||||||
'openSpaceId': open_space_id,
|
|
||||||
'imGroupOpenSpaceModel': {'supportForward': support_forward},
|
|
||||||
'imRobotOpenSpaceModel': {'supportForward': support_forward},
|
|
||||||
}
|
|
||||||
if callback_type == 'HTTP' and callback_route_key:
|
|
||||||
body['callbackRouteKey'] = callback_route_key
|
|
||||||
|
|
||||||
if is_group:
|
|
||||||
deliver: dict = {'robotCode': self.robot_code or self.key}
|
|
||||||
if at_user_ids:
|
|
||||||
deliver['atUserIds'] = at_user_ids
|
|
||||||
if recipients is not None:
|
|
||||||
deliver['recipients'] = recipients
|
|
||||||
body['imGroupOpenDeliverModel'] = deliver
|
|
||||||
else:
|
|
||||||
body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'}
|
|
||||||
|
|
||||||
if dynamic_data_source_configs:
|
|
||||||
body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs}
|
|
||||||
|
|
||||||
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver'
|
|
||||||
headers = {
|
|
||||||
'x-acs-dingtalk-access-token': self.access_token,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
_stdout_logger.info(
|
|
||||||
'DingTalk createAndDeliver request body: %s',
|
|
||||||
json.dumps(body, ensure_ascii=False)[:1500],
|
|
||||||
)
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(url, headers=headers, json=body, timeout=30.0)
|
|
||||||
if response.status_code == 200:
|
|
||||||
_stdout_logger.info(
|
|
||||||
'DingTalk createAndDeliver response: %s',
|
|
||||||
response.text[:500],
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
_stdout_logger.error(
|
|
||||||
'DingTalk createAndDeliver failed: status=%s body=%s',
|
|
||||||
response.status_code,
|
|
||||||
response.text,
|
|
||||||
)
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(
|
|
||||||
f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}'
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
_stdout_logger.exception('DingTalk createAndDeliver error')
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}')
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def streaming_update_card(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
out_track_id: str,
|
|
||||||
content_key: str,
|
|
||||||
content_value: str,
|
|
||||||
append: bool,
|
|
||||||
finished: bool,
|
|
||||||
failed: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""PUT /v1.0/card/streaming.
|
|
||||||
|
|
||||||
Replaces `dingtalk_stream.AICardReplier.async_streaming` — same body
|
|
||||||
shape (outTrackId / guid / key / content / isFull / isFinalize /
|
|
||||||
isError) per the SDK source.
|
|
||||||
"""
|
|
||||||
if not await self.check_access_token():
|
|
||||||
await self.get_access_token()
|
|
||||||
|
|
||||||
body = {
|
|
||||||
'outTrackId': out_track_id,
|
|
||||||
'guid': uuid.uuid4().hex,
|
|
||||||
'key': content_key,
|
|
||||||
'content': content_value,
|
|
||||||
'isFull': not append,
|
|
||||||
'isFinalize': finished,
|
|
||||||
'isError': failed,
|
|
||||||
}
|
|
||||||
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming'
|
|
||||||
headers = {
|
|
||||||
'x-acs-dingtalk-access-token': self.access_token,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return True
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(
|
|
||||||
f'DingTalk card streaming failed: status={response.status_code} body={response.text}'
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(f'DingTalk card streaming error: {traceback.format_exc()}')
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def update_card_data(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
out_track_id: str,
|
|
||||||
card_param_map: Optional[dict] = None,
|
|
||||||
private_data: Optional[dict] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""PUT /v1.0/card/instances — non-streaming card content update."""
|
|
||||||
if not await self.check_access_token():
|
|
||||||
await self.get_access_token()
|
|
||||||
|
|
||||||
body: dict = {
|
|
||||||
'outTrackId': out_track_id,
|
|
||||||
'cardData': {'cardParamMap': _stringify_card_param_map(card_param_map)},
|
|
||||||
}
|
|
||||||
if private_data:
|
|
||||||
body['privateData'] = private_data
|
|
||||||
|
|
||||||
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances'
|
|
||||||
headers = {
|
|
||||||
'x-acs-dingtalk-access-token': self.access_token,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
_stdout_logger.info(
|
|
||||||
'DingTalk update_card_data request: out_track_id=%s body=%s',
|
|
||||||
out_track_id,
|
|
||||||
json.dumps(body, ensure_ascii=False)[:1500],
|
|
||||||
)
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
|
||||||
_stdout_logger.info(
|
|
||||||
'DingTalk update_card_data response: status=%d body=%s',
|
|
||||||
response.status_code,
|
|
||||||
response.text[:300],
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return True
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(
|
|
||||||
f'DingTalk update card failed: status={response.status_code} body={response.text}'
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
_stdout_logger.exception('DingTalk update_card_data error')
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}')
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_legacy_access_token(self) -> Optional[str]:
|
|
||||||
"""Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a
|
|
||||||
different auth domain from the v1.0 token cached in
|
|
||||||
``self.access_token`` — only the legacy token authorises the
|
|
||||||
``/media/upload`` endpoint that returns an ``@xxx`` media_id
|
|
||||||
consumable by card components like Avatar.imageUrl.
|
|
||||||
|
|
||||||
Returns the token string on success, None on failure. Caches
|
|
||||||
with a 60s safety margin before the documented 7200s expiry.
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
if (
|
|
||||||
self.legacy_access_token
|
|
||||||
and self.legacy_access_token_expiry_time
|
|
||||||
and now < self.legacy_access_token_expiry_time
|
|
||||||
):
|
|
||||||
return self.legacy_access_token
|
|
||||||
|
|
||||||
url = 'https://oapi.dingtalk.com/gettoken'
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0)
|
|
||||||
data = response.json() if response.status_code == 200 else {}
|
|
||||||
if data.get('errcode') == 0 and data.get('access_token'):
|
|
||||||
self.legacy_access_token = data['access_token']
|
|
||||||
expires_in = int(data.get('expires_in', 7200))
|
|
||||||
self.legacy_access_token_expiry_time = now + expires_in - 60
|
|
||||||
return self.legacy_access_token
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(
|
|
||||||
f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
_stdout_logger.exception('DingTalk legacy gettoken error')
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def upload_image_media(self, file_path: str) -> Optional[str]:
|
|
||||||
"""Upload an image file to DingTalk media storage and return the
|
|
||||||
``@xxx`` media_id, which can be passed straight into card variables
|
|
||||||
like Avatar.imageUrl. Endpoint:
|
|
||||||
|
|
||||||
POST https://oapi.dingtalk.com/media/upload?access_token=…&type=image
|
|
||||||
|
|
||||||
Returns the media_id on success, None on any failure (caller
|
|
||||||
should handle a None gracefully — DingTalk falls back to a
|
|
||||||
default avatar when imageUrl is empty/unknown).
|
|
||||||
"""
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
token = await self.get_legacy_access_token()
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = 'https://oapi.dingtalk.com/media/upload'
|
|
||||||
try:
|
|
||||||
with open(file_path, 'rb') as f:
|
|
||||||
file_bytes = f.read()
|
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
# Best-effort content-type guess; DingTalk accepts the major image
|
|
||||||
# mime types and otherwise infers from the bytes.
|
|
||||||
ext = os.path.splitext(file_name)[1].lower().lstrip('.')
|
|
||||||
mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
|
|
||||||
ext, 'application/octet-stream'
|
|
||||||
)
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
url,
|
|
||||||
params={'access_token': token, 'type': 'image'},
|
|
||||||
files={'media': (file_name, file_bytes, mime)},
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
data = response.json() if response.status_code == 200 else {}
|
|
||||||
if data.get('errcode') == 0 and data.get('media_id'):
|
|
||||||
_stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
|
|
||||||
return data['media_id']
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(
|
|
||||||
f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
_stdout_logger.exception('DingTalk upload_image_media error')
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""启动 WebSocket 连接,监听消息"""
|
"""启动 WebSocket 连接,监听消息"""
|
||||||
self._stopped = False
|
self._stopped = False
|
||||||
@@ -885,10 +525,7 @@ class DingTalkClient:
|
|||||||
|
|
||||||
while not self._stopped:
|
while not self._stopped:
|
||||||
try:
|
try:
|
||||||
# open_connection performs blocking network I/O in the DingTalk SDK.
|
connection = self.client.open_connection()
|
||||||
# Run it off the event loop so connection stalls do not block the
|
|
||||||
# LangBot HTTP server and other async tasks.
|
|
||||||
connection = await asyncio.to_thread(self.client.open_connection)
|
|
||||||
|
|
||||||
if not connection:
|
if not connection:
|
||||||
if self.logger:
|
if self.logger:
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
"""STREAM-mode handler for DingTalk card action button clicks.
|
|
||||||
|
|
||||||
DingTalk delivers card-action callbacks over the same WebSocket stream used
|
|
||||||
for chatbot messages, under the topic `/v1.0/card/instances/callback`. This
|
|
||||||
module subclasses `dingtalk_stream.CallbackHandler` and forwards the parsed
|
|
||||||
payload to a coroutine the adapter registers, so the resume-paused-workflow
|
|
||||||
logic stays in the platform adapter where it belongs.
|
|
||||||
|
|
||||||
The `CardCallbackMessage` returned by `from_dict` exposes:
|
|
||||||
|
|
||||||
* `card_instance_id` (from `outTrackId`) — the card whose button was clicked
|
|
||||||
* `user_id` — the clicker's userId
|
|
||||||
* `content` — parsed JSON; the click params live here. Where exactly inside
|
|
||||||
`content` they sit depends on the template binding. We probe
|
|
||||||
the common paths.
|
|
||||||
* `extension` — parsed JSON; any extra data we set when delivering the card.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Awaitable, Callable, Optional
|
|
||||||
|
|
||||||
import dingtalk_stream # type: ignore
|
|
||||||
from dingtalk_stream import AckMessage
|
|
||||||
from dingtalk_stream.card_callback import CardCallbackMessage
|
|
||||||
|
|
||||||
|
|
||||||
_PARAM_PATHS = (
|
|
||||||
('params',),
|
|
||||||
('cardPrivateData', 'params'),
|
|
||||||
('userPrivateData', 'params'),
|
|
||||||
('actionData', 'cardPrivateData', 'params'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_params(content: dict) -> dict:
|
|
||||||
"""Return the action params dict regardless of where the template put it."""
|
|
||||||
for path in _PARAM_PATHS:
|
|
||||||
node = content
|
|
||||||
for key in path:
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
node = None
|
|
||||||
break
|
|
||||||
node = node.get(key)
|
|
||||||
if node is None:
|
|
||||||
break
|
|
||||||
if isinstance(node, dict) and node:
|
|
||||||
return node
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_params(*sources: dict) -> dict:
|
|
||||||
merged = {}
|
|
||||||
for source in sources:
|
|
||||||
if isinstance(source, dict):
|
|
||||||
merged.update(source)
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
dingtalk_stream_client,
|
|
||||||
on_action: Optional[Callable[[dict], Awaitable[None]]] = None,
|
|
||||||
):
|
|
||||||
super().__init__()
|
|
||||||
self.dingtalk_client = dingtalk_stream_client
|
|
||||||
self.on_action = on_action
|
|
||||||
|
|
||||||
async def process(self, callback: dingtalk_stream.CallbackMessage):
|
|
||||||
try:
|
|
||||||
message = CardCallbackMessage.from_dict(callback.data)
|
|
||||||
content = message.content if isinstance(message.content, dict) else {}
|
|
||||||
|
|
||||||
# `CardCallbackMessage.from_dict` does not surface `actionId` (the
|
|
||||||
# top-level field that ButtonGroup's sendCardRequest event puts
|
|
||||||
# there). Pull it from the raw callback.data instead.
|
|
||||||
raw = callback.data if isinstance(callback.data, dict) else {}
|
|
||||||
params = _merge_params(_extract_params(content), _extract_params(raw))
|
|
||||||
action_id = raw.get('actionId') or ''
|
|
||||||
if not action_id:
|
|
||||||
# Some templates nest it under actionData / cardPrivateData.
|
|
||||||
action_data = raw.get('actionData') or {}
|
|
||||||
if isinstance(action_data, dict):
|
|
||||||
action_id = action_data.get('actionId') or action_id
|
|
||||||
if not action_id:
|
|
||||||
cpd = action_data.get('cardPrivateData') or {}
|
|
||||||
if isinstance(cpd, dict):
|
|
||||||
ids = cpd.get('actionIds')
|
|
||||||
if isinstance(ids, list) and ids:
|
|
||||||
action_id = str(ids[0])
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'out_track_id': message.card_instance_id,
|
|
||||||
'user_id': message.user_id,
|
|
||||||
'corp_id': message.corp_id,
|
|
||||||
'action_id': action_id,
|
|
||||||
'params': params,
|
|
||||||
'raw_content': message.content,
|
|
||||||
'extension': message.extension if isinstance(message.extension, dict) else {},
|
|
||||||
}
|
|
||||||
if self.on_action is not None:
|
|
||||||
await self.on_action(payload)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f'DingTalkCardActionHandler.process error: {e}')
|
|
||||||
return AckMessage.STATUS_OK, 'OK'
|
|
||||||
@@ -12,142 +12,6 @@ import traceback
|
|||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||||
|
|
||||||
|
|
||||||
QQ_SELECT_ACTION_PREFIX = '__langbot_select__:'
|
|
||||||
|
|
||||||
|
|
||||||
def get_select_field_options(form_data: dict) -> tuple[str, list[str]]:
|
|
||||||
"""Return the active select field name and its display/submission values."""
|
|
||||||
field_name = str(form_data.get('_current_input_field') or '').strip()
|
|
||||||
if not field_name:
|
|
||||||
return '', []
|
|
||||||
|
|
||||||
field = next(
|
|
||||||
(
|
|
||||||
item
|
|
||||||
for item in form_data.get('input_defs') or []
|
|
||||||
if str(item.get('output_variable_name') or '').strip() == field_name
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not field or str(field.get('type') or '').strip().lower() != 'select':
|
|
||||||
return '', []
|
|
||||||
|
|
||||||
source = field.get('option_source') or {}
|
|
||||||
source_value = source.get('value') if isinstance(source, dict) else None
|
|
||||||
if isinstance(source_value, list):
|
|
||||||
return field_name, [str(item) for item in source_value]
|
|
||||||
if isinstance(source_value, str):
|
|
||||||
return field_name, [part.strip() for part in source_value.splitlines() if part.strip()]
|
|
||||||
|
|
||||||
options = field.get('options')
|
|
||||||
if not isinstance(options, list):
|
|
||||||
return field_name, []
|
|
||||||
values = []
|
|
||||||
for item in options:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
values.append(str(item.get('label') or item.get('value') or ''))
|
|
||||||
else:
|
|
||||||
values.append(str(item))
|
|
||||||
return field_name, [value for value in values if value]
|
|
||||||
|
|
||||||
|
|
||||||
def build_keyboard_from_select_field(form_data: dict, *, buttons_per_row: int | None = None) -> dict:
|
|
||||||
"""Build callback buttons for the currently active Dify select field."""
|
|
||||||
_, options = get_select_field_options(form_data)
|
|
||||||
visible_options = options[:25]
|
|
||||||
if buttons_per_row is None:
|
|
||||||
# Keep small choices readable while fitting up to QQ's 5x5 limit.
|
|
||||||
buttons_per_row = min(5, max(2, (len(visible_options) + 4) // 5))
|
|
||||||
selection_actions = [
|
|
||||||
{
|
|
||||||
'id': f'{QQ_SELECT_ACTION_PREFIX}{idx}',
|
|
||||||
'title': option,
|
|
||||||
'button_style': 'secondary',
|
|
||||||
}
|
|
||||||
for idx, option in enumerate(visible_options)
|
|
||||||
]
|
|
||||||
return build_keyboard_from_form({'actions': selection_actions}, buttons_per_row=buttons_per_row)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_select_button_action(form_data: dict, action_id: str) -> tuple[str, str] | None:
|
|
||||||
"""Resolve a select-button callback to ``(field_name, option_value)``."""
|
|
||||||
if not action_id.startswith(QQ_SELECT_ACTION_PREFIX):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
option_index = int(action_id[len(QQ_SELECT_ACTION_PREFIX) :])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
field_name, options = get_select_field_options(form_data)
|
|
||||||
if not field_name or option_index < 0 or option_index >= len(options) or option_index >= 25:
|
|
||||||
return None
|
|
||||||
return field_name, options[option_index]
|
|
||||||
|
|
||||||
|
|
||||||
def build_keyboard_from_form(form_data: dict, *, buttons_per_row: int = 2) -> dict:
|
|
||||||
"""Build a QQ keyboard JSON payload from a Dify human-input form_data.
|
|
||||||
|
|
||||||
Each Dify ``action`` becomes a callback button (``action.type=1``)
|
|
||||||
whose ``data`` is set directly to the Dify ``action_id``. The
|
|
||||||
INTERACTION_CREATE event carries this back as
|
|
||||||
``data.resolved.button_data`` so the adapter can match the click to
|
|
||||||
the originating form.
|
|
||||||
|
|
||||||
Layout limits per spec: max 5 rows, max 5 buttons per row. We default
|
|
||||||
to 2 buttons per row for legibility; oversized button lists wrap
|
|
||||||
onto additional rows and overflow gets dropped (max 25 visible).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
form_data: Dify ``{"actions": [{"id", "title", "button_style"}, ...]}``.
|
|
||||||
buttons_per_row: 1..5. Mobile UI looks best at 2.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"content": {"rows": [{"buttons": [...]}]}}``.
|
|
||||||
"""
|
|
||||||
actions = list(form_data.get('actions') or [])[:25] # 5×5 hard cap
|
|
||||||
buttons_per_row = max(1, min(5, buttons_per_row))
|
|
||||||
|
|
||||||
def _button(idx: int, action: dict) -> dict:
|
|
||||||
action_id = str(action.get('id') or '')
|
|
||||||
label = str(action.get('title') or action_id or f'选项 {idx + 1}')
|
|
||||||
style_raw = (action.get('button_style') or '').lower()
|
|
||||||
# QQ: 0 灰色线框, 1 蓝色线框. Highlight the primary / first action.
|
|
||||||
if style_raw == 'primary' or (style_raw == '' and idx == 0):
|
|
||||||
style = 1
|
|
||||||
else:
|
|
||||||
style = 0
|
|
||||||
return {
|
|
||||||
'id': str(idx + 1),
|
|
||||||
'render_data': {
|
|
||||||
'label': label,
|
|
||||||
# Shown after the user clicks — gives local "已选择" feedback
|
|
||||||
# without a follow-up message. Style mimics DingTalk/Lark's
|
|
||||||
# in-card selection state.
|
|
||||||
'visited_label': f'✓ {label}',
|
|
||||||
'style': style,
|
|
||||||
},
|
|
||||||
'action': {
|
|
||||||
'type': 1, # callback button
|
|
||||||
'permission': {'type': 2}, # everyone can click
|
|
||||||
'data': action_id,
|
|
||||||
'unsupport_tips': '当前客户端版本不支持此按钮,请升级 QQ',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for row_start in range(0, len(actions), buttons_per_row):
|
|
||||||
row_actions = actions[row_start : row_start + buttons_per_row]
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
'buttons': [_button(row_start + j, a) for j, a in enumerate(row_actions)],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(rows) >= 5:
|
|
||||||
break
|
|
||||||
|
|
||||||
return {'content': {'rows': rows}}
|
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialClient:
|
class QQOfficialClient:
|
||||||
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
||||||
self.unified_mode = unified_mode
|
self.unified_mode = unified_mode
|
||||||
@@ -166,10 +30,6 @@ class QQOfficialClient:
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.app_id = app_id
|
self.app_id = app_id
|
||||||
self._message_handlers = {}
|
self._message_handlers = {}
|
||||||
# Single optional handler for INTERACTION_CREATE (button click). We
|
|
||||||
# don't multiplex like message handlers — only the adapter cares,
|
|
||||||
# and the click<->resume path needs a single source of truth.
|
|
||||||
self._interaction_handler: Optional[Callable[[Dict[str, Any], Optional[str]], Any]] = None
|
|
||||||
self.base_url = 'https://api.sgroup.qq.com'
|
self.base_url = 'https://api.sgroup.qq.com'
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.access_token_expiry_time = None
|
self.access_token_expiry_time = None
|
||||||
@@ -247,23 +107,6 @@ class QQOfficialClient:
|
|||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
if payload.get('op') == 0:
|
if payload.get('op') == 0:
|
||||||
# INTERACTION_CREATE (button click) skips ``get_message`` —
|
|
||||||
# that helper only flattens message-event fields and would
|
|
||||||
# drop ``data.resolved.button_data`` / ``data.button_id``.
|
|
||||||
if payload.get('t') == 'INTERACTION_CREATE':
|
|
||||||
if self._interaction_handler:
|
|
||||||
try:
|
|
||||||
d = payload.get('d') or {}
|
|
||||||
# Top-level ``id`` is the ws/event id used as
|
|
||||||
# ``event_id`` for passive replies. ``d.id``
|
|
||||||
# is the interaction id used for ACK. Do not
|
|
||||||
# confuse the two — QQ rejects misuse with
|
|
||||||
# 40034025.
|
|
||||||
ws_event_id = payload.get('id')
|
|
||||||
await self._interaction_handler(d, ws_event_id)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error in interaction handler: {traceback.format_exc()}')
|
|
||||||
return {'code': 0, 'message': 'success'}
|
|
||||||
message_data = await self.get_message(payload)
|
message_data = await self.get_message(payload)
|
||||||
if message_data:
|
if message_data:
|
||||||
event = QQOfficialEvent.from_payload(message_data)
|
event = QQOfficialEvent.from_payload(message_data)
|
||||||
@@ -290,21 +133,6 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def on_interaction(self):
|
|
||||||
"""Register a single handler for INTERACTION_CREATE events.
|
|
||||||
|
|
||||||
The handler receives ``(data_dict, interaction_id)`` — the raw
|
|
||||||
``d`` payload plus the top-level ``id`` field (the interaction
|
|
||||||
id, needed for the PUT /interactions/{id} ack and for reuse as
|
|
||||||
an ``event_id`` on the resumed reply within 30 minutes).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: Callable[[Dict[str, Any], Optional[str]], Any]):
|
|
||||||
self._interaction_handler = func
|
|
||||||
return func
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
async def _handle_message(self, event: QQOfficialEvent):
|
async def _handle_message(self, event: QQOfficialEvent):
|
||||||
"""处理消息事件"""
|
"""处理消息事件"""
|
||||||
msg_type = event.t
|
msg_type = event.t
|
||||||
@@ -349,20 +177,8 @@ class QQOfficialClient:
|
|||||||
content_type = attachment.get('content_type', '')
|
content_type = attachment.get('content_type', '')
|
||||||
return content_type.startswith('image/')
|
return content_type.startswith('image/')
|
||||||
|
|
||||||
async def send_private_text_msg(
|
async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
|
||||||
self,
|
"""发送私聊消息"""
|
||||||
user_openid: str,
|
|
||||||
content: str,
|
|
||||||
msg_id: Optional[str] = None,
|
|
||||||
event_id: Optional[str] = None,
|
|
||||||
msg_seq: int = 1,
|
|
||||||
):
|
|
||||||
"""Send a c2c text message.
|
|
||||||
|
|
||||||
Either ``msg_id`` (inbound user msg, free passive reply) or
|
|
||||||
``event_id`` (e.g. INTERACTION_CREATE id, valid 30 min) is
|
|
||||||
required. Without either, the call costs the proactive-send quota.
|
|
||||||
"""
|
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_access_token()
|
await self.get_access_token()
|
||||||
|
|
||||||
@@ -372,15 +188,11 @@ class QQOfficialClient:
|
|||||||
'Authorization': f'QQBot {self.access_token}',
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
data: dict[str, Any] = {
|
data = {
|
||||||
'content': content,
|
'content': content,
|
||||||
'msg_type': 0,
|
'msg_type': 0,
|
||||||
'msg_seq': msg_seq,
|
'msg_id': msg_id,
|
||||||
}
|
}
|
||||||
if msg_id:
|
|
||||||
data['msg_id'] = msg_id
|
|
||||||
if event_id:
|
|
||||||
data['event_id'] = event_id
|
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -389,19 +201,8 @@ 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_group_text_msg(
|
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
||||||
self,
|
"""发送群聊消息"""
|
||||||
group_openid: str,
|
|
||||||
content: str,
|
|
||||||
msg_id: Optional[str] = None,
|
|
||||||
event_id: Optional[str] = None,
|
|
||||||
msg_seq: int = 1,
|
|
||||||
):
|
|
||||||
"""Send a group text message.
|
|
||||||
|
|
||||||
Either ``msg_id`` or ``event_id`` is required (see
|
|
||||||
:meth:`send_private_text_msg` for the distinction).
|
|
||||||
"""
|
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_access_token()
|
await self.get_access_token()
|
||||||
|
|
||||||
@@ -411,15 +212,11 @@ class QQOfficialClient:
|
|||||||
'Authorization': f'QQBot {self.access_token}',
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
data: dict[str, Any] = {
|
data = {
|
||||||
'content': content,
|
'content': content,
|
||||||
'msg_type': 0,
|
'msg_type': 0,
|
||||||
'msg_seq': msg_seq,
|
'msg_id': msg_id,
|
||||||
}
|
}
|
||||||
if msg_id:
|
|
||||||
data['msg_id'] = msg_id
|
|
||||||
if event_id:
|
|
||||||
data['event_id'] = event_id
|
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return
|
return
|
||||||
@@ -688,107 +485,6 @@ class QQOfficialClient:
|
|||||||
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def send_markdown_keyboard(
|
|
||||||
self,
|
|
||||||
target_type: str,
|
|
||||||
target_id: str,
|
|
||||||
markdown_content: str,
|
|
||||||
keyboard: Optional[dict] = None,
|
|
||||||
msg_id: Optional[str] = None,
|
|
||||||
event_id: Optional[str] = None,
|
|
||||||
msg_seq: int = 1,
|
|
||||||
) -> dict:
|
|
||||||
"""Send a ``msg_type=2`` (markdown) message carrying a keyboard.
|
|
||||||
|
|
||||||
The keyboard ride-along is the only documented way to attach
|
|
||||||
buttons in QQ official; pure keyboard-only messages are not
|
|
||||||
accepted by the server (markdown content is required).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target_type: 'c2c' (single chat), 'group', 'channel' (text
|
|
||||||
channel — uses POST /channels/{id}/messages instead of v2).
|
|
||||||
target_id: openid for c2c/group, channel_id for channel.
|
|
||||||
markdown_content: Plain markdown text shown above the buttons.
|
|
||||||
keyboard: ``{'content': {'rows': [{'buttons': [...]}]}}`` per
|
|
||||||
the official spec. Use :func:`build_keyboard_from_form`
|
|
||||||
to construct from Dify form_data.
|
|
||||||
msg_id: Inbound user message id; turns this into a passive
|
|
||||||
reply (preferred — no monthly quota cost).
|
|
||||||
event_id: Use ``INTERACTION_CREATE`` event id from a prior
|
|
||||||
button click to keep within the 30-minute passive window
|
|
||||||
without an inbound msg_id.
|
|
||||||
msg_seq: De-dup counter when reusing msg_id.
|
|
||||||
"""
|
|
||||||
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'
|
|
||||||
elif target_type == 'channel':
|
|
||||||
url = f'{self.base_url}/channels/{target_id}/messages'
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}')
|
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
'msg_type': 2,
|
|
||||||
'markdown': {'content': markdown_content},
|
|
||||||
'msg_seq': msg_seq,
|
|
||||||
}
|
|
||||||
if keyboard and keyboard.get('content', {}).get('rows'):
|
|
||||||
body['keyboard'] = keyboard
|
|
||||||
if msg_id:
|
|
||||||
body['msg_id'] = msg_id
|
|
||||||
if event_id:
|
|
||||||
body['event_id'] = event_id
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
headers = {
|
|
||||||
'Authorization': f'QQBot {self.access_token}',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
response = await client.post(url, headers=headers, json=body)
|
|
||||||
if response.status_code != 200:
|
|
||||||
await self.logger.error(
|
|
||||||
f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}'
|
|
||||||
)
|
|
||||||
raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}')
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def ack_interaction(self, interaction_id: str, code: int = 0) -> None:
|
|
||||||
"""Acknowledge a button-click INTERACTION_CREATE event.
|
|
||||||
|
|
||||||
QQ keeps the client in a loading spinner until this ack is
|
|
||||||
received. Should be called as soon as the click is parsed, before
|
|
||||||
any heavier downstream work (the actual workflow resume can run
|
|
||||||
async).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interaction_id: The ``id`` field from the INTERACTION_CREATE event.
|
|
||||||
code: 0=success, 1=fail, 2=rate-limited, 3=duplicate, 4=no
|
|
||||||
permission, 5=admin only. Default 0.
|
|
||||||
"""
|
|
||||||
if not interaction_id:
|
|
||||||
return
|
|
||||||
if not await self.check_access_token():
|
|
||||||
await self.get_access_token()
|
|
||||||
|
|
||||||
url = f'{self.base_url}/interactions/{interaction_id}'
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
headers = {
|
|
||||||
'Authorization': f'QQBot {self.access_token}',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
response = await client.put(url, headers=headers, json={'code': code})
|
|
||||||
if response.status_code >= 400:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'ack_interaction non-success: HTTP {response.status_code} {response.text}'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await self.logger.warning(f'ack_interaction error (non-fatal): {e}')
|
|
||||||
|
|
||||||
async def is_token_expired(self):
|
async def is_token_expired(self):
|
||||||
"""检查token是否过期"""
|
"""检查token是否过期"""
|
||||||
if self.access_token_expiry_time is None:
|
if self.access_token_expiry_time is None:
|
||||||
@@ -957,12 +653,6 @@ class QQOfficialClient:
|
|||||||
d = payload.get('d', {})
|
d = payload.get('d', {})
|
||||||
s = payload.get('s')
|
s = payload.get('s')
|
||||||
t = payload.get('t')
|
t = payload.get('t')
|
||||||
# Top-level event id, distinct from `d.id`. Per QQ
|
|
||||||
# spec this is the only value accepted as ``event_id``
|
|
||||||
# in subsequent passive-reply send-message calls
|
|
||||||
# (``d.id`` for INTERACTION_CREATE is the interaction
|
|
||||||
# id, used solely for PUT /interactions/{id} ack).
|
|
||||||
ws_event_id = payload.get('id')
|
|
||||||
|
|
||||||
if not isinstance(d, dict):
|
if not isinstance(d, dict):
|
||||||
d = {}
|
d = {}
|
||||||
@@ -1041,22 +731,7 @@ class QQOfficialClient:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
await self.logger.debug(f'Received event: {t}, seq={s}')
|
await self.logger.debug(f'Received event: {t}, seq={s}')
|
||||||
# INTERACTION_CREATE bypasses the regular
|
if on_event:
|
||||||
# on_event dispatcher so the adapter sees the
|
|
||||||
# top-level ws_event_id (needed as event_id
|
|
||||||
# for the resumed reply) — same shape as the
|
|
||||||
# webhook handler.
|
|
||||||
if t == 'INTERACTION_CREATE':
|
|
||||||
if self._interaction_handler:
|
|
||||||
try:
|
|
||||||
result = self._interaction_handler(d, ws_event_id)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
await result
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(
|
|
||||||
f'Error in interaction handler (ws): {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
elif on_event:
|
|
||||||
try:
|
try:
|
||||||
result = on_event(t, d)
|
result = on_event(t, d)
|
||||||
if asyncio.iscoroutine(result):
|
if asyncio.iscoroutine(result):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,19 +20,7 @@ from typing import Any, Callable, Optional
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import (
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||||
parse_wecom_bot_message,
|
|
||||||
StreamSession,
|
|
||||||
build_human_input_template_card_payload,
|
|
||||||
build_human_input_text_prompt,
|
|
||||||
build_button_interaction_update_card,
|
|
||||||
build_multiple_interaction_update_card,
|
|
||||||
extract_template_card_action,
|
|
||||||
extract_template_card_event_payload,
|
|
||||||
extract_template_card_selections,
|
|
||||||
extract_wecom_event_type,
|
|
||||||
parse_select_button_action,
|
|
||||||
)
|
|
||||||
from langbot.pkg.platform.logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
@@ -55,10 +43,6 @@ def _generate_req_id(prefix: str) -> str:
|
|||||||
return f'{prefix}_{ts}_{rand}'
|
return f'{prefix}_{ts}_{rand}'
|
||||||
|
|
||||||
|
|
||||||
def _frame_snippet(frame: dict, limit: int = 1000) -> str:
|
|
||||||
return json.dumps(frame, ensure_ascii=False, default=str)[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
class WecomBotWsClient:
|
class WecomBotWsClient:
|
||||||
"""WeChat Work AI Bot WebSocket long connection client.
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
@@ -119,22 +103,6 @@ class WecomBotWsClient:
|
|||||||
# msg_id -> feedback_id (for associating feedback with message)
|
# msg_id -> feedback_id (for associating feedback with message)
|
||||||
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
||||||
|
|
||||||
# Dify human-input pause state for ws mode. Keys are task_id (echoed
|
|
||||||
# back in template_card_event.TaskId so we can rebuild the session
|
|
||||||
# context on click).
|
|
||||||
# task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id}
|
|
||||||
self._pending_forms_by_task: dict[str, dict] = {}
|
|
||||||
# Reverse: msg_id -> task_id (for cleanup when stream finishes).
|
|
||||||
self._task_id_by_msg: dict[str, str] = {}
|
|
||||||
# Optional card-action callback registered by the adapter.
|
|
||||||
# Signature mirrors the http-mode WecomBotClient:
|
|
||||||
# async def callback(session, action_id, task_id, raw_event) -> None
|
|
||||||
self._card_action_callback: Optional[Callable] = None
|
|
||||||
# Optional `source` block injected into every interactive
|
|
||||||
# template_card the client builds via `push_form_pause`. Set via
|
|
||||||
# `set_card_source` from the adapter after reading config.
|
|
||||||
self.card_source: Optional[dict] = None
|
|
||||||
|
|
||||||
# ── Public API ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
@@ -268,132 +236,6 @@ class WecomBotWsClient:
|
|||||||
}
|
}
|
||||||
return await self._send_reply(req_id, body)
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
async def reply_template_card(self, req_id: str, card_payload: dict[str, Any]) -> Optional[dict]:
|
|
||||||
"""Send a template_card (button_interaction etc.) reply.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
req_id: The req_id from the original message frame.
|
|
||||||
card_payload: Body produced by ``build_button_interaction_payload``;
|
|
||||||
must contain ``msgtype`` and ``template_card`` keys.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ACK frame dict, or None on failure.
|
|
||||||
"""
|
|
||||||
return await self._send_reply(req_id, card_payload)
|
|
||||||
|
|
||||||
async def update_template_card(
|
|
||||||
self,
|
|
||||||
req_id: str,
|
|
||||||
template_card: dict[str, Any],
|
|
||||||
) -> Optional[dict]:
|
|
||||||
"""Update an existing template_card via WebSocket.
|
|
||||||
|
|
||||||
Uses the ``aibot_respond_update_msg`` command. Must be called
|
|
||||||
within 5 seconds of receiving the ``template_card_event`` callback,
|
|
||||||
using the **same req_id** from that callback.
|
|
||||||
|
|
||||||
The ``template_card`` dict should contain ``card_type`` and the
|
|
||||||
new content fields (e.g. ``main_title``, ``button_list`` with
|
|
||||||
disabled buttons and ``replace_text``).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ACK frame dict, or None on failure.
|
|
||||||
"""
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
'response_type': 'update_template_card',
|
|
||||||
'template_card': template_card,
|
|
||||||
}
|
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_UPDATE)
|
|
||||||
|
|
||||||
def set_card_action_callback(self, callback: Callable) -> None:
|
|
||||||
"""Register the button-click handler.
|
|
||||||
|
|
||||||
``async def callback(session, action_id, task_id, raw_event) -> None``
|
|
||||||
— same signature as the http-mode WecomBotClient version so the
|
|
||||||
adapter can hand both off to the same coroutine.
|
|
||||||
"""
|
|
||||||
self._card_action_callback = callback
|
|
||||||
|
|
||||||
def set_card_source(self, source: Optional[dict]) -> None:
|
|
||||||
"""Set the `source` block injected into every interactive
|
|
||||||
template_card pushed via `push_form_pause`. Pass None to clear."""
|
|
||||||
self.card_source = source
|
|
||||||
|
|
||||||
async def push_form_pause(
|
|
||||||
self, msg_id: str, form_data: dict, task_id: Optional[str] = None
|
|
||||||
) -> tuple[bool, Optional[str], Optional[str]]:
|
|
||||||
"""Attach a Dify human-input pause to the active stream and send
|
|
||||||
the button_interaction card immediately.
|
|
||||||
|
|
||||||
ws mode has no notion of polled "followup" responses — each reply
|
|
||||||
is a one-shot frame send. So unlike the http path (which defers
|
|
||||||
card delivery to the next followup), here we just craft the card
|
|
||||||
and reply with it on the original req_id. The corresponding stream
|
|
||||||
session is then torn down so subsequent chunks don't re-send.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``(ok, stream_id, task_id)``. ``ok=False`` if no active stream
|
|
||||||
for this msg_id (e.g. message arrived in non-stream mode).
|
|
||||||
"""
|
|
||||||
key = self._stream_ids.get(msg_id)
|
|
||||||
if not key:
|
|
||||||
return False, None, None
|
|
||||||
req_id, stream_id = key.split('|', 1)
|
|
||||||
|
|
||||||
if not task_id:
|
|
||||||
task_id = f'dify-{secrets.token_hex(12)}'
|
|
||||||
|
|
||||||
session_info = self._stream_sessions.get(msg_id) or {}
|
|
||||||
text_prompt = build_human_input_text_prompt(form_data)
|
|
||||||
if text_prompt:
|
|
||||||
try:
|
|
||||||
ack = await self.reply_text(req_id, text_prompt)
|
|
||||||
if ack is None:
|
|
||||||
return False, stream_id, None
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Failed to send human-input text prompt: {traceback.format_exc()}')
|
|
||||||
return False, stream_id, None
|
|
||||||
|
|
||||||
self._stream_ids.pop(msg_id, None)
|
|
||||||
self._stream_last_content.pop(msg_id, None)
|
|
||||||
self._stream_sessions.pop(msg_id, None)
|
|
||||||
return True, stream_id, None
|
|
||||||
|
|
||||||
self._pending_forms_by_task[task_id] = {
|
|
||||||
'form_data': form_data,
|
|
||||||
'msg_id': msg_id,
|
|
||||||
'user_id': session_info.get('user_id', ''),
|
|
||||||
'chat_id': session_info.get('chat_id', ''),
|
|
||||||
'stream_id': stream_id,
|
|
||||||
'req_id': req_id,
|
|
||||||
}
|
|
||||||
self._task_id_by_msg[msg_id] = task_id
|
|
||||||
|
|
||||||
card_payload = build_human_input_template_card_payload(
|
|
||||||
form_data,
|
|
||||||
task_id,
|
|
||||||
source=self.card_source,
|
|
||||||
select_as_buttons=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self.reply_template_card(req_id, card_payload)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Failed to send button_interaction card: {traceback.format_exc()}')
|
|
||||||
# Roll back the bookkeeping so the next attempt isn't blocked.
|
|
||||||
self._pending_forms_by_task.pop(task_id, None)
|
|
||||||
self._task_id_by_msg.pop(msg_id, None)
|
|
||||||
return False, stream_id, None
|
|
||||||
|
|
||||||
# Tear down the stream — WeCom expects either stream chunks OR a
|
|
||||||
# template_card, not both on the same req_id. Subsequent
|
|
||||||
# push_stream_chunk calls for this msg_id become no-ops.
|
|
||||||
self._stream_ids.pop(msg_id, None)
|
|
||||||
self._stream_last_content.pop(msg_id, None)
|
|
||||||
# Keep _stream_sessions so the button callback can still resolve
|
|
||||||
# user/chat context; it gets cleaned up when the click fires.
|
|
||||||
|
|
||||||
return True, stream_id, task_id
|
|
||||||
|
|
||||||
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
|
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
|
||||||
"""Proactively send a message to a specified chat.
|
"""Proactively send a message to a specified chat.
|
||||||
|
|
||||||
@@ -416,23 +258,6 @@ class WecomBotWsClient:
|
|||||||
body['text'] = {'content': content}
|
body['text'] = {'content': content}
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
async def send_template_card(self, chat_id: str, card_payload: dict[str, Any]) -> Optional[dict]:
|
|
||||||
"""Proactively push a template_card to a chat.
|
|
||||||
|
|
||||||
Used for the resumed-workflow path (button click → new query):
|
|
||||||
synthetic events have no inbound req_id to reply against, so we
|
|
||||||
fall back to proactive ``aibot_send_msg`` instead of reply mode.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
chat_id: userid (single chat) or chatid (group chat).
|
|
||||||
card_payload: ``{"msgtype": "template_card", "template_card": {...}}``
|
|
||||||
as produced by :func:`build_button_interaction_payload`.
|
|
||||||
"""
|
|
||||||
req_id = _generate_req_id(CMD_SEND_MSG)
|
|
||||||
body = dict(card_payload)
|
|
||||||
body['chatid'] = chat_id
|
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
|
||||||
|
|
||||||
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||||
"""Push a streaming chunk for a given message ID.
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
@@ -451,29 +276,8 @@ class WecomBotWsClient:
|
|||||||
return False
|
return False
|
||||||
req_id, stream_id = key.split('|', 1)
|
req_id, stream_id = key.split('|', 1)
|
||||||
try:
|
try:
|
||||||
previous_content = self._stream_last_content.get(msg_id, '')
|
|
||||||
if previous_content and content.startswith(previous_content):
|
|
||||||
next_content = content
|
|
||||||
elif previous_content and not content:
|
|
||||||
next_content = previous_content
|
|
||||||
else:
|
|
||||||
next_content = previous_content + content if previous_content else content
|
|
||||||
|
|
||||||
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
if not is_final and next_content == previous_content:
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
return True
|
|
||||||
|
|
||||||
# Skip empty/whitespace-only snapshots — the runner injects a
|
|
||||||
# zero-width space ('') as a pass-through when workflow_paused
|
|
||||||
# fires without any preceding LLM output. WeCom renders that
|
|
||||||
# as an empty bubble that sits before the form card; skip it.
|
|
||||||
# NOTE: Python str.strip() does NOT strip , so we use
|
|
||||||
# a regex that treats any character with Unicode category Zs
|
|
||||||
# (separator space) or Cf (format char like ZWS) as blank.
|
|
||||||
if not is_final:
|
|
||||||
import re as _re
|
|
||||||
|
|
||||||
if not _re.sub(r'[\s]', '', next_content):
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Generate feedback_id for final chunk
|
# Generate feedback_id for final chunk
|
||||||
@@ -486,10 +290,8 @@ class WecomBotWsClient:
|
|||||||
if session_info:
|
if session_info:
|
||||||
self._feedback_sessions[feedback_id] = session_info
|
self._feedback_sessions[feedback_id] = session_info
|
||||||
|
|
||||||
# WeCom replaces the displayed stream content on each refresh, so
|
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
|
||||||
# every frame must contain the complete snapshot, not only a delta.
|
self._stream_last_content[msg_id] = content
|
||||||
await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id)
|
|
||||||
self._stream_last_content[msg_id] = next_content
|
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ids.pop(msg_id, None)
|
self._stream_ids.pop(msg_id, None)
|
||||||
self._stream_last_content.pop(msg_id, None)
|
self._stream_last_content.pop(msg_id, None)
|
||||||
@@ -663,7 +465,7 @@ class WecomBotWsClient:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Unknown frame
|
# Unknown frame
|
||||||
await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}')
|
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
|
||||||
|
|
||||||
async def _handle_message_callback(self, frame: dict):
|
async def _handle_message_callback(self, frame: dict):
|
||||||
"""Handle an incoming message callback frame."""
|
"""Handle an incoming message callback frame."""
|
||||||
@@ -671,13 +473,6 @@ class WecomBotWsClient:
|
|||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
event_type = extract_wecom_event_type(body)
|
|
||||||
if event_type == 'template_card_event':
|
|
||||||
await self._handle_template_card_event_frame(frame, body)
|
|
||||||
return
|
|
||||||
if event_type:
|
|
||||||
await self.logger.debug(f'Received msg_callback event_type={event_type}: {_frame_snippet(frame)}')
|
|
||||||
|
|
||||||
# Parse message using shared logic
|
# Parse message using shared logic
|
||||||
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
||||||
if not message_data:
|
if not message_data:
|
||||||
@@ -711,12 +506,8 @@ class WecomBotWsClient:
|
|||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body
|
event_info = body.get('event', {})
|
||||||
event_type = extract_wecom_event_type(body)
|
event_type = event_info.get('eventtype', '')
|
||||||
if not event_type:
|
|
||||||
await self.logger.warning(f'Received event_callback without event_type: {_frame_snippet(frame)}')
|
|
||||||
else:
|
|
||||||
await self.logger.debug(f'Received event_callback event_type={event_type}')
|
|
||||||
|
|
||||||
message_data = {
|
message_data = {
|
||||||
'msgtype': 'event',
|
'msgtype': 'event',
|
||||||
@@ -777,10 +568,6 @@ class WecomBotWsClient:
|
|||||||
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||||
return
|
return
|
||||||
|
|
||||||
if event_type == 'template_card_event':
|
|
||||||
await self._handle_template_card_event_frame(frame, body)
|
|
||||||
return
|
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
if event_type in self._message_handlers:
|
if event_type in self._message_handlers:
|
||||||
@@ -794,72 +581,6 @@ class WecomBotWsClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def _handle_template_card_event_frame(self, frame: dict, body: dict):
|
|
||||||
"""Handle template_card_event frames from event_callback or msg_callback."""
|
|
||||||
tce = extract_template_card_event_payload(body)
|
|
||||||
task_id, event_key, card_type = extract_template_card_action(tce)
|
|
||||||
await self.logger.info(
|
|
||||||
f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}'
|
|
||||||
)
|
|
||||||
|
|
||||||
pending = self._pending_forms_by_task.get(task_id)
|
|
||||||
if pending is None:
|
|
||||||
await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored')
|
|
||||||
return
|
|
||||||
|
|
||||||
req_id_for_update = frame.get('headers', {}).get('req_id', '')
|
|
||||||
form_data = pending.get('form_data', {}) or {}
|
|
||||||
selections = extract_template_card_selections(tce, form_data)
|
|
||||||
if not selections:
|
|
||||||
selections = parse_select_button_action(event_key, form_data)
|
|
||||||
if card_type == 'multiple_interaction' and not selections:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'multiple_interaction callback has no parseable selections (ws): raw={str(tce)[:1000]}'
|
|
||||||
)
|
|
||||||
self._drop_pending_form_task(task_id, pending)
|
|
||||||
return
|
|
||||||
|
|
||||||
update_card = build_button_interaction_update_card(
|
|
||||||
form_data,
|
|
||||||
task_id,
|
|
||||||
event_key,
|
|
||||||
source=self.card_source,
|
|
||||||
)
|
|
||||||
if card_type == 'multiple_interaction' or selections:
|
|
||||||
update_card = build_multiple_interaction_update_card(
|
|
||||||
form_data,
|
|
||||||
task_id,
|
|
||||||
selections,
|
|
||||||
source=self.card_source,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self.update_template_card(req_id_for_update, update_card)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.warning(f'Failed to update template card (ws): {traceback.format_exc()}')
|
|
||||||
|
|
||||||
if self._card_action_callback is not None:
|
|
||||||
try:
|
|
||||||
session = StreamSession(
|
|
||||||
stream_id=pending.get('stream_id', ''),
|
|
||||||
msg_id=pending.get('msg_id', ''),
|
|
||||||
chat_id=pending.get('chat_id') or None,
|
|
||||||
user_id=pending.get('user_id') or None,
|
|
||||||
)
|
|
||||||
session.pending_form = pending.get('form_data')
|
|
||||||
session.pending_form_task_id = task_id
|
|
||||||
await self._card_action_callback(session, event_key, task_id, body)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}')
|
|
||||||
|
|
||||||
self._drop_pending_form_task(task_id, pending)
|
|
||||||
|
|
||||||
def _drop_pending_form_task(self, task_id: str, pending: dict) -> None:
|
|
||||||
self._pending_forms_by_task.pop(task_id, None)
|
|
||||||
msg_id = pending.get('msg_id', '')
|
|
||||||
if msg_id:
|
|
||||||
self._task_id_by_msg.pop(msg_id, None)
|
|
||||||
self._stream_sessions.pop(msg_id, None)
|
|
||||||
|
|
||||||
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""Dispatch a message event to registered handlers with deduplication."""
|
"""Dispatch a message event to registered handlers with deduplication."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import quart
|
|||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
from ......utils import paths
|
from ......utils import paths
|
||||||
from ......platform.sources.websocket_manager import is_valid_session_id, ws_connection_manager
|
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -203,15 +203,11 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
if session_type not in ['person', 'group']:
|
if session_type not in ['person', 'group']:
|
||||||
return self.http_status(400, -1, 'session_type must be person or group')
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
session_id = quart.request.args.get('session_id', '')
|
|
||||||
if not is_valid_session_id(session_id):
|
|
||||||
return self.http_status(400, -1, 'Valid session_id is required')
|
|
||||||
|
|
||||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
if not websocket_adapter:
|
if not websocket_adapter:
|
||||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type, session_id)
|
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||||
return self.success(data={'messages': messages})
|
return self.success(data={'messages': messages})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -231,15 +227,11 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
if session_type not in ['person', 'group']:
|
if session_type not in ['person', 'group']:
|
||||||
return self.http_status(400, -1, 'session_type must be person or group')
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
session_id = quart.request.args.get('session_id', '')
|
|
||||||
if not is_valid_session_id(session_id):
|
|
||||||
return self.http_status(400, -1, 'Valid session_id is required')
|
|
||||||
|
|
||||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
if not websocket_adapter:
|
if not websocket_adapter:
|
||||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
websocket_adapter.reset_session(pipeline_uuid, session_type, session_id)
|
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||||
return self.success(data={'message': 'Session reset successfully'})
|
return self.success(data={'message': 'Session reset successfully'})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -302,11 +294,6 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
session_id = quart.websocket.args.get('session_id', '')
|
|
||||||
if not is_valid_session_id(session_id):
|
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
|
|
||||||
return
|
|
||||||
|
|
||||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
if not websocket_adapter:
|
if not websocket_adapter:
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
@@ -317,7 +304,6 @@ class EmbedRouterGroup(group.RouterGroup):
|
|||||||
websocket=quart.websocket._get_current_object(),
|
websocket=quart.websocket._get_current_object(),
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
session_id=session_id,
|
|
||||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,29 +5,6 @@ from ... import group
|
|||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
|
|
||||||
"""Decrypt the AppSecret returned by the QQ Official QR binding endpoint.
|
|
||||||
|
|
||||||
The base64 payload is laid out as `nonce (12 B) | ciphertext | tag (16 B)`.
|
|
||||||
`key` is the 32-byte AES-256 key locally generated when the bind task
|
|
||||||
was created and submitted as `key` to `q.qq.com/lite/create_bind_task`.
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = base64.b64decode(encrypted_b64)
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError('Malformed encrypted credential') from exc
|
|
||||||
if len(key) != 32 or len(raw) <= 28:
|
|
||||||
raise ValueError('Invalid encrypted credential layout')
|
|
||||||
nonce, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:]
|
|
||||||
try:
|
|
||||||
return AESGCM(key).decrypt(nonce, ciphertext + tag, None).decode('utf-8')
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError('Failed to decrypt credential') from exc
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('adapters', '/api/v1/platform/adapters')
|
@group.group_class('adapters', '/api/v1/platform/adapters')
|
||||||
class AdaptersRouterGroup(group.RouterGroup):
|
class AdaptersRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@@ -60,15 +37,6 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
|
|
||||||
async def _() -> quart.Response:
|
|
||||||
filename = 'dingtalk_human_input_card.json'
|
|
||||||
response = quart.Response(
|
|
||||||
importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json'
|
|
||||||
)
|
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
|
|
||||||
return response
|
|
||||||
|
|
||||||
# In-memory session store for active registrations
|
# In-memory session store for active registrations
|
||||||
_create_app_sessions: dict = {}
|
_create_app_sessions: dict = {}
|
||||||
_SESSION_TTL = 900 # 15 minutes
|
_SESSION_TTL = 900 # 15 minutes
|
||||||
@@ -682,220 +650,3 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if session and session.get('task') and not session['task'].done():
|
if session and session.get('task') and not session['task'].done():
|
||||||
session['task'].cancel()
|
session['task'].cancel()
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# QQ Official QR Binding
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
_qqofficial_sessions: dict = {}
|
|
||||||
_QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window)
|
|
||||||
|
|
||||||
def _cleanup_expired_qqofficial_sessions():
|
|
||||||
import time
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
expired = [
|
|
||||||
sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL
|
|
||||||
]
|
|
||||||
for sid in expired:
|
|
||||||
session = _qqofficial_sessions.pop(sid, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
|
|
||||||
@self.route('/qqofficial/bind', methods=['POST'])
|
|
||||||
async def _() -> str:
|
|
||||||
"""Start QQ Official QR binding. Returns session_id + QR URL.
|
|
||||||
|
|
||||||
Flow: generate a local AES-256 key, register it with
|
|
||||||
`q.qq.com/lite/create_bind_task`, then poll
|
|
||||||
`q.qq.com/lite/poll_bind_result` until the user authorizes the
|
|
||||||
bind inside the QQ Bot Assistant on mobile QQ. The encrypted
|
|
||||||
AppSecret returned by the poll endpoint is decrypted with the
|
|
||||||
same key. The key never leaves this process.
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
import secrets
|
|
||||||
import base64
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
QQ_BIND_BASE = 'https://q.qq.com'
|
|
||||||
_cleanup_expired_qqofficial_sessions()
|
|
||||||
|
|
||||||
bind_key_bytes = secrets.token_bytes(32)
|
|
||||||
bind_key = base64.b64encode(bind_key_bytes).decode('ascii')
|
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
session = {
|
|
||||||
'status': 'pending',
|
|
||||||
'qr_url': None,
|
|
||||||
'expire_at': None,
|
|
||||||
'appid': None,
|
|
||||||
'secret': None,
|
|
||||||
'user_openid': None,
|
|
||||||
'error': None,
|
|
||||||
'created_at': time.time(),
|
|
||||||
'task_id': None,
|
|
||||||
'bind_key_bytes': bind_key_bytes,
|
|
||||||
'interval': 2,
|
|
||||||
}
|
|
||||||
_qqofficial_sessions[session_id] = session
|
|
||||||
|
|
||||||
async def run_qr_binding():
|
|
||||||
try:
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as http:
|
|
||||||
# Step 1: create_bind_task — register our AES key, get task_id
|
|
||||||
async with http.post(
|
|
||||||
f'{QQ_BIND_BASE}/lite/create_bind_task',
|
|
||||||
json={'key': bind_key},
|
|
||||||
headers={'Accept': 'application/json'},
|
|
||||||
) as resp:
|
|
||||||
try:
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
except (aiohttp.ContentTypeError, ValueError):
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Invalid response from QQ bind service'
|
|
||||||
return
|
|
||||||
if int(data.get('retcode', -1)) != 0:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = (
|
|
||||||
data.get('msg') or data.get('message') or 'Failed to create bind task'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
task_id = str((data.get('data') or {}).get('task_id') or '').strip()
|
|
||||||
if not task_id:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Missing task_id in QQ response'
|
|
||||||
return
|
|
||||||
|
|
||||||
# The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant.
|
|
||||||
# `source=langbot` is a courtesy attribution parameter so Tencent
|
|
||||||
# can see LangBot adoption metrics, matching the convention used by
|
|
||||||
# other third-party integrations (e.g. hermes-agent uses `source=hermes`).
|
|
||||||
qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot'
|
|
||||||
session['task_id'] = task_id
|
|
||||||
session['qr_url'] = qr_url
|
|
||||||
session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL
|
|
||||||
session['status'] = 'waiting'
|
|
||||||
|
|
||||||
# Step 2: poll_bind_result until completed (status=2) or expired (3).
|
|
||||||
deadline = time.time() + _QQOFFICIAL_SESSION_TTL
|
|
||||||
while time.time() < deadline:
|
|
||||||
await asyncio.sleep(session['interval'])
|
|
||||||
|
|
||||||
async with http.post(
|
|
||||||
f'{QQ_BIND_BASE}/lite/poll_bind_result',
|
|
||||||
json={'task_id': task_id},
|
|
||||||
headers={'Accept': 'application/json'},
|
|
||||||
) as poll_resp:
|
|
||||||
try:
|
|
||||||
poll_data = await poll_resp.json(content_type=None)
|
|
||||||
except (aiohttp.ContentTypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if int(poll_data.get('retcode', -1)) != 0:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed'
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = poll_data.get('data') or {}
|
|
||||||
try:
|
|
||||||
raw_status = int(payload.get('status', 0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raw_status = 0
|
|
||||||
|
|
||||||
if raw_status == 2:
|
|
||||||
appid = str(payload.get('bot_appid') or '').strip()
|
|
||||||
encrypted = str(payload.get('bot_encrypt_secret') or '').strip()
|
|
||||||
if not appid or not encrypted:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Incomplete credential payload'
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
session['secret'] = _decrypt_qqofficial_secret(
|
|
||||||
encrypted,
|
|
||||||
bind_key_bytes,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = str(exc)
|
|
||||||
return
|
|
||||||
session['appid'] = appid
|
|
||||||
# The scanner's OpenID is returned alongside the credentials —
|
|
||||||
# surfaced to the dashboard for audit / "bound by" display.
|
|
||||||
session['user_openid'] = str(payload.get('user_openid') or '').strip() or None
|
|
||||||
session['status'] = 'success'
|
|
||||||
return
|
|
||||||
|
|
||||||
if raw_status == 3:
|
|
||||||
session['status'] = 'expired'
|
|
||||||
session['error'] = 'QR code expired'
|
|
||||||
return
|
|
||||||
# status 0 / 1: still pending, continue polling
|
|
||||||
|
|
||||||
session['status'] = 'expired'
|
|
||||||
session['error'] = 'QR code expired'
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = str(e)
|
|
||||||
|
|
||||||
task = asyncio.create_task(run_qr_binding())
|
|
||||||
session['task'] = task
|
|
||||||
|
|
||||||
# Wait up to 10s for the QR URL to be ready before responding.
|
|
||||||
for _ in range(20):
|
|
||||||
if session['qr_url'] or session['error']:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
if session['error']:
|
|
||||||
task.cancel()
|
|
||||||
return self.http_status(502, -1, session['error'])
|
|
||||||
|
|
||||||
if not session['qr_url']:
|
|
||||||
task.cancel()
|
|
||||||
session['status'] = 'error'
|
|
||||||
session['error'] = 'Timeout waiting for QR code'
|
|
||||||
return self.http_status(504, -1, 'Timeout waiting for QR code')
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'session_id': session_id,
|
|
||||||
'qr_url': session['qr_url'],
|
|
||||||
'expire_at': session['expire_at'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/qqofficial/bind/status/<session_id>', methods=['GET'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Poll QQ Official QR binding status."""
|
|
||||||
_cleanup_expired_qqofficial_sessions()
|
|
||||||
session = _qqofficial_sessions.get(session_id)
|
|
||||||
if not session:
|
|
||||||
return self.http_status(404, -1, 'Session not found')
|
|
||||||
|
|
||||||
data = {'status': session['status']}
|
|
||||||
|
|
||||||
if session['status'] == 'success':
|
|
||||||
data['appid'] = session['appid']
|
|
||||||
data['secret'] = session['secret']
|
|
||||||
if session.get('user_openid'):
|
|
||||||
data['user_openid'] = session['user_openid']
|
|
||||||
_qqofficial_sessions.pop(session_id, None)
|
|
||||||
elif session['status'] in ('error', 'expired'):
|
|
||||||
data['error'] = session['error']
|
|
||||||
_qqofficial_sessions.pop(session_id, None)
|
|
||||||
|
|
||||||
return self.success(data=data)
|
|
||||||
|
|
||||||
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
|
|
||||||
async def _(session_id: str) -> str:
|
|
||||||
"""Cancel and clean up a QQ Official QR binding session."""
|
|
||||||
session = _qqofficial_sessions.pop(session_id, None)
|
|
||||||
if session and session.get('task') and not session['task'].done():
|
|
||||||
session['task'].cancel()
|
|
||||||
return self.success(data={})
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ class RuntimePipeline:
|
|||||||
bot_message=query.resp_messages[-1],
|
bot_message=query.resp_messages[-1],
|
||||||
message=result.user_notice,
|
message=result.user_notice,
|
||||||
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
|
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
|
||||||
is_final=[msg.is_final for msg in query.resp_messages][-1],
|
is_final=[msg.is_final for msg in query.resp_messages][0],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await query.adapter.reply_message(
|
await query.adapter.reply_message(
|
||||||
|
|||||||
@@ -42,13 +42,9 @@ class QueryPool:
|
|||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
pipeline_uuid: typing.Optional[str] = None,
|
pipeline_uuid: typing.Optional[str] = None,
|
||||||
routed_by_rule: bool = False,
|
routed_by_rule: bool = False,
|
||||||
variables: typing.Optional[dict[str, typing.Any]] = None,
|
|
||||||
) -> pipeline_query.Query:
|
) -> pipeline_query.Query:
|
||||||
async with self.condition:
|
async with self.condition:
|
||||||
query_id = self.query_id_counter
|
query_id = self.query_id_counter
|
||||||
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
|
|
||||||
if variables:
|
|
||||||
initial_variables.update(variables)
|
|
||||||
query = pipeline_query.Query(
|
query = pipeline_query.Query(
|
||||||
bot_uuid=bot_uuid,
|
bot_uuid=bot_uuid,
|
||||||
query_id=query_id,
|
query_id=query_id,
|
||||||
@@ -57,7 +53,7 @@ class QueryPool:
|
|||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
message_event=message_event,
|
message_event=message_event,
|
||||||
message_chain=message_chain,
|
message_chain=message_chain,
|
||||||
variables=initial_variables,
|
variables={'_routed_by_rule': routed_by_rule},
|
||||||
resp_messages=[],
|
resp_messages=[],
|
||||||
resp_message_chain=[],
|
resp_message_chain=[],
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class SendResponseBackStage(stage.PipelineStage):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if await query.adapter.is_stream_output_supported() and has_chunks:
|
if await query.adapter.is_stream_output_supported() and has_chunks:
|
||||||
is_final = [msg.is_final for msg in query.resp_messages][-1]
|
is_final = [msg.is_final for msg in query.resp_messages][0]
|
||||||
await query.adapter.reply_message_chunk(
|
await query.adapter.reply_message_chunk(
|
||||||
message_source=query.message_event,
|
message_source=query.message_event,
|
||||||
bot_message=query.resp_messages[-1],
|
bot_message=query.resp_messages[-1],
|
||||||
|
|||||||
@@ -501,8 +501,6 @@ class PlatformManager:
|
|||||||
bot_entity.adapter_config,
|
bot_entity.adapter_config,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
if hasattr(adapter_inst, 'ap'):
|
|
||||||
adapter_inst.ap = self.ap
|
|
||||||
|
|
||||||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||||
|
|||||||
@@ -491,11 +491,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||||
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
|
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
|
||||||
|
|
||||||
on_websocket_connection_event_cache: list[aiocqhttp.Event] = []
|
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
|
||||||
_listener_wrappers: dict[
|
|
||||||
tuple[typing.Type[platform_events.Event], typing.Callable],
|
|
||||||
tuple[str, typing.Callable],
|
|
||||||
] = {}
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -510,7 +506,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
|
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
|
||||||
|
|
||||||
self.on_websocket_connection_event_cache = []
|
self.on_websocket_connection_event_cache = []
|
||||||
self._listener_wrappers = {}
|
|
||||||
|
|
||||||
if 'access-token' in config:
|
if 'access-token' in config:
|
||||||
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
|
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
|
||||||
@@ -518,16 +513,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
else:
|
else:
|
||||||
self.bot = aiocqhttp.CQHttp()
|
self.bot = aiocqhttp.CQHttp()
|
||||||
|
|
||||||
self.bot.on_websocket_connection(self._on_websocket_connection)
|
|
||||||
|
|
||||||
async def _on_websocket_connection(self, event: aiocqhttp.Event):
|
|
||||||
for cached_event in self.on_websocket_connection_event_cache:
|
|
||||||
if cached_event.self_id == event.self_id and cached_event.time == event.time:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.on_websocket_connection_event_cache.append(event)
|
|
||||||
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
|
|
||||||
|
|
||||||
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):
|
||||||
# Check if message contains a Forward component
|
# Check if message contains a Forward component
|
||||||
forward_msg = message.get_first(platform_message.Forward)
|
forward_msg = message.get_first(platform_message.Forward)
|
||||||
@@ -663,14 +648,22 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
|
|
||||||
if event_type == platform_events.GroupMessage:
|
if event_type == platform_events.GroupMessage:
|
||||||
self.bot.on_message('group')(on_message)
|
self.bot.on_message('group')(on_message)
|
||||||
self._listener_wrappers[(event_type, callback)] = ('message.group', on_message)
|
|
||||||
# self.bot.on_notice()(on_message)
|
# self.bot.on_notice()(on_message)
|
||||||
elif event_type == platform_events.FriendMessage:
|
elif event_type == platform_events.FriendMessage:
|
||||||
self.bot.on_message('private')(on_message)
|
self.bot.on_message('private')(on_message)
|
||||||
self._listener_wrappers[(event_type, callback)] = ('message.private', on_message)
|
|
||||||
# self.bot.on_notice()(on_message)
|
# self.bot.on_notice()(on_message)
|
||||||
# print(event_type)
|
# print(event_type)
|
||||||
|
|
||||||
|
async def on_websocket_connection(event: aiocqhttp.Event):
|
||||||
|
for event in self.on_websocket_connection_event_cache:
|
||||||
|
if event.self_id == event.self_id and event.time == event.time:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.on_websocket_connection_event_cache.append(event)
|
||||||
|
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
|
||||||
|
|
||||||
|
self.bot.on_websocket_connection(on_websocket_connection)
|
||||||
|
|
||||||
def unregister_listener(
|
def unregister_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -678,12 +671,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||||
],
|
],
|
||||||
):
|
):
|
||||||
listener = self._listener_wrappers.pop((event_type, callback), None)
|
return super().unregister_listener(event_type, callback)
|
||||||
if listener is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
event_name, wrapper = listener
|
|
||||||
self.bot._bus.unsubscribe(event_name, wrapper)
|
|
||||||
|
|
||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
await self.bot._server_app.run_task(**self.config)
|
await self.bot._server_app.run_task(**self.config)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -103,41 +103,6 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "填写你的卡片template_id"
|
default: "填写你的卡片template_id"
|
||||||
- name: human_input_card_template_download
|
|
||||||
label:
|
|
||||||
en_US: Download Human Input Card Template
|
|
||||||
zh_Hans: 下载人工输入卡片模板
|
|
||||||
zh_Hant: 下載人工輸入卡片範本
|
|
||||||
description:
|
|
||||||
en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior."
|
|
||||||
zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。"
|
|
||||||
zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。"
|
|
||||||
type: download-link
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
url: /api/v1/platform/adapters/dingtalk/human-input-card-template
|
|
||||||
download_filename: dingtalk_human_input_card.json
|
|
||||||
help_links:
|
|
||||||
zh: https://open-dev.dingtalk.com/fe/card
|
|
||||||
en: https://open-dev.dingtalk.com/fe/card
|
|
||||||
ja: https://open-dev.dingtalk.com/fe/card
|
|
||||||
help_label:
|
|
||||||
en_US: Import Guide
|
|
||||||
zh_Hans: 导入指引
|
|
||||||
zh_Hant: 匯入指引
|
|
||||||
ja_JP: インポート手順
|
|
||||||
- name: human_input_card_template_id
|
|
||||||
label:
|
|
||||||
en_US: Human Input Card Template ID
|
|
||||||
zh_Hans: 人工输入卡片模板ID
|
|
||||||
zh_Hant: 人工輸入卡片範本ID
|
|
||||||
description:
|
|
||||||
en_US: "Paste the template ID generated after importing the human input card template."
|
|
||||||
zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。"
|
|
||||||
zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./dingtalk.py
|
path: ./dingtalk.py
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import ui as discord_ui
|
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import re
|
import re
|
||||||
@@ -9,8 +8,6 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
# 使用BytesIO创建文件对象,避免路径问题
|
# 使用BytesIO创建文件对象,避免路径问题
|
||||||
import io
|
import io
|
||||||
@@ -827,69 +824,6 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DiscordFormView(discord_ui.View):
|
|
||||||
"""Discord ``ui.View`` that renders one button per Dify form action.
|
|
||||||
|
|
||||||
Each button's click triggers ``adapter._on_form_button_click`` which
|
|
||||||
acks the interaction, locks the buttons in place, and enqueues a
|
|
||||||
synthetic ``_dify_form_action`` query so the runner resumes the
|
|
||||||
workflow.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Discord button style mapping for Dify ``button_style`` values.
|
|
||||||
_STYLE_MAP: typing.ClassVar[dict] = {
|
|
||||||
'primary': discord.ButtonStyle.primary,
|
|
||||||
'danger': discord.ButtonStyle.danger,
|
|
||||||
'warning': discord.ButtonStyle.danger,
|
|
||||||
'success': discord.ButtonStyle.success,
|
|
||||||
'default': discord.ButtonStyle.secondary,
|
|
||||||
'': discord.ButtonStyle.secondary,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
adapter: 'DiscordAdapter',
|
|
||||||
session_key: str,
|
|
||||||
actions: list,
|
|
||||||
timeout: float = 1800,
|
|
||||||
):
|
|
||||||
super().__init__(timeout=timeout)
|
|
||||||
self._adapter = adapter
|
|
||||||
self._session_key = session_key
|
|
||||||
# Discord caps a view at 25 children (5 rows × 5 buttons). Trim
|
|
||||||
# silently — most Dify forms have ≤10 actions in practice.
|
|
||||||
for idx, action in enumerate(actions[:25]):
|
|
||||||
action_id = str(action.get('id') or '')
|
|
||||||
label = str(action.get('title') or action_id or f'Option {idx + 1}')
|
|
||||||
style = self._STYLE_MAP.get(
|
|
||||||
str(action.get('button_style') or '').lower(),
|
|
||||||
discord.ButtonStyle.secondary,
|
|
||||||
)
|
|
||||||
# custom_id must be unique within the view and ≤100 chars.
|
|
||||||
# Encode (session, idx) so we can recover the action even
|
|
||||||
# if Dify ids contain unsafe characters.
|
|
||||||
custom_id = f'lb_form:{idx}:{action_id[:80]}'[:100]
|
|
||||||
button = discord_ui.Button(
|
|
||||||
label=label[:80], # Discord label limit
|
|
||||||
style=style,
|
|
||||||
custom_id=custom_id,
|
|
||||||
)
|
|
||||||
button.callback = self._make_callback(action_id, label)
|
|
||||||
self.add_item(button)
|
|
||||||
|
|
||||||
def _make_callback(self, action_id: str, action_title: str):
|
|
||||||
async def _cb(interaction: discord.Interaction):
|
|
||||||
await self._adapter._on_form_button_click(
|
|
||||||
interaction=interaction,
|
|
||||||
session_key=self._session_key,
|
|
||||||
action_id=action_id,
|
|
||||||
action_title=action_title,
|
|
||||||
view=self,
|
|
||||||
)
|
|
||||||
|
|
||||||
return _cb
|
|
||||||
|
|
||||||
|
|
||||||
class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: discord.Client = pydantic.Field(exclude=True)
|
bot: discord.Client = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
@@ -903,10 +837,6 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)
|
voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)
|
||||||
|
|
||||||
# Injected by botmgr at construction so the form-button callback can
|
|
||||||
# enqueue a synthetic resume query (`_dify_form_action`) on the pool.
|
|
||||||
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||||
bot_account_id = config['client_id']
|
bot_account_id = config['client_id']
|
||||||
|
|
||||||
@@ -930,18 +860,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
# Proxy: config > env var > auto-detect.
|
if os.getenv('http_proxy'):
|
||||||
# discord.py uses aiohttp which does NOT respect http_proxy env
|
args['proxy'] = os.getenv('http_proxy')
|
||||||
# vars by default — we must pass proxy= explicitly.
|
|
||||||
proxy = (
|
|
||||||
config.get('proxy')
|
|
||||||
or os.getenv('http_proxy')
|
|
||||||
or os.getenv('HTTP_PROXY')
|
|
||||||
or os.getenv('https_proxy')
|
|
||||||
or os.getenv('HTTPS_PROXY')
|
|
||||||
)
|
|
||||||
if proxy:
|
|
||||||
args['proxy'] = proxy
|
|
||||||
|
|
||||||
bot = MyClient(intents=intents, **args)
|
bot = MyClient(intents=intents, **args)
|
||||||
|
|
||||||
@@ -955,19 +875,6 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-resp-message-id buffer for the accumulated text yielded by
|
|
||||||
# the runner. Discord's edit-message ratelimit (5/5s) makes true
|
|
||||||
# progressive streaming impractical, so we collect chunks and
|
|
||||||
# render once on is_final. ``_form_data`` on the final chunk
|
|
||||||
# diverts to the button-view path.
|
|
||||||
self._stream_buffer: dict[str, str] = {}
|
|
||||||
# session_key -> {form_data, channel_id, thread_id, sender_id,
|
|
||||||
# posted_at, view_message_id}
|
|
||||||
# Populated when we send a form view; consumed when the user
|
|
||||||
# clicks a button so we know which workflow_run / form_token to
|
|
||||||
# resume.
|
|
||||||
self._pending_forms: dict[str, dict] = {}
|
|
||||||
|
|
||||||
# Voice functionality methods
|
# Voice functionality methods
|
||||||
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
|
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
|
||||||
"""
|
"""
|
||||||
@@ -1161,12 +1068,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
msg_to_send, files = await self.message_converter.yiri2target(message)
|
msg_to_send, files = await self.message_converter.yiri2target(message)
|
||||||
|
|
||||||
# Synthetic events (button-click resume) have no inbound discord
|
assert isinstance(message_source.source_platform_object, discord.Message)
|
||||||
# Message. Route via the channel we cached when the user clicked.
|
|
||||||
source = message_source.source_platform_object
|
|
||||||
if not isinstance(source, discord.Message):
|
|
||||||
await self._reply_synthetic(message_source, msg_to_send, files)
|
|
||||||
return
|
|
||||||
|
|
||||||
args = {
|
args = {
|
||||||
'content': msg_to_send,
|
'content': msg_to_send,
|
||||||
@@ -1176,7 +1078,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
args['files'] = files
|
args['files'] = files
|
||||||
|
|
||||||
if quote_origin:
|
if quote_origin:
|
||||||
args['reference'] = source
|
args['reference'] = message_source.source_platform_object
|
||||||
|
|
||||||
has_at = False
|
has_at = False
|
||||||
|
|
||||||
@@ -1188,422 +1090,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if has_at:
|
if has_at:
|
||||||
args['mention_author'] = True
|
args['mention_author'] = True
|
||||||
|
|
||||||
await source.channel.send(**args)
|
await message_source.source_platform_object.channel.send(**args)
|
||||||
|
|
||||||
async def _reply_synthetic(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
msg_to_send: str,
|
|
||||||
files: list,
|
|
||||||
) -> None:
|
|
||||||
"""Deliver a reply for a button-click-resumed (synthetic) event.
|
|
||||||
|
|
||||||
We don't have an inbound discord.Message to anchor to; instead
|
|
||||||
look up the channel cached in ``_pending_forms[session_key +
|
|
||||||
'__last_channel']`` from the most recent button click.
|
|
||||||
"""
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
# _handle_form_chunk uses channel_id alone as the session
|
|
||||||
# scope, and launcher_id was set to channel_id when
|
|
||||||
# synthesizing the event.
|
|
||||||
session_key = f'c:{message_source.group.id}'
|
|
||||||
else:
|
|
||||||
session_key = f'p:{message_source.sender.id}'
|
|
||||||
|
|
||||||
cached = self._pending_forms.get(session_key + '__last_channel') or {}
|
|
||||||
channel = cached.get('channel')
|
|
||||||
if channel is None:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Discord: synthetic reply has no cached channel for '
|
|
||||||
f'{session_key}; dropping content (len={len(msg_to_send)})'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
args: dict[str, typing.Any] = {'content': msg_to_send}
|
|
||||||
if files:
|
|
||||||
args['files'] = files
|
|
||||||
try:
|
|
||||||
await channel.send(**args)
|
|
||||||
except Exception:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.error(f'Discord: synthetic reply send failed: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
# Discord allows 5 edits per 5 seconds per message. We throttle
|
|
||||||
# to one edit per 8 runner-chunks (runner already yields every 8
|
|
||||||
# text_chunks internally), which stays comfortably within limits.
|
|
||||||
_STREAM_EDIT_INTERVAL = 8
|
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
|
||||||
"""Set up a stream context for progressive editing.
|
|
||||||
|
|
||||||
The first non-empty reply_message_chunk will send the initial
|
|
||||||
message; subsequent chunks edit it in place.
|
|
||||||
"""
|
|
||||||
source = event.source_platform_object
|
|
||||||
if not isinstance(source, discord.Message):
|
|
||||||
return False
|
|
||||||
self._stream_buffer[message_id] = {
|
|
||||||
'channel': source.channel,
|
|
||||||
'sent_message': None, # discord.Message set on first send
|
|
||||||
'last_content': '',
|
|
||||||
'chunk_count': 0,
|
|
||||||
}
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def reply_message_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
bot_message: typing.Any,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
is_final: bool = False,
|
|
||||||
):
|
|
||||||
msg_id = (
|
|
||||||
bot_message.get('resp_message_id')
|
|
||||||
if isinstance(bot_message, dict)
|
|
||||||
else getattr(bot_message, 'resp_message_id', None)
|
|
||||||
)
|
|
||||||
|
|
||||||
text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)]
|
|
||||||
chunk_text = '\n\n'.join(t for t in text_parts if t)
|
|
||||||
|
|
||||||
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
|
|
||||||
|
|
||||||
ctx = self._stream_buffer.get(msg_id) if msg_id else None
|
|
||||||
|
|
||||||
# If the stream ctx was not set up (create_message_card wasn't
|
|
||||||
# called, e.g. synthetic event), or the final chunk carries a
|
|
||||||
# form, skip progressive editing entirely.
|
|
||||||
if ctx is None or form_data:
|
|
||||||
try:
|
|
||||||
if form_data and is_final:
|
|
||||||
await self._handle_form_chunk(message_source, form_data)
|
|
||||||
elif is_final and chunk_text:
|
|
||||||
await self.reply_message(
|
|
||||||
message_source,
|
|
||||||
platform_message.MessageChain([platform_message.Plain(text=chunk_text)]),
|
|
||||||
quote_origin,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self._stream_buffer.pop(msg_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Progressive streaming path: send first chunk, edit subsequent.
|
|
||||||
ctx['chunk_count'] += 1
|
|
||||||
|
|
||||||
# Runner yields the full accumulated text on each chunk, so we
|
|
||||||
# always replace (not append).
|
|
||||||
if chunk_text:
|
|
||||||
ctx['last_content'] = chunk_text
|
|
||||||
|
|
||||||
sent = ctx['sent_message']
|
|
||||||
|
|
||||||
if sent is None:
|
|
||||||
# First non-empty chunk — send the initial message.
|
|
||||||
if not ctx['last_content']:
|
|
||||||
return # No content yet, wait for next chunk.
|
|
||||||
try:
|
|
||||||
sent = await ctx['channel'].send(ctx['last_content'])
|
|
||||||
ctx['sent_message'] = sent
|
|
||||||
except Exception:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.error(f'Discord stream send failed: {traceback.format_exc()}')
|
|
||||||
self._stream_buffer.pop(msg_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_final:
|
|
||||||
# Final chunk — edit to the full content, then clean up.
|
|
||||||
if ctx['last_content'] and ctx['last_content'] != sent.content:
|
|
||||||
try:
|
|
||||||
await sent.edit(content=ctx['last_content'][:2000])
|
|
||||||
except Exception:
|
|
||||||
pass # Best-effort
|
|
||||||
self._stream_buffer.pop(msg_id, None)
|
|
||||||
elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0:
|
|
||||||
# Intermediate edit — throttle to avoid rate limits.
|
|
||||||
if ctx['last_content'] and ctx['last_content'] != sent.content:
|
|
||||||
try:
|
|
||||||
await sent.edit(content=ctx['last_content'][:2000])
|
|
||||||
except Exception:
|
|
||||||
pass # Rate-limited or deleted — ignore.
|
|
||||||
|
|
||||||
async def _handle_form_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
form_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Render a Dify form pause as a Discord embed + button View.
|
|
||||||
|
|
||||||
Mirrors the QQ / Telegram / Lark form path: the button's click
|
|
||||||
callback synthesizes a ``_dify_form_action`` query so the runner's
|
|
||||||
``_merge_pending_form_action`` resumes the workflow.
|
|
||||||
"""
|
|
||||||
source = message_source.source_platform_object
|
|
||||||
|
|
||||||
actions = form_data.get('actions') or []
|
|
||||||
if not actions:
|
|
||||||
# Nothing clickable — fall back to plain text.
|
|
||||||
if source is not None:
|
|
||||||
await self.reply_message(
|
|
||||||
message_source,
|
|
||||||
platform_message.MessageChain(
|
|
||||||
[platform_message.Plain(text=str(form_data.get('node_title') or ''))]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
node_title = str(form_data.get('node_title') or 'Confirmation needed')
|
|
||||||
form_content = str(form_data.get('form_content') or '').strip()
|
|
||||||
|
|
||||||
# Two paths:
|
|
||||||
# (a) Real message — extract channel from source.
|
|
||||||
# (b) Synthetic event (button-click resume) — no
|
|
||||||
# source_platform_object; recover the channel we cached
|
|
||||||
# when the user clicked.
|
|
||||||
if isinstance(source, discord.Message):
|
|
||||||
channel = source.channel
|
|
||||||
guild_id = str(source.guild.id) if source.guild else ''
|
|
||||||
sender_id = str(source.author.id)
|
|
||||||
channel_id = str(source.channel.id)
|
|
||||||
session_key = f'c:{channel_id}' if guild_id else f'p:{sender_id}'
|
|
||||||
else:
|
|
||||||
# Synthetic event — resolve session_key from event shape,
|
|
||||||
# then look up the cached channel from the click.
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
# launcher_id was set to channel_id when we synthesized.
|
|
||||||
channel_id = str(message_source.group.id)
|
|
||||||
session_key = f'c:{channel_id}'
|
|
||||||
else:
|
|
||||||
session_key = f'p:{message_source.sender.id}'
|
|
||||||
channel_id = ''
|
|
||||||
|
|
||||||
cached = self._pending_forms.get(session_key + '__last_channel')
|
|
||||||
channel = cached.get('channel') if cached else None
|
|
||||||
guild_id = (cached or {}).get('guild_id', '')
|
|
||||||
sender_id = str(message_source.sender.id) if message_source.sender else ''
|
|
||||||
if channel is None:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Discord: synthetic form chunk has no cached channel for '
|
|
||||||
f'{session_key}; cannot render form buttons'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
body_parts: list[str] = []
|
|
||||||
if form_content:
|
|
||||||
body_parts.append(form_content)
|
|
||||||
embed_body = '\n\n'.join(body_parts)
|
|
||||||
# Discord embed.description has a 4096 char limit — defensive trim.
|
|
||||||
if len(embed_body) > 4000:
|
|
||||||
embed_body = embed_body[:3990] + '\n\n…(truncated)'
|
|
||||||
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=node_title[:256],
|
|
||||||
description=embed_body,
|
|
||||||
color=discord.Color.blurple(),
|
|
||||||
)
|
|
||||||
|
|
||||||
view = DiscordFormView(
|
|
||||||
adapter=self,
|
|
||||||
session_key=session_key,
|
|
||||||
actions=actions,
|
|
||||||
timeout=1800, # 30 min — matches Dify form_token TTL
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
sent_msg = await channel.send(embed=embed, view=view)
|
|
||||||
except Exception:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.error(f'Discord: form view send failed: {traceback.format_exc()}')
|
|
||||||
return
|
|
||||||
|
|
||||||
self._pending_forms[session_key] = {
|
|
||||||
'form_data': form_data,
|
|
||||||
'channel_id': channel_id,
|
|
||||||
'guild_id': guild_id,
|
|
||||||
'sender_id': sender_id,
|
|
||||||
'view_message_id': str(sent_msg.id),
|
|
||||||
'posted_at': time.time(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.info(f'Discord: form view posted session={session_key} actions={len(actions)}')
|
|
||||||
|
|
||||||
async def _on_form_button_click(
|
|
||||||
self,
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
session_key: str,
|
|
||||||
action_id: str,
|
|
||||||
action_title: str,
|
|
||||||
view: DiscordFormView,
|
|
||||||
) -> None:
|
|
||||||
"""Handle a click on a form button — ack, resume the workflow,
|
|
||||||
and disable the View buttons so the choice is visually locked in."""
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
|
||||||
|
|
||||||
# ACK first (3-second deadline before Discord shows "interaction failed").
|
|
||||||
try:
|
|
||||||
await interaction.response.defer()
|
|
||||||
except discord.HTTPException:
|
|
||||||
# Already responded somehow — proceed regardless.
|
|
||||||
pass
|
|
||||||
|
|
||||||
pending = self._pending_forms.get(session_key)
|
|
||||||
if not pending:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Discord: button click on stale session {session_key}; ignoring (action_id={action_id!r})'
|
|
||||||
)
|
|
||||||
await self._lock_view_message(interaction, view, action_title, stale=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
form_data: dict = pending.get('form_data') or {}
|
|
||||||
guild_id = pending.get('guild_id', '')
|
|
||||||
channel_id = pending.get('channel_id', '')
|
|
||||||
initiator_id = str(pending.get('sender_id', '') or '')
|
|
||||||
actor_id = str(interaction.user.id) if interaction.user is not None else initiator_id
|
|
||||||
if not guild_id and initiator_id and actor_id != initiator_id:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Discord: user {actor_id} cannot act on private form created for {initiator_id}'
|
|
||||||
)
|
|
||||||
await self._lock_view_message(interaction, view, action_title, stale=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._pending_forms.pop(session_key, None)
|
|
||||||
|
|
||||||
# Lock the buttons in place: disable everything, mark chosen one.
|
|
||||||
await self._lock_view_message(interaction, view, action_title)
|
|
||||||
|
|
||||||
# In group context the launcher remains the channel so Dify resumes
|
|
||||||
# the original group session. The synthetic sender is still the real
|
|
||||||
# clicker, preserving actor identity for auditing and routing rules.
|
|
||||||
if guild_id:
|
|
||||||
launcher_type = provider_session.LauncherTypes.GROUP
|
|
||||||
launcher_id = channel_id
|
|
||||||
else:
|
|
||||||
launcher_type = provider_session.LauncherTypes.PERSON
|
|
||||||
launcher_id = initiator_id or actor_id
|
|
||||||
|
|
||||||
form_action_data = {
|
|
||||||
'form_token': form_data.get('form_token', ''),
|
|
||||||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
|
||||||
'action_id': action_id,
|
|
||||||
'action_title': action_title,
|
|
||||||
'node_title': form_data.get('node_title', ''),
|
|
||||||
'user': f'{launcher_type.value}_{launcher_id}',
|
|
||||||
'inputs': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
|
|
||||||
|
|
||||||
# Synthesize a platform event so the pipeline can run the resume
|
|
||||||
# query. source_platform_object=None signals "no inbound discord
|
|
||||||
# message" — reply_message must tolerate this (it falls through
|
|
||||||
# to channel.send via the cached interaction.channel below).
|
|
||||||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
|
||||||
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
|
||||||
sender=platform_entities.GroupMember(
|
|
||||||
id=actor_id,
|
|
||||||
member_name=interaction.user.display_name if interaction.user else '',
|
|
||||||
permission='MEMBER',
|
|
||||||
group=platform_entities.Group(
|
|
||||||
id=launcher_id,
|
|
||||||
name=channel_id,
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
),
|
|
||||||
special_title='',
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
time=int(time.time()),
|
|
||||||
source_platform_object=None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
synthetic_event = platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(
|
|
||||||
id=actor_id,
|
|
||||||
nickname=interaction.user.display_name if interaction.user else '',
|
|
||||||
remark='',
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
time=int(time.time()),
|
|
||||||
source_platform_object=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ap is None:
|
|
||||||
if self.logger:
|
|
||||||
await self.logger.error('Discord: ap not injected; cannot enqueue button-click query')
|
|
||||||
return
|
|
||||||
|
|
||||||
bot_uuid = ''
|
|
||||||
pipeline_uuid = form_data.get('pipeline_uuid') or None
|
|
||||||
for bot in self.ap.platform_mgr.bots:
|
|
||||||
if bot.adapter is self:
|
|
||||||
bot_uuid = bot.bot_entity.uuid
|
|
||||||
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
|
|
||||||
break
|
|
||||||
|
|
||||||
# Remember the channel so _reply_synthetic and _handle_form_chunk
|
|
||||||
# (synthetic-event path) can find a target. guild_id is needed
|
|
||||||
# to reconstruct the launcher_type on subsequent form pauses.
|
|
||||||
self._pending_forms[session_key + '__last_channel'] = {
|
|
||||||
'channel': interaction.channel,
|
|
||||||
'guild_id': guild_id,
|
|
||||||
'posted_at': time.time(),
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.ap.query_pool.add_query(
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
launcher_type=launcher_type,
|
|
||||||
launcher_id=launcher_id,
|
|
||||||
sender_id=actor_id,
|
|
||||||
message_event=synthetic_event,
|
|
||||||
message_chain=message_chain,
|
|
||||||
adapter=self,
|
|
||||||
pipeline_uuid=pipeline_uuid,
|
|
||||||
variables={
|
|
||||||
'_dify_form_action': form_action_data,
|
|
||||||
'_routed_by_rule': True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'Discord: button-click query enqueued action_id={action_id!r} '
|
|
||||||
f'session={session_key} actor_id={actor_id}'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.error(f'Discord: enqueue button-click query failed: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
async def _lock_view_message(
|
|
||||||
self,
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
view: DiscordFormView,
|
|
||||||
chosen_title: str,
|
|
||||||
stale: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Disable all buttons on the form view and annotate the chosen
|
|
||||||
one — mirrors DingTalk/Lark's in-card selection feedback."""
|
|
||||||
try:
|
|
||||||
for child in view.children:
|
|
||||||
if not isinstance(child, discord_ui.Button):
|
|
||||||
continue
|
|
||||||
child.disabled = True
|
|
||||||
if not stale and child.label == chosen_title:
|
|
||||||
child.style = discord.ButtonStyle.success
|
|
||||||
if not (child.label or '').startswith('✓ '):
|
|
||||||
child.label = f'✓ {child.label}'
|
|
||||||
view.stop()
|
|
||||||
if interaction.message is not None:
|
|
||||||
await interaction.message.edit(view=view)
|
|
||||||
except Exception:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.warning(f'Discord: lock-view-message failed (non-fatal): {traceback.format_exc()}')
|
|
||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
|
|||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
from langbot.libs.qq_official_api.api import (
|
from langbot.libs.qq_official_api.api import QQOfficialClient
|
||||||
QQ_SELECT_ACTION_PREFIX,
|
|
||||||
QQOfficialClient,
|
|
||||||
build_keyboard_from_form,
|
|
||||||
build_keyboard_from_select_field,
|
|
||||||
resolve_select_button_action,
|
|
||||||
)
|
|
||||||
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
||||||
from ...utils import image
|
from ...utils import image
|
||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
@@ -197,7 +191,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
enable_webhook: bool = False
|
enable_webhook: bool = False
|
||||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||||
ap: typing.Any = None
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
enable_webhook = config.get('enable-webhook', False)
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
@@ -223,31 +216,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
self._stream_ctx_ts: dict[str, float] = {}
|
self._stream_ctx_ts: dict[str, float] = {}
|
||||||
self._fallback_text: dict[str, str] = {}
|
self._fallback_text: dict[str, str] = {}
|
||||||
self._fallback_text_ts: dict[str, float] = {}
|
self._fallback_text_ts: dict[str, float] = {}
|
||||||
# Dify form-action bookkeeping for the human-input button flow.
|
|
||||||
# session_key = "<scene>_<id>" where scene is c2c/group/channel and
|
|
||||||
# id is user_openid / group_openid / channel_id.
|
|
||||||
# session_key -> {form_data, msg_id, event_id, scene, target_id,
|
|
||||||
# sender_id, posted_at}
|
|
||||||
# Set when we send a markdown+keyboard card and consulted when:
|
|
||||||
# (a) INTERACTION_CREATE fires — we look up the form by
|
|
||||||
# session_key (button's `data` carries the action_id),
|
|
||||||
# (b) the resumed-workflow query needs to find a passive-reply
|
|
||||||
# event_id (INTERACTION_CREATE id, 30-min validity).
|
|
||||||
self._pending_forms: dict[str, dict] = {}
|
|
||||||
# session_key -> most recent ``INTERACTION_CREATE`` event_id, used
|
|
||||||
# as the passive event_id for the resumed query's LLM output.
|
|
||||||
self._session_event_ids: dict[str, dict] = {}
|
|
||||||
# Per-anchor msg_seq counter. QQ accepts up to 5 passive replies
|
|
||||||
# per (msg_id|event_id) within 60 min, but each reuse needs a
|
|
||||||
# fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd.
|
|
||||||
self._anchor_msg_seq: dict[str, int] = {}
|
|
||||||
|
|
||||||
# Wire button-click handler so webhook mode catches INTERACTION_CREATE.
|
|
||||||
# (ws mode is wired separately via on_event in _run_websocket so the
|
|
||||||
# raw payload bypasses get_message's message-only flattening.)
|
|
||||||
@self.bot.on_interaction()
|
|
||||||
async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]):
|
|
||||||
await self._handle_interaction_create(event_data, interaction_id)
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -259,13 +227,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
message_source,
|
message_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Synthetic event (button-click resume): no inbound platform
|
|
||||||
# object → no msg_id. Route via the cached INTERACTION_CREATE
|
|
||||||
# event_id (valid 30 min, no quota cost).
|
|
||||||
if qq_official_event is None:
|
|
||||||
await self._reply_synthetic(message_source, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||||
|
|
||||||
# 确定 target_type 和 target_id
|
# 确定 target_type 和 target_id
|
||||||
@@ -415,9 +376,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
await self.logger.info('QQ Official WebSocket connected and ready')
|
await self.logger.info('QQ Official WebSocket connected and ready')
|
||||||
|
|
||||||
async def on_event(event_type: str, event_data: dict):
|
async def on_event(event_type: str, event_data: dict):
|
||||||
# INTERACTION_CREATE is dispatched via bot.on_interaction()
|
|
||||||
# (registered in __init__) so we get the top-level ws_event_id
|
|
||||||
# — needed as the passive-reply event_id. It never reaches here.
|
|
||||||
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
||||||
message_event_types = {
|
message_event_types = {
|
||||||
'C2C_MESSAGE_CREATE',
|
'C2C_MESSAGE_CREATE',
|
||||||
@@ -479,36 +437,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
return self.config.get('enable-stream-reply', False)
|
return self.config.get('enable-stream-reply', False)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_form_placeholder_chunk(text: str) -> bool:
|
|
||||||
"""Return True for invisible placeholder chunks used to carry forms."""
|
|
||||||
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
|
|
||||||
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
|
||||||
# Some Windows consoles/logs display the zero-width placeholder as
|
|
||||||
# mojibake. Treat those variants as the same non-user-facing marker.
|
|
||||||
return cleaned in {'', '鈥?', '​'}
|
|
||||||
|
|
||||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||||
source = event.source_platform_object
|
source = event.source_platform_object
|
||||||
# Synthetic events (button-click resume) have no source object —
|
|
||||||
# they ride a cached INTERACTION_CREATE event_id, not a streamable
|
|
||||||
# msg_id. Skip stream setup; reply_message handles the one-shot
|
|
||||||
# send at is_final.
|
|
||||||
if source is None:
|
|
||||||
return False
|
|
||||||
# Streaming API only supports C2C private chat
|
# Streaming API only supports C2C private chat
|
||||||
if source.t != 'C2C_MESSAGE_CREATE':
|
if source.t != 'C2C_MESSAGE_CREATE':
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# The stream endpoint still consumes msg_seq for this inbound msg_id.
|
|
||||||
# Keep the passive-reply counter in sync so a follow-up form card uses
|
|
||||||
# msg_seq=2 instead of being deduplicated by QQ as another seq=1 send.
|
|
||||||
if source.d_id:
|
|
||||||
self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1)
|
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
'user_openid': source.user_openid,
|
'user_openid': source.user_openid,
|
||||||
'msg_id': source.d_id,
|
'msg_id': source.d_id,
|
||||||
@@ -535,38 +469,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
):
|
):
|
||||||
# Periodically clean up stale stream contexts
|
# Periodically clean up stale stream contexts
|
||||||
await self._cleanup_stale_streams()
|
await self._cleanup_stale_streams()
|
||||||
|
|
||||||
# Dify human-input pause: when the runner attaches `_form_data` to
|
|
||||||
# the final chunk, finalize any in-flight stream session and send
|
|
||||||
# a markdown + keyboard message instead. Plain-text content from
|
|
||||||
# earlier chunks is already on the stream; we close it cleanly
|
|
||||||
# and the buttons land as a separate reply.
|
|
||||||
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
|
|
||||||
if is_final:
|
|
||||||
_resume = getattr(bot_message, '_resume_from_form', None) if not isinstance(bot_message, dict) else None
|
|
||||||
_open_new = getattr(bot_message, '_open_new_card', None) if not isinstance(bot_message, dict) else None
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'QQ Official reply_message_chunk final: '
|
|
||||||
f'type={type(bot_message).__name__} '
|
|
||||||
f'is_final={is_final} '
|
|
||||||
f'form_data_present={form_data is not None} '
|
|
||||||
f'resume_from_form={_resume} open_new_card={_open_new} '
|
|
||||||
f'content_len={len(getattr(bot_message, "content", "") or "")}'
|
|
||||||
)
|
|
||||||
if form_data and is_final:
|
|
||||||
await self._handle_form_chunk(message_source, message, form_data)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 提取纯文本内容(当前 chunk 的文本)
|
# 提取纯文本内容(当前 chunk 的文本)
|
||||||
text_parts = []
|
text_parts = []
|
||||||
for msg in message:
|
for msg in message:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
text_parts.append(msg.text)
|
text_parts.append(msg.text)
|
||||||
chunk_text = '\n\n'.join(text_parts)
|
chunk_text = '\n\n'.join(text_parts)
|
||||||
if self._is_form_placeholder_chunk(chunk_text):
|
|
||||||
await self.logger.debug('QQ Official: skipped invisible form placeholder chunk')
|
|
||||||
return
|
|
||||||
|
|
||||||
message_id = (
|
message_id = (
|
||||||
bot_message.get('resp_message_id')
|
bot_message.get('resp_message_id')
|
||||||
@@ -576,8 +484,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
if not message_id or message_id not in self._stream_ctx:
|
if not message_id or message_id not in self._stream_ctx:
|
||||||
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
||||||
if chunk_text:
|
if chunk_text:
|
||||||
# Chunks carry the latest full snapshot, not a text delta.
|
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
|
||||||
self._fallback_text[message_id] = chunk_text
|
|
||||||
self._fallback_text_ts[message_id] = time.time()
|
self._fallback_text_ts[message_id] = time.time()
|
||||||
if is_final:
|
if is_final:
|
||||||
full_text = self._fallback_text.pop(message_id, '')
|
full_text = self._fallback_text.pop(message_id, '')
|
||||||
@@ -590,7 +497,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
|
|
||||||
# 累积文本
|
# 累积文本
|
||||||
if chunk_text:
|
if chunk_text:
|
||||||
ctx['accumulated_text'] = chunk_text
|
ctx['accumulated_text'] += chunk_text
|
||||||
|
|
||||||
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
||||||
if not ctx['session_started']:
|
if not ctx['session_started']:
|
||||||
@@ -650,489 +557,3 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
],
|
],
|
||||||
):
|
):
|
||||||
return super().unregister_listener(event_type, callback)
|
return super().unregister_listener(event_type, callback)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Dify human-input button-interaction support
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
_PENDING_FORM_TTL = 1800 # 30 min — matches QQ passive-reply window.
|
|
||||||
_MAX_REPLIES_PER_ANCHOR = 5 # QQ hard limit per msg_id / event_id.
|
|
||||||
|
|
||||||
def _next_msg_seq(self, anchor: str) -> typing.Optional[int]:
|
|
||||||
"""Return the next msg_seq for an anchor, or ``None`` if the
|
|
||||||
anchor has already been used 5 times (further sends would be
|
|
||||||
silently dropped by QQ)."""
|
|
||||||
if not anchor:
|
|
||||||
return 1
|
|
||||||
used = self._anchor_msg_seq.get(anchor, 0)
|
|
||||||
if used >= self._MAX_REPLIES_PER_ANCHOR:
|
|
||||||
return None
|
|
||||||
self._anchor_msg_seq[anchor] = used + 1
|
|
||||||
return used + 1
|
|
||||||
|
|
||||||
async def _reply_synthetic(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
) -> None:
|
|
||||||
"""Deliver a reply for a synthetic (button-click-resume) event.
|
|
||||||
|
|
||||||
Synthetic events have ``source_platform_object=None`` and no
|
|
||||||
fresh inbound msg_id. The previous INTERACTION_CREATE id we
|
|
||||||
cached in :attr:`_session_event_ids` is a valid passive-reply
|
|
||||||
anchor (``event_id``) for up to 30 minutes — use it.
|
|
||||||
"""
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
target_type = 'group'
|
|
||||||
group = getattr(message_source, 'group', None) or (
|
|
||||||
message_source.sender.group if hasattr(message_source.sender, 'group') else None
|
|
||||||
)
|
|
||||||
target_id = str(group.id) if group else None
|
|
||||||
else:
|
|
||||||
target_type = 'c2c'
|
|
||||||
target_id = str(message_source.sender.id) if message_source.sender else None
|
|
||||||
|
|
||||||
if not target_id:
|
|
||||||
await self.logger.warning('QQ Official: synthetic reply has no target_id; dropping')
|
|
||||||
return
|
|
||||||
|
|
||||||
session_key = f'{target_type}_{target_id}'
|
|
||||||
cached = self._session_event_ids.get(session_key)
|
|
||||||
event_id = cached.get('event_id') if cached else None
|
|
||||||
if cached and (time.time() - cached.get('posted_at', 0)) > self._PENDING_FORM_TTL:
|
|
||||||
event_id = None
|
|
||||||
|
|
||||||
if not event_id:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'QQ Official: no cached event_id for {session_key}; '
|
|
||||||
f'cannot deliver synthetic reply within passive-reply window'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
|
||||||
text_parts = [c['content'] for c in content_list if c.get('type') == 'text' and c.get('content')]
|
|
||||||
if not text_parts:
|
|
||||||
await self.logger.info('QQ Official: synthetic reply has no text content; skipping')
|
|
||||||
return
|
|
||||||
text = '\n\n'.join(text_parts)
|
|
||||||
|
|
||||||
msg_seq = self._next_msg_seq(event_id)
|
|
||||||
if msg_seq is None:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'QQ Official: anchor {event_id!r} exhausted (>5 passive replies); '
|
|
||||||
f'cannot deliver synthetic reply for {session_key}'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
if target_type == 'c2c':
|
|
||||||
await self.bot.send_private_text_msg(
|
|
||||||
user_openid=target_id,
|
|
||||||
content=text,
|
|
||||||
event_id=event_id,
|
|
||||||
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:
|
|
||||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
def _resolve_target_from_source(self, source: QQOfficialEvent) -> typing.Optional[tuple[str, str]]:
|
|
||||||
"""Return ``(target_type, target_id)`` for sending a reply, or
|
|
||||||
``None`` if the scene cannot host a markdown+keyboard message."""
|
|
||||||
if source is None:
|
|
||||||
return None
|
|
||||||
if source.t == 'C2C_MESSAGE_CREATE':
|
|
||||||
return 'c2c', source.user_openid
|
|
||||||
if source.t == 'GROUP_AT_MESSAGE_CREATE':
|
|
||||||
return 'group', source.group_openid
|
|
||||||
if source.t == 'AT_MESSAGE_CREATE':
|
|
||||||
return 'channel', source.channel_id
|
|
||||||
# DIRECT_MESSAGE_CREATE uses the guild DM API which does not accept
|
|
||||||
# markdown+keyboard at the time of writing — caller falls back to text.
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _resolve_target_from_event(
|
|
||||||
self, message_source: platform_events.MessageEvent
|
|
||||||
) -> typing.Optional[tuple[str, str]]:
|
|
||||||
"""Resolve ``(target_type, target_id)`` from the public event.
|
|
||||||
|
|
||||||
Prefers the platform-native source when present; falls back to
|
|
||||||
the synthesized event's sender/group fields so button-click
|
|
||||||
resume queries can still find a destination.
|
|
||||||
"""
|
|
||||||
source = message_source.source_platform_object
|
|
||||||
if source is not None:
|
|
||||||
return self._resolve_target_from_source(source)
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
group = getattr(message_source, 'group', None) or (
|
|
||||||
message_source.sender.group
|
|
||||||
if message_source.sender and hasattr(message_source.sender, 'group')
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if group and getattr(group, 'id', None):
|
|
||||||
return 'group', str(group.id)
|
|
||||||
if isinstance(message_source, platform_events.FriendMessage):
|
|
||||||
if message_source.sender and getattr(message_source.sender, 'id', None):
|
|
||||||
return 'c2c', str(message_source.sender.id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _prune_pending_forms(self) -> None:
|
|
||||||
now = time.time()
|
|
||||||
stale = [k for k, v in self._pending_forms.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL]
|
|
||||||
for k in stale:
|
|
||||||
self._pending_forms.pop(k, None)
|
|
||||||
stale_e = [
|
|
||||||
k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL
|
|
||||||
]
|
|
||||||
for k in stale_e:
|
|
||||||
self._session_event_ids.pop(k, None)
|
|
||||||
|
|
||||||
async def _handle_form_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
form_data: dict,
|
|
||||||
) -> None:
|
|
||||||
"""Send the markdown + keyboard form prompt for a Dify pause.
|
|
||||||
|
|
||||||
Called from ``reply_message_chunk`` when the runner attaches
|
|
||||||
``_form_data`` to the final chunk. Replaces what would otherwise
|
|
||||||
be a plain-text numbered-list fallback.
|
|
||||||
"""
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'QQ Official _handle_form_chunk entered; '
|
|
||||||
f'source_present={message_source.source_platform_object is not None} '
|
|
||||||
f'form_actions={len(form_data.get("actions") or [])}'
|
|
||||||
)
|
|
||||||
self._prune_pending_forms()
|
|
||||||
|
|
||||||
source = message_source.source_platform_object
|
|
||||||
scene_target = self._resolve_target_from_event(message_source)
|
|
||||||
if scene_target is None:
|
|
||||||
# No rich-UI fit — fall through to existing text path.
|
|
||||||
await self.logger.info('QQ Official: form chunk on unsupported scene; falling back to text')
|
|
||||||
text_parts = [m.text for m in message if type(m) is platform_message.Plain]
|
|
||||||
fallback_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(text_parts))])
|
|
||||||
try:
|
|
||||||
await self.reply_message(message_source, fallback_msg)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'QQ Official: form fallback text send failed: {traceback.format_exc()}')
|
|
||||||
return
|
|
||||||
|
|
||||||
target_type, target_id = scene_target
|
|
||||||
session_key = f'{target_type}_{target_id}'
|
|
||||||
|
|
||||||
# Cancel any in-flight stream / fallback ctx so plain-text prefix
|
|
||||||
# doesn't continue alongside the keyboard message.
|
|
||||||
msg_id = getattr(source, 'd_id', '') or '' if source is not None else ''
|
|
||||||
if msg_id:
|
|
||||||
self._stream_ctx.pop(msg_id, None)
|
|
||||||
self._stream_ctx_ts.pop(msg_id, None)
|
|
||||||
self._fallback_text.pop(msg_id, None)
|
|
||||||
self._fallback_text_ts.pop(msg_id, None)
|
|
||||||
|
|
||||||
node_title = form_data.get('node_title') or 'Confirmation needed'
|
|
||||||
form_content = form_data.get('form_content') or ''
|
|
||||||
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
|
|
||||||
parts = [f'### {node_title}']
|
|
||||||
plain_parts = [node_title]
|
|
||||||
if form_content.strip():
|
|
||||||
parts.append(form_content.strip())
|
|
||||||
plain_parts.append(form_content.strip())
|
|
||||||
markdown_content = '\n\n'.join(parts)
|
|
||||||
plain_content = '\n\n'.join(plain_parts)
|
|
||||||
|
|
||||||
keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None
|
|
||||||
is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows')
|
|
||||||
if is_text_field_step:
|
|
||||||
keyboard = None
|
|
||||||
if keyboard is None and not is_text_field_step:
|
|
||||||
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
|
|
||||||
if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step:
|
|
||||||
# No actions to render — fall back to plain text.
|
|
||||||
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
|
|
||||||
try:
|
|
||||||
await self.reply_message(message_source, text_msg)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'QQ Official: empty-keyboard fallback send failed: {traceback.format_exc()}')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Prefer the inbound msg_id (no quota cost). If the source is a
|
|
||||||
# synthetic event from a prior click, the cached interaction id
|
|
||||||
# serves as event_id for up to 30 min.
|
|
||||||
event_id = None
|
|
||||||
if not msg_id:
|
|
||||||
cached = self._session_event_ids.get(session_key)
|
|
||||||
if cached and (time.time() - cached.get('posted_at', 0)) < self._PENDING_FORM_TTL:
|
|
||||||
event_id = cached.get('event_id')
|
|
||||||
|
|
||||||
anchor = msg_id or event_id or ''
|
|
||||||
msg_seq = self._next_msg_seq(anchor)
|
|
||||||
if msg_seq is None:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'QQ Official: anchor {anchor!r} exhausted (>5 passive replies); '
|
|
||||||
f'cannot deliver form card for session={session_key}'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.bot.send_markdown_keyboard(
|
|
||||||
target_type=target_type,
|
|
||||||
target_id=target_id,
|
|
||||||
markdown_content=markdown_content,
|
|
||||||
keyboard=keyboard,
|
|
||||||
msg_id=msg_id if (msg_id and not event_id) else None,
|
|
||||||
event_id=event_id,
|
|
||||||
msg_seq=msg_seq,
|
|
||||||
)
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'QQ Official: form card sent '
|
|
||||||
f'target={target_type}/{target_id} '
|
|
||||||
f'msg_id={msg_id!r} event_id={event_id!r} msg_seq={msg_seq}'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.error(
|
|
||||||
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
await self.logger.error(
|
|
||||||
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
|
|
||||||
try:
|
|
||||||
await self.reply_message(message_source, text_msg)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
sender_id = ''
|
|
||||||
if source is not None:
|
|
||||||
sender_id = (
|
|
||||||
getattr(source, 'user_openid', None)
|
|
||||||
or getattr(source, 'member_openid', None)
|
|
||||||
or getattr(source, 'd_author_id', None)
|
|
||||||
or ''
|
|
||||||
)
|
|
||||||
if not sender_id and message_source.sender is not None:
|
|
||||||
sender_id = str(getattr(message_source.sender, 'id', '') or '')
|
|
||||||
self._pending_forms[session_key] = {
|
|
||||||
'form_data': form_data,
|
|
||||||
'msg_id': msg_id,
|
|
||||||
'sender_id': sender_id,
|
|
||||||
'target_type': target_type,
|
|
||||||
'target_id': target_id,
|
|
||||||
'source_event_t': source.t if source is not None else None,
|
|
||||||
'posted_at': time.time(),
|
|
||||||
}
|
|
||||||
await self.logger.info(
|
|
||||||
f'QQ Official: form posted session={session_key} actions={len(form_data.get("actions") or [])}'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_interaction_create(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
ws_event_id: typing.Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Handle a button-click INTERACTION_CREATE event.
|
|
||||||
|
|
||||||
Two IDs at play (QQ keeps them separate):
|
|
||||||
ws_event_id top-level payload ``id`` (or webhook ``X-Bot-
|
|
||||||
Event-Id``). The ONLY value accepted as
|
|
||||||
``event_id`` for subsequent passive replies.
|
|
||||||
d['id'] the interaction id — used for PUT
|
|
||||||
/interactions/{id} ack. Cannot be reused as
|
|
||||||
event_id (QQ returns 40034025 if you try).
|
|
||||||
|
|
||||||
Layout (https://bot.q.qq.com/.../msg-btn.html):
|
|
||||||
chat_type 0 channel / 1 group / 2 c2c
|
|
||||||
data.resolved.button_data what we set as ``action.data``
|
|
||||||
data.resolved.button_id ``id`` field on the button row
|
|
||||||
"""
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
|
||||||
|
|
||||||
if self.ap is not None:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'QQ Official _handle_interaction_create entered; '
|
|
||||||
f'ws_event_id={ws_event_id!r} '
|
|
||||||
f'interaction_id={(event_data.get("id") if isinstance(event_data, dict) else None)!r} '
|
|
||||||
f'chat_type={event_data.get("chat_type") if isinstance(event_data, dict) else None}'
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(event_data, dict):
|
|
||||||
await self.logger.warning(f'QQ Official: INTERACTION_CREATE event_data is not dict: {type(event_data)}')
|
|
||||||
return
|
|
||||||
|
|
||||||
# ACK uses the interaction id, NOT the ws event id.
|
|
||||||
interaction_id = event_data.get('id') or ''
|
|
||||||
if interaction_id:
|
|
||||||
asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0))
|
|
||||||
|
|
||||||
resolved = (event_data.get('data') or {}).get('resolved') or {}
|
|
||||||
action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip()
|
|
||||||
if not action_id:
|
|
||||||
await self.logger.warning('QQ Official: INTERACTION_CREATE missing button_data/button_id; ignoring')
|
|
||||||
return
|
|
||||||
|
|
||||||
chat_type = event_data.get('chat_type')
|
|
||||||
scene_target: typing.Optional[tuple[str, str]] = None
|
|
||||||
if chat_type == 2 or event_data.get('user_openid'):
|
|
||||||
scene_target = ('c2c', event_data.get('user_openid') or '')
|
|
||||||
elif chat_type == 1 or event_data.get('group_openid'):
|
|
||||||
scene_target = ('group', event_data.get('group_openid') or '')
|
|
||||||
elif chat_type == 0 or event_data.get('channel_id'):
|
|
||||||
scene_target = ('channel', event_data.get('channel_id') or '')
|
|
||||||
|
|
||||||
if not scene_target or not scene_target[1]:
|
|
||||||
await self.logger.warning(f'QQ Official: INTERACTION_CREATE missing scene/target; raw={event_data}')
|
|
||||||
return
|
|
||||||
|
|
||||||
target_type, target_id = scene_target
|
|
||||||
session_key = f'{target_type}_{target_id}'
|
|
||||||
|
|
||||||
self._prune_pending_forms()
|
|
||||||
pending = self._pending_forms.get(session_key)
|
|
||||||
if not pending:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Cache ws_event_id so a follow-up pause / text reply can use it
|
|
||||||
# as event_id for passive delivery (30-min window). Falls back to
|
|
||||||
# the interaction_id only if no ws_event_id was provided (e.g.
|
|
||||||
# tests / older payload shape) — QQ will reject that value but
|
|
||||||
# we log so the mismatch is debuggable.
|
|
||||||
cached_event_id = ws_event_id or interaction_id
|
|
||||||
if cached_event_id:
|
|
||||||
self._session_event_ids[session_key] = {
|
|
||||||
'event_id': cached_event_id,
|
|
||||||
'posted_at': time.time(),
|
|
||||||
}
|
|
||||||
# New anchor → fresh 5-reply budget.
|
|
||||||
self._anchor_msg_seq[cached_event_id] = 0
|
|
||||||
if self.ap is not None and not ws_event_id:
|
|
||||||
self.ap.logger.warning(
|
|
||||||
'QQ Official: INTERACTION_CREATE lacked ws_event_id; '
|
|
||||||
'falling back to interaction_id (passive reply may be rejected)'
|
|
||||||
)
|
|
||||||
|
|
||||||
form_data: dict = pending.get('form_data') or {}
|
|
||||||
actions = form_data.get('actions') or []
|
|
||||||
select_choice = resolve_select_button_action(form_data, action_id)
|
|
||||||
if action_id.startswith(QQ_SELECT_ACTION_PREFIX) and select_choice is None:
|
|
||||||
await self.logger.warning(f'QQ Official: invalid select action_id={action_id!r} for {session_key}')
|
|
||||||
return
|
|
||||||
|
|
||||||
matched = None
|
|
||||||
if select_choice is None:
|
|
||||||
matched = next(
|
|
||||||
(a for a in actions if str(a.get('id', '')) == action_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if matched is None:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'QQ Official: action_id={action_id!r} is not present on pending form for {session_key}'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self._pending_forms.pop(session_key, None)
|
|
||||||
action_title = select_choice[1] if select_choice else matched.get('title') or action_id
|
|
||||||
|
|
||||||
initiator_id = str(pending.get('sender_id') or '')
|
|
||||||
actor_id = str(event_data.get('member_openid') or event_data.get('user_openid') or initiator_id)
|
|
||||||
|
|
||||||
# Build resume payload matching the shape every other adapter uses
|
|
||||||
# (DingTalk / Lark / Telegram / WeCom). The runner's
|
|
||||||
# _merge_pending_form_action consumes this verbatim.
|
|
||||||
if target_type == 'group' or target_type == 'channel':
|
|
||||||
launcher_type = provider_session.LauncherTypes.GROUP
|
|
||||||
launcher_id = target_id
|
|
||||||
else:
|
|
||||||
launcher_type = provider_session.LauncherTypes.PERSON
|
|
||||||
launcher_id = target_id
|
|
||||||
|
|
||||||
form_action_data = {
|
|
||||||
'form_token': form_data.get('form_token', ''),
|
|
||||||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
|
||||||
'action_id': '' if select_choice else action_id,
|
|
||||||
'action_title': action_title,
|
|
||||||
'node_title': form_data.get('node_title', ''),
|
|
||||||
'user': f'{launcher_type.value}_{launcher_id}',
|
|
||||||
'inputs': {'select': select_choice[1]} if select_choice else {},
|
|
||||||
}
|
|
||||||
if select_choice:
|
|
||||||
form_action_data['_current_input_field'] = select_choice[0]
|
|
||||||
form_action_data['_input_progress'] = True
|
|
||||||
|
|
||||||
event_label = 'Form Select' if select_choice else 'Form Action'
|
|
||||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[{event_label}: {action_title}]')])
|
|
||||||
|
|
||||||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
|
||||||
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
|
||||||
sender=platform_entities.GroupMember(
|
|
||||||
id=actor_id or launcher_id,
|
|
||||||
member_name='',
|
|
||||||
permission='MEMBER',
|
|
||||||
group=platform_entities.Group(
|
|
||||||
id=launcher_id,
|
|
||||||
name='',
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
),
|
|
||||||
special_title='',
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
time=int(time.time()),
|
|
||||||
source_platform_object=None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
synthetic_event = platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(
|
|
||||||
id=actor_id or launcher_id,
|
|
||||||
nickname='',
|
|
||||||
remark='',
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
time=int(time.time()),
|
|
||||||
source_platform_object=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ap is None:
|
|
||||||
await self.logger.error('QQ Official: ap not injected; cannot enqueue button-click query')
|
|
||||||
return
|
|
||||||
|
|
||||||
bot_uuid = ''
|
|
||||||
pipeline_uuid = form_data.get('pipeline_uuid') or None
|
|
||||||
for bot in self.ap.platform_mgr.bots:
|
|
||||||
if bot.adapter is self:
|
|
||||||
bot_uuid = bot.bot_entity.uuid
|
|
||||||
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.ap.query_pool.add_query(
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
launcher_type=launcher_type,
|
|
||||||
launcher_id=launcher_id,
|
|
||||||
sender_id=actor_id or launcher_id,
|
|
||||||
message_event=synthetic_event,
|
|
||||||
message_chain=message_chain,
|
|
||||||
adapter=self,
|
|
||||||
pipeline_uuid=pipeline_uuid,
|
|
||||||
variables={
|
|
||||||
'_dify_form_action': form_action_data,
|
|
||||||
'_routed_by_rule': True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self.logger.info(
|
|
||||||
f'QQ Official: button-click query enqueued action_id={action_id!r} '
|
|
||||||
f'session={session_key} actor_id={actor_id}'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}')
|
|
||||||
|
|||||||
@@ -31,18 +31,6 @@ spec:
|
|||||||
type: array[string]
|
type: array[string]
|
||||||
required: false
|
required: false
|
||||||
default: []
|
default: []
|
||||||
- name: one-click-bind
|
|
||||||
label:
|
|
||||||
en_US: One-Click QR Binding
|
|
||||||
zh_Hans: 一键扫码绑定
|
|
||||||
zh_Hant: 一鍵掃碼綁定
|
|
||||||
description:
|
|
||||||
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank)
|
|
||||||
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空)
|
|
||||||
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空)
|
|
||||||
type: qr-code-login
|
|
||||||
login_platform: qqofficial
|
|
||||||
required: false
|
|
||||||
- name: appid
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
@@ -64,12 +52,8 @@ spec:
|
|||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
zh_Hant: 令牌
|
zh_Hant: 令牌
|
||||||
description:
|
|
||||||
en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank.
|
|
||||||
zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。
|
|
||||||
zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。
|
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: enable-webhook
|
- name: enable-webhook
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
import telegram
|
import telegram
|
||||||
import telegram.ext
|
import telegram.ext
|
||||||
from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update
|
from telegram import Update
|
||||||
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
|
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
|
||||||
import telegramify_markdown
|
import telegramify_markdown
|
||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
import json
|
|
||||||
import base64
|
import base64
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
from langbot.pkg.utils import httpclient
|
from langbot.pkg.utils import httpclient
|
||||||
@@ -22,61 +20,6 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
|||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
|
||||||
|
|
||||||
def _telegram_select_field_options(form_data: dict) -> tuple[str, list[str]]:
|
|
||||||
"""Return the active select field and its option values."""
|
|
||||||
field_name = str(form_data.get('_current_input_field') or '').strip()
|
|
||||||
if not field_name:
|
|
||||||
return '', []
|
|
||||||
field = next(
|
|
||||||
(
|
|
||||||
item
|
|
||||||
for item in form_data.get('input_defs') or []
|
|
||||||
if str(item.get('output_variable_name') or '').strip() == field_name
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not field or str(field.get('type') or '').strip().lower() != 'select':
|
|
||||||
return '', []
|
|
||||||
|
|
||||||
source = field.get('option_source') or {}
|
|
||||||
source_value = source.get('value') if isinstance(source, dict) else None
|
|
||||||
if isinstance(source_value, list):
|
|
||||||
return field_name, [str(item) for item in source_value]
|
|
||||||
if isinstance(source_value, str):
|
|
||||||
return field_name, [part.strip() for part in source_value.splitlines() if part.strip()]
|
|
||||||
|
|
||||||
options = field.get('options')
|
|
||||||
if not isinstance(options, list):
|
|
||||||
return field_name, []
|
|
||||||
values = []
|
|
||||||
for item in options:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
values.append(str(item.get('label') or item.get('value') or ''))
|
|
||||||
else:
|
|
||||||
values.append(str(item))
|
|
||||||
return field_name, [value for value in values if value]
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_form_action_from_callback(data: dict) -> dict | None:
|
|
||||||
"""Translate compact Telegram callback data into a runner form action."""
|
|
||||||
if 'x' not in data:
|
|
||||||
return {
|
|
||||||
'action_id': str(data.get('action_id') or data.get('a') or ''),
|
|
||||||
'inputs': {},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
option_index = int(data['x'])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if option_index < 0:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
'action_id': '',
|
|
||||||
'inputs': {'select': {'index': option_index}},
|
|
||||||
'_input_progress': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
|
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
|
||||||
@@ -224,7 +167,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
time=event.message.date.timestamp(),
|
time=event.message.date.timestamp(),
|
||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
elif event.effective_chat.type in ('group', 'supergroup'):
|
elif event.effective_chat.type == 'group' or 'supergroup':
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.effective_chat.id,
|
id=event.effective_chat.id,
|
||||||
@@ -246,7 +189,6 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: telegram.Bot = pydantic.Field(exclude=True)
|
bot: telegram.Bot = pydantic.Field(exclude=True)
|
||||||
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
||||||
ap: typing.Any = pydantic.Field(exclude=True, default=None)
|
|
||||||
|
|
||||||
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
||||||
event_converter: TelegramEventConverter = TelegramEventConverter()
|
event_converter: TelegramEventConverter = TelegramEventConverter()
|
||||||
@@ -262,48 +204,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||||
] = {}
|
] = {}
|
||||||
|
|
||||||
_FORM_ACTION_CACHE_TTL = 30 * 60
|
|
||||||
# callback_data -> (display title, pipeline UUID, expiration time, form group id)
|
|
||||||
_form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {}
|
|
||||||
|
|
||||||
def _prune_form_action_titles(self, now: float | None = None) -> None:
|
|
||||||
now = time.monotonic() if now is None else now
|
|
||||||
expired = [key for key, (_, _, expires_at, _) in self._form_action_titles.items() if expires_at <= now]
|
|
||||||
for key in expired:
|
|
||||||
self._form_action_titles.pop(key, None)
|
|
||||||
|
|
||||||
def _cache_form_action_titles(
|
|
||||||
self,
|
|
||||||
mappings: dict[str, str],
|
|
||||||
pipeline_uuid: str = '',
|
|
||||||
now: float | None = None,
|
|
||||||
) -> None:
|
|
||||||
now = time.monotonic() if now is None else now
|
|
||||||
self._prune_form_action_titles(now)
|
|
||||||
group_id = uuid.uuid4().hex
|
|
||||||
expires_at = now + self._FORM_ACTION_CACHE_TTL
|
|
||||||
self._form_action_titles.update(
|
|
||||||
{callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()}
|
|
||||||
)
|
|
||||||
|
|
||||||
def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None:
|
|
||||||
"""Consume a callback and invalidate every button from the same form."""
|
|
||||||
self._prune_form_action_titles(now)
|
|
||||||
entry = self._form_action_titles.get(callback_data)
|
|
||||||
if entry is None:
|
|
||||||
return None
|
|
||||||
title, pipeline_uuid, _, group_id = entry
|
|
||||||
group_keys = [
|
|
||||||
key for key, (_, _, _, cached_group_id) in self._form_action_titles.items() if cached_group_id == group_id
|
|
||||||
]
|
|
||||||
for key in group_keys:
|
|
||||||
self._form_action_titles.pop(key, None)
|
|
||||||
return title, pipeline_uuid
|
|
||||||
|
|
||||||
def _take_form_action_title(self, callback_data: str, now: float | None = None) -> str | None:
|
|
||||||
context = self._take_form_action_context(callback_data, now)
|
|
||||||
return context[0] if context else None
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
if update.message.from_user.is_bot:
|
if update.message.from_user.is_bot:
|
||||||
@@ -324,117 +224,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
telegram_callback,
|
telegram_callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def callback_query_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
||||||
query = update.callback_query
|
|
||||||
await query.answer()
|
|
||||||
try:
|
|
||||||
data = json.loads(query.data)
|
|
||||||
if data.get('form_action') or data.get('f'):
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
|
||||||
|
|
||||||
# workflow_run_id is not in the callback payload (too large
|
|
||||||
# for Telegram's 64-byte limit). Only w_suffix is sent;
|
|
||||||
# the runner resolves the full run id from _PENDING_FORMS.
|
|
||||||
w_suffix = data.get('w', '')
|
|
||||||
session_key = data.get('session_key') or data.get('s', '')
|
|
||||||
callback_action = _telegram_form_action_from_callback(data)
|
|
||||||
action_context = self._take_form_action_context(query.data) if callback_action is not None else None
|
|
||||||
if callback_action is None or action_context is None:
|
|
||||||
await self.logger.warning(f'Invalid or stale Telegram form callback: {query.data!r}')
|
|
||||||
return
|
|
||||||
action_title, pipeline_uuid = action_context
|
|
||||||
# Show selected action feedback by editing the original message
|
|
||||||
try:
|
|
||||||
original_text = query.message.text or ''
|
|
||||||
selected_text = f'{original_text}\n\n✅ {action_title}'
|
|
||||||
await query.edit_message_text(text=selected_text, reply_markup=None)
|
|
||||||
except Exception:
|
|
||||||
# If edit fails (e.g. message too long), just pass
|
|
||||||
pass
|
|
||||||
|
|
||||||
if session_key.startswith('group_') or session_key.startswith('g:'):
|
|
||||||
launcher_type = provider_session.LauncherTypes.GROUP
|
|
||||||
launcher_id = (
|
|
||||||
session_key.split(':', 1)[1]
|
|
||||||
if session_key.startswith('g:')
|
|
||||||
else session_key[len('group_') :]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
launcher_type = provider_session.LauncherTypes.PERSON
|
|
||||||
launcher_id = (
|
|
||||||
session_key.split(':', 1)[1]
|
|
||||||
if session_key.startswith('p:')
|
|
||||||
else session_key[len('person_') :]
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id = str(query.from_user.id)
|
|
||||||
|
|
||||||
# Find bot_uuid and pipeline_uuid
|
|
||||||
bot_uuid = ''
|
|
||||||
for b in self.ap.platform_mgr.bots:
|
|
||||||
if b.adapter is self:
|
|
||||||
bot_uuid = b.bot_entity.uuid
|
|
||||||
pipeline_uuid = pipeline_uuid or b.bot_entity.use_pipeline_uuid
|
|
||||||
break
|
|
||||||
|
|
||||||
form_action_data = {
|
|
||||||
# workflow_run_id is intentionally omitted; the runner
|
|
||||||
# resolves it from w_suffix via _PENDING_FORMS.
|
|
||||||
'w_suffix': w_suffix,
|
|
||||||
'user': f'{launcher_type.value}_{launcher_id}',
|
|
||||||
**callback_action,
|
|
||||||
}
|
|
||||||
|
|
||||||
event_label = 'Form Select' if callback_action.get('_input_progress') else 'Form Action'
|
|
||||||
message_chain = platform_message.MessageChain(
|
|
||||||
[platform_message.Plain(text=f'[{event_label}: {action_title}]')]
|
|
||||||
)
|
|
||||||
|
|
||||||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
|
||||||
synthetic_event = platform_events.GroupMessage(
|
|
||||||
sender=platform_entities.GroupMember(
|
|
||||||
id=user_id,
|
|
||||||
member_name='',
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
group=platform_entities.Group(
|
|
||||||
id=launcher_id,
|
|
||||||
name='',
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
source_platform_object=update,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
synthetic_event = platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(
|
|
||||||
id=user_id,
|
|
||||||
nickname='',
|
|
||||||
remark='',
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
source_platform_object=update,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.ap.query_pool.add_query(
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
launcher_type=launcher_type,
|
|
||||||
launcher_id=launcher_id,
|
|
||||||
sender_id=user_id,
|
|
||||||
message_event=synthetic_event,
|
|
||||||
message_chain=message_chain,
|
|
||||||
adapter=self,
|
|
||||||
pipeline_uuid=pipeline_uuid,
|
|
||||||
variables={
|
|
||||||
'_dify_form_action': form_action_data,
|
|
||||||
'_routed_by_rule': True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error in telegram callback query: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
application.add_handler(CallbackQueryHandler(callback_query_handler))
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
@@ -525,34 +314,23 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
args['parse_mode'] = 'MarkdownV2'
|
args['parse_mode'] = 'MarkdownV2'
|
||||||
return args
|
return args
|
||||||
|
|
||||||
async def _delete_group_stream_message(self, chat_mode: str, chat_id: int, stream_id: int | None):
|
|
||||||
if chat_mode != 'group' or stream_id is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await self.bot.delete_message(chat_id=chat_id, message_id=stream_id)
|
|
||||||
except telegram.error.TelegramError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_form_placeholder_chunk(text: str) -> bool:
|
|
||||||
"""Return True for invisible placeholder chunks used to carry forms."""
|
|
||||||
|
|
||||||
if not text:
|
|
||||||
return True
|
|
||||||
|
|
||||||
cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip()
|
|
||||||
return cleaned == ''
|
|
||||||
|
|
||||||
async def create_message_card(self, message_id, event):
|
async def create_message_card(self, message_id, event):
|
||||||
assert isinstance(event.source_platform_object, Update)
|
assert isinstance(event.source_platform_object, Update)
|
||||||
update = event.source_platform_object
|
update = event.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
effective_message = update.effective_message
|
chat_type = update.effective_chat.type
|
||||||
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
message_thread_id = update.message.message_thread_id
|
||||||
|
|
||||||
|
if chat_type == 'private':
|
||||||
|
draft_id = int(time.time() * 1000)
|
||||||
|
self.msg_stream_id[message_id] = ('private', draft_id)
|
||||||
|
|
||||||
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id)
|
||||||
|
await self.bot.send_message_draft(**args)
|
||||||
|
else:
|
||||||
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
|
||||||
send_msg = await self.bot.send_message(**args)
|
send_msg = await self.bot.send_message(**args)
|
||||||
self.msg_stream_id[message_id] = ('message', send_msg.message_id, False)
|
self.msg_stream_id[message_id] = ('group', send_msg.message_id)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -569,15 +347,12 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
assert isinstance(message_source.source_platform_object, Update)
|
assert isinstance(message_source.source_platform_object, Update)
|
||||||
update = message_source.source_platform_object
|
update = message_source.source_platform_object
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
effective_message = update.effective_message
|
message_thread_id = update.message.message_thread_id
|
||||||
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
|
||||||
|
|
||||||
if message_id not in self.msg_stream_id:
|
if message_id not in self.msg_stream_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
stream_state = self.msg_stream_id[message_id]
|
chat_mode, draft_id = self.msg_stream_id[message_id]
|
||||||
chat_mode, stream_id = stream_state[:2]
|
|
||||||
has_visible_content = len(stream_state) > 2 and stream_state[2]
|
|
||||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||||
|
|
||||||
if not components or components[0]['type'] != 'text':
|
if not components or components[0]['type'] != 'text':
|
||||||
@@ -586,68 +361,17 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
content = components[0]['text']
|
content = components[0]['text']
|
||||||
form_data = getattr(bot_message, '_form_data', None)
|
|
||||||
|
|
||||||
if form_data and is_final:
|
|
||||||
if not has_visible_content:
|
|
||||||
await self._send_form_action_buttons(message_source, form_data, edit_message_id=stream_id)
|
|
||||||
else:
|
|
||||||
await self._send_form_action_buttons(message_source, form_data)
|
|
||||||
self.msg_stream_id.pop(message_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._is_form_placeholder_chunk(content):
|
|
||||||
if is_final and bot_message.tool_calls is None and not has_visible_content:
|
|
||||||
await self._delete_group_stream_message(chat_mode, chat_id, stream_id)
|
|
||||||
self.msg_stream_id.pop(message_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
if chat_mode == 'private':
|
if chat_mode == 'private':
|
||||||
# Streaming via draft (ephemeral preview in the chat input area)
|
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id)
|
||||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
|
||||||
args = self._build_message_args(chat_id, content, message_thread_id, draft_id=stream_id)
|
|
||||||
try:
|
|
||||||
await self.bot.send_message_draft(**args)
|
await self.bot.send_message_draft(**args)
|
||||||
except telegram.error.BadRequest as exc:
|
|
||||||
if 'Message_too_long' in str(exc):
|
|
||||||
args['text'] = content[:4000] + '\n\n… (truncated)'
|
|
||||||
try:
|
|
||||||
await self.bot.send_message_draft(**args)
|
|
||||||
except telegram.error.RetryAfter:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass # Ignore other draft errors (cosmetic)
|
|
||||||
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
|
|
||||||
if is_final and bot_message.tool_calls is None:
|
if is_final and bot_message.tool_calls is None:
|
||||||
# Finalise: send the real message, discard the draft
|
del args['draft_id']
|
||||||
args = self._build_message_args(chat_id, content, message_thread_id)
|
|
||||||
try:
|
|
||||||
await self.bot.send_message(**args)
|
await self.bot.send_message(**args)
|
||||||
except telegram.error.BadRequest as exc:
|
|
||||||
if 'Message_too_long' in str(exc):
|
|
||||||
args['text'] = content[:4000] + '\n\n… (truncated)'
|
|
||||||
await self.bot.send_message(**args)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
self.msg_stream_id.pop(message_id)
|
self.msg_stream_id.pop(message_id)
|
||||||
else:
|
else:
|
||||||
# Streaming via edit_message_text (persistent message)
|
stream_id = draft_id
|
||||||
if stream_id is None:
|
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||||
args = self._build_message_args(chat_id, content, message_thread_id)
|
|
||||||
try:
|
|
||||||
send_msg = await self.bot.send_message(**args)
|
|
||||||
except telegram.error.BadRequest as exc:
|
|
||||||
if 'Message_too_long' in str(exc):
|
|
||||||
args['text'] = self._process_markdown(content[:4000] + '\n\n鈥?(truncated)')
|
|
||||||
send_msg = await self.bot.send_message(**args)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
self.msg_stream_id[message_id] = (chat_mode, send_msg.message_id, True)
|
|
||||||
if is_final and bot_message.tool_calls is None:
|
|
||||||
self.msg_stream_id.pop(message_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not has_visible_content or (msg_seq - 1) % 8 == 0 or is_final:
|
|
||||||
args = {
|
args = {
|
||||||
'message_id': stream_id,
|
'message_id': stream_id,
|
||||||
'chat_id': chat_id,
|
'chat_id': chat_id,
|
||||||
@@ -655,137 +379,11 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
}
|
}
|
||||||
if self.config.get('markdown_card', False):
|
if self.config.get('markdown_card', False):
|
||||||
args['parse_mode'] = 'MarkdownV2'
|
args['parse_mode'] = 'MarkdownV2'
|
||||||
try:
|
|
||||||
await self.bot.edit_message_text(**args)
|
await self.bot.edit_message_text(**args)
|
||||||
except telegram.error.BadRequest as exc:
|
|
||||||
if 'Message_too_long' in str(exc):
|
|
||||||
args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)')
|
|
||||||
await self.bot.edit_message_text(**args)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
self.msg_stream_id[message_id] = (chat_mode, stream_id, True)
|
|
||||||
|
|
||||||
if is_final and bot_message.tool_calls is None:
|
if is_final and bot_message.tool_calls is None:
|
||||||
self.msg_stream_id.pop(message_id)
|
self.msg_stream_id.pop(message_id)
|
||||||
|
|
||||||
async def _send_form_action_buttons(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
form_data: dict,
|
|
||||||
edit_message_id: int | None = None,
|
|
||||||
):
|
|
||||||
"""Send inline keyboard buttons for Dify form fields or actions."""
|
|
||||||
actions = form_data.get('actions', [])
|
|
||||||
node_title = form_data.get('node_title', '')
|
|
||||||
form_content = form_data.get('form_content', '')
|
|
||||||
workflow_run_id = form_data.get('workflow_run_id', '')
|
|
||||||
# Telegram callback_data is capped at 64 bytes, so we identify the
|
|
||||||
# paused workflow by the last 8 chars of workflow_run_id (unique
|
|
||||||
# within a session with overwhelming probability).
|
|
||||||
w_suffix = workflow_run_id[-8:] if workflow_run_id else ''
|
|
||||||
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
session_key = f'g:{message_source.group.id}'
|
|
||||||
else:
|
|
||||||
session_key = f'p:{message_source.sender.id}'
|
|
||||||
|
|
||||||
current_field = str(form_data.get('_current_input_field') or '').strip()
|
|
||||||
is_field_step = bool(current_field) and not form_data.get('_action_select_only')
|
|
||||||
select_field, select_options = _telegram_select_field_options(form_data)
|
|
||||||
is_select_field = bool(select_field and select_options)
|
|
||||||
if is_select_field:
|
|
||||||
choices = [(option, {'x': idx}) for idx, option in enumerate(select_options)]
|
|
||||||
elif is_field_step:
|
|
||||||
choices = []
|
|
||||||
else:
|
|
||||||
choices = [(action.get('title', action.get('id', '')), {'a': action.get('id', '')}) for action in actions]
|
|
||||||
|
|
||||||
keyboard = []
|
|
||||||
pending_title_mappings: dict[str, str] = {}
|
|
||||||
oversized = False
|
|
||||||
buttons_per_row = 2 if is_select_field else 1
|
|
||||||
current_row = []
|
|
||||||
for title, choice_data in choices:
|
|
||||||
callback_payload = {'f': 1, **choice_data, 's': session_key}
|
|
||||||
if w_suffix:
|
|
||||||
callback_payload['w'] = w_suffix
|
|
||||||
callback_data = json.dumps(callback_payload, separators=(',', ':'))
|
|
||||||
if len(callback_data.encode('utf-8')) > 64:
|
|
||||||
oversized = True
|
|
||||||
break
|
|
||||||
pending_title_mappings[callback_data] = str(title)
|
|
||||||
current_row.append(InlineKeyboardButton(str(title), callback_data=callback_data))
|
|
||||||
if len(current_row) == buttons_per_row:
|
|
||||||
keyboard.append(current_row)
|
|
||||||
current_row = []
|
|
||||||
if current_row and not oversized:
|
|
||||||
keyboard.append(current_row)
|
|
||||||
|
|
||||||
update = message_source.source_platform_object
|
|
||||||
chat_id = update.effective_chat.id
|
|
||||||
effective_message = update.effective_message
|
|
||||||
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
|
|
||||||
|
|
||||||
heading = f'[{node_title}]'
|
|
||||||
text_lines = [heading]
|
|
||||||
if form_content:
|
|
||||||
text_lines.append(form_content)
|
|
||||||
|
|
||||||
if oversized:
|
|
||||||
# callback_data exceeds Telegram's 64-byte limit — fall back to
|
|
||||||
# a plain-text numbered list so the user can reply by number.
|
|
||||||
for idx, (title, _) in enumerate(choices, start=1):
|
|
||||||
text_lines.append(f' {idx}. {title}')
|
|
||||||
args = {
|
|
||||||
'chat_id': chat_id,
|
|
||||||
'text': '\n\n'.join(text_lines),
|
|
||||||
}
|
|
||||||
elif keyboard:
|
|
||||||
self._cache_form_action_titles(
|
|
||||||
pending_title_mappings,
|
|
||||||
str(form_data.get('pipeline_uuid') or ''),
|
|
||||||
)
|
|
||||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
|
||||||
args = {
|
|
||||||
'chat_id': chat_id,
|
|
||||||
'text': '\n\n'.join(text_lines),
|
|
||||||
'reply_markup': reply_markup,
|
|
||||||
}
|
|
||||||
elif is_field_step:
|
|
||||||
args = {
|
|
||||||
'chat_id': chat_id,
|
|
||||||
'text': '\n\n'.join(text_lines),
|
|
||||||
# Telegram privacy-mode bots receive replies to ForceReply
|
|
||||||
# prompts even when they cannot read ordinary group messages.
|
|
||||||
'reply_markup': ForceReply(
|
|
||||||
selective=False,
|
|
||||||
input_field_placeholder=current_field,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
args = {
|
|
||||||
'chat_id': chat_id,
|
|
||||||
'text': '\n\n'.join(text_lines),
|
|
||||||
}
|
|
||||||
|
|
||||||
if message_thread_id:
|
|
||||||
args['message_thread_id'] = message_thread_id
|
|
||||||
|
|
||||||
if edit_message_id is not None:
|
|
||||||
edit_args = {
|
|
||||||
'chat_id': chat_id,
|
|
||||||
'message_id': edit_message_id,
|
|
||||||
'text': args['text'],
|
|
||||||
}
|
|
||||||
edit_args['reply_markup'] = args.get('reply_markup')
|
|
||||||
try:
|
|
||||||
await self.bot.edit_message_text(**edit_args)
|
|
||||||
return
|
|
||||||
except telegram.error.TelegramError:
|
|
||||||
await self._delete_group_stream_message('group', chat_id, edit_message_id)
|
|
||||||
|
|
||||||
await self.bot.send_message(**args)
|
|
||||||
|
|
||||||
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||||
if not isinstance(event.source_platform_object, Update):
|
if not isinstance(event.source_platform_object, Update):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
from ...core import app
|
from ...core import app
|
||||||
from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
|
from .websocket_manager import ws_connection_manager, WebSocketConnection
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -91,59 +91,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
self.outbound_message_queue = asyncio.Queue()
|
self.outbound_message_queue = asyncio.Queue()
|
||||||
self.stream_enabled = True
|
self.stream_enabled = True
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _conversation_key(pipeline_uuid: str, session_id: str | None = None) -> str:
|
|
||||||
"""Return the history key for a pipeline/client conversation."""
|
|
||||||
return f'{pipeline_uuid}:{session_id}' if session_id else pipeline_uuid
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_embed_target(target_id: str) -> tuple[str, str] | None:
|
|
||||||
"""Extract pipeline and session identifiers from a stable embed launcher."""
|
|
||||||
target_value = str(target_id)
|
|
||||||
for prefix in ('websocket_', 'websocketgroup_'):
|
|
||||||
if target_value.startswith(prefix):
|
|
||||||
target = target_value[len(prefix) :]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if ':' not in target:
|
|
||||||
return None
|
|
||||||
pipeline_uuid, session_id = target.rsplit(':', 1)
|
|
||||||
if not pipeline_uuid or not is_valid_session_id(session_id):
|
|
||||||
return None
|
|
||||||
return pipeline_uuid, session_id
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _get_connection_from_target(cls, target_id: str):
|
|
||||||
"""Resolve a person or group WebSocket launcher to its connection."""
|
|
||||||
target_value = str(target_id)
|
|
||||||
for prefix in ('websocket_', 'websocketgroup_'):
|
|
||||||
if target_value.startswith(prefix):
|
|
||||||
target = target_value[len(prefix) :]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
connection = await ws_connection_manager.get_connection(target)
|
|
||||||
if connection is not None:
|
|
||||||
return connection
|
|
||||||
embed_target = cls._parse_embed_target(target_id)
|
|
||||||
if embed_target is not None:
|
|
||||||
pipeline_uuid, session_id = embed_target
|
|
||||||
return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
|
|
||||||
return await ws_connection_manager.get_connection_by_session_id(target)
|
|
||||||
|
|
||||||
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
|
|
||||||
"""Resolve the originating pipeline and browser session for a reply."""
|
|
||||||
sender = getattr(message_source, 'sender', None)
|
|
||||||
sender_id = getattr(sender, 'id', '')
|
|
||||||
connection = await self._get_connection_from_target(sender_id)
|
|
||||||
if connection is not None:
|
|
||||||
return connection.pipeline_uuid, connection.session_id
|
|
||||||
embed_target = self._parse_embed_target(sender_id)
|
|
||||||
if embed_target is not None:
|
|
||||||
return embed_target
|
|
||||||
return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
|
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
@@ -156,26 +103,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
target_id 可能是 launcher_id(如 websocket_xxx)或 pipeline_uuid。
|
target_id 可能是 launcher_id(如 websocket_xxx)或 pipeline_uuid。
|
||||||
我们需要尝试两种方式来确保消息能够送达。
|
我们需要尝试两种方式来确保消息能够送达。
|
||||||
"""
|
"""
|
||||||
connection = await self._get_connection_from_target(target_id)
|
# 获取当前的 pipeline_uuid
|
||||||
if connection is not None:
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
pipeline_uuid = connection.pipeline_uuid
|
|
||||||
session_id = connection.session_id
|
|
||||||
else:
|
|
||||||
embed_target = self._parse_embed_target(target_id)
|
|
||||||
if embed_target is not None:
|
|
||||||
pipeline_uuid, session_id = embed_target
|
|
||||||
else:
|
|
||||||
pipeline_uuid = typing.cast(
|
|
||||||
str,
|
|
||||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
|
|
||||||
)
|
|
||||||
session_id = None
|
|
||||||
session_type = 'group' if target_type == 'group' else 'person'
|
session_type = 'group' if target_type == 'group' else 'person'
|
||||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
|
||||||
|
|
||||||
|
# 选择会话
|
||||||
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||||
|
|
||||||
msg_id = len(session.get_message_list(conversation_key)) + 1
|
# 生成唯一消息ID
|
||||||
|
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||||
|
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
@@ -186,8 +122,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
is_final=True,
|
is_final=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.get_message_list(conversation_key).append(message_data)
|
# 保存到历史记录
|
||||||
|
session.get_message_list(pipeline_uuid).append(message_data)
|
||||||
|
|
||||||
|
# 直接广播到当前pipeline的连接
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
pipeline_uuid,
|
pipeline_uuid,
|
||||||
{
|
{
|
||||||
@@ -196,7 +134,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
'data': message_data.model_dump(),
|
'data': message_data.model_dump(),
|
||||||
},
|
},
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
session_id=session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_data.model_dump()
|
return message_data.model_dump()
|
||||||
@@ -215,11 +152,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
else self.websocket_person_session
|
else self.websocket_person_session
|
||||||
)
|
)
|
||||||
|
|
||||||
pipeline_uuid, session_id = await self._get_message_context(message_source)
|
# 从message_source获取pipeline_uuid和connection_id
|
||||||
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
|
||||||
|
|
||||||
msg_id = len(session.get_message_list(conversation_key)) + 1
|
# 生成新的消息ID
|
||||||
|
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||||
|
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
@@ -230,8 +168,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
is_final=True,
|
is_final=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
session.get_message_list(conversation_key).append(message_data)
|
# 保存到历史记录
|
||||||
|
session.get_message_list(pipeline_uuid).append(message_data)
|
||||||
|
|
||||||
|
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
pipeline_uuid,
|
pipeline_uuid,
|
||||||
{
|
{
|
||||||
@@ -240,7 +180,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
'data': message_data.model_dump(),
|
'data': message_data.model_dump(),
|
||||||
},
|
},
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
session_id=session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_data.model_dump()
|
return message_data.model_dump()
|
||||||
@@ -261,11 +200,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
else self.websocket_person_session
|
else self.websocket_person_session
|
||||||
)
|
)
|
||||||
|
|
||||||
pipeline_uuid, session_id = await self._get_message_context(message_source)
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
message_list = session.get_message_list(pipeline_uuid)
|
||||||
message_list = session.get_message_list(conversation_key)
|
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
|
||||||
stream_message_indexes = session.get_stream_message_indexes(conversation_key)
|
|
||||||
|
|
||||||
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
|
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
|
||||||
# Use it as the primary key to avoid overwriting an old card from a previous reply.
|
# Use it as the primary key to avoid overwriting an old card from a previous reply.
|
||||||
@@ -309,6 +247,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if message_is_final and resp_message_id:
|
if message_is_final and resp_message_id:
|
||||||
stream_message_indexes.pop(resp_message_id, None)
|
stream_message_indexes.pop(resp_message_id, None)
|
||||||
|
|
||||||
|
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
pipeline_uuid,
|
pipeline_uuid,
|
||||||
{
|
{
|
||||||
@@ -317,7 +256,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
'data': message_data.model_dump(),
|
'data': message_data.model_dump(),
|
||||||
},
|
},
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
session_id=session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_data.model_dump()
|
return message_data.model_dump()
|
||||||
@@ -443,19 +381,23 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
"""
|
"""
|
||||||
pipeline_uuid = connection.pipeline_uuid
|
pipeline_uuid = connection.pipeline_uuid
|
||||||
session_type = connection.session_type
|
session_type = connection.session_type
|
||||||
conversation_key = self._conversation_key(pipeline_uuid, connection.session_id)
|
|
||||||
|
|
||||||
|
# 获取stream参数,默认为True
|
||||||
self.stream_enabled = message_data.get('stream', True)
|
self.stream_enabled = message_data.get('stream', True)
|
||||||
|
|
||||||
|
# 选择会话
|
||||||
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||||
|
|
||||||
|
# 解析消息链
|
||||||
message_chain_obj = message_data.get('message', [])
|
message_chain_obj = message_data.get('message', [])
|
||||||
|
|
||||||
|
# 处理图片组件:将path转换为base64
|
||||||
await self._process_image_components(message_chain_obj)
|
await self._process_image_components(message_chain_obj)
|
||||||
|
|
||||||
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
|
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
|
||||||
|
|
||||||
message_id = len(use_session.get_message_list(conversation_key)) + 1
|
# 生成消息ID
|
||||||
|
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||||
|
|
||||||
# 保存用户消息
|
# 保存用户消息
|
||||||
user_message = WebSocketMessage(
|
user_message = WebSocketMessage(
|
||||||
@@ -467,8 +409,9 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
connection_id=connection.connection_id,
|
connection_id=connection.connection_id,
|
||||||
is_final=True, # 用户消息始终是完整的,非流式
|
is_final=True, # 用户消息始终是完整的,非流式
|
||||||
)
|
)
|
||||||
use_session.get_message_list(conversation_key).append(user_message)
|
use_session.get_message_list(pipeline_uuid).append(user_message)
|
||||||
|
|
||||||
|
# 广播用户消息到所有连接(包括发送者),包含session_type信息
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
pipeline_uuid,
|
pipeline_uuid,
|
||||||
{
|
{
|
||||||
@@ -477,27 +420,25 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
'data': user_message.model_dump(),
|
'data': user_message.model_dump(),
|
||||||
},
|
},
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
session_id=connection.session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加消息源
|
# 添加消息源
|
||||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||||
|
|
||||||
# 创建事件
|
# 创建事件
|
||||||
launcher_id = f'{pipeline_uuid}:{connection.session_id}' if connection.session_id else connection.connection_id
|
|
||||||
if session_type == 'person':
|
if session_type == 'person':
|
||||||
sender = platform_entities.Friend(id=f'websocket_{launcher_id}', nickname='User', remark='User')
|
sender = platform_entities.Friend(
|
||||||
|
id=f'websocket_{connection.connection_id}', nickname='User', remark='User'
|
||||||
|
)
|
||||||
event = platform_events.FriendMessage(
|
event = platform_events.FriendMessage(
|
||||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
group = platform_entities.Group(
|
group = platform_entities.Group(
|
||||||
id=f'websocketgroup_{launcher_id}' if connection.session_id else 'websocketgroup',
|
id='websocketgroup', name='Group', permission=platform_entities.Permission.Member
|
||||||
name='Group',
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
)
|
)
|
||||||
sender = platform_entities.GroupMember(
|
sender = platform_entities.GroupMember(
|
||||||
id=f'websocket_{launcher_id}',
|
id=f'websocket_{connection.connection_id}',
|
||||||
member_name='User',
|
member_name='User',
|
||||||
group=group,
|
group=group,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
@@ -527,47 +468,22 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if event.__class__ in listeners:
|
if event.__class__ in listeners:
|
||||||
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||||
|
|
||||||
def get_websocket_messages(
|
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||||
self,
|
"""获取消息历史"""
|
||||||
pipeline_uuid: str,
|
if session_type == 'person':
|
||||||
session_type: str,
|
return [message.model_dump() for message in self.websocket_person_session.get_message_list(pipeline_uuid)]
|
||||||
session_id: str | None = None,
|
else:
|
||||||
) -> list[dict]:
|
return [message.model_dump() for message in self.websocket_group_session.get_message_list(pipeline_uuid)]
|
||||||
"""Return history for one pipeline/client conversation."""
|
|
||||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
|
||||||
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
|
|
||||||
return [message.model_dump() for message in session.message_lists.get(conversation_key, [])]
|
|
||||||
|
|
||||||
def reset_session(
|
def reset_session(self, pipeline_uuid: str, session_type: str):
|
||||||
self,
|
"""重置会话"""
|
||||||
pipeline_uuid: str,
|
if session_type == 'person':
|
||||||
session_type: str,
|
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||||
session_id: str | None = None,
|
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
||||||
):
|
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
|
||||||
"""Reset one pipeline/client conversation."""
|
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
|
||||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
else:
|
||||||
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
|
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||||
if conversation_key in session.message_lists:
|
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
||||||
session.message_lists[conversation_key] = []
|
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
|
||||||
if conversation_key in session.stream_message_indexes:
|
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}
|
||||||
session.stream_message_indexes[conversation_key] = {}
|
|
||||||
|
|
||||||
if session_id:
|
|
||||||
launcher_id = (
|
|
||||||
f'websocketgroup_{pipeline_uuid}:{session_id}'
|
|
||||||
if session_type == 'group'
|
|
||||||
else f'websocket_{pipeline_uuid}:{session_id}'
|
|
||||||
)
|
|
||||||
self.ap.sess_mgr.session_list = [
|
|
||||||
candidate_session
|
|
||||||
for candidate_session in self.ap.sess_mgr.session_list
|
|
||||||
if not (
|
|
||||||
str(
|
|
||||||
candidate_session.launcher_type.value
|
|
||||||
if hasattr(candidate_session.launcher_type, 'value')
|
|
||||||
else candidate_session.launcher_type
|
|
||||||
)
|
|
||||||
== session_type
|
|
||||||
and str(candidate_session.launcher_id) == launcher_id
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -9,16 +9,6 @@ from datetime import datetime
|
|||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_SESSION_FILTER_UNSET = object()
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_session_id(value: str) -> bool:
|
|
||||||
"""Accept only canonical random UUIDs for client conversation identifiers."""
|
|
||||||
try:
|
|
||||||
parsed = uuid.UUID(value)
|
|
||||||
except (ValueError, TypeError, AttributeError):
|
|
||||||
return False
|
|
||||||
return parsed.version == 4 and str(parsed) == value
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketConnection(pydantic.BaseModel):
|
class WebSocketConnection(pydantic.BaseModel):
|
||||||
@@ -35,9 +25,6 @@ class WebSocketConnection(pydantic.BaseModel):
|
|||||||
session_type: str # 'person' or 'group'
|
session_type: str # 'person' or 'group'
|
||||||
"""会话类型"""
|
"""会话类型"""
|
||||||
|
|
||||||
session_id: str | None = None
|
|
||||||
"""Optional client conversation identifier used by embed widgets."""
|
|
||||||
|
|
||||||
websocket: typing.Any = pydantic.Field(exclude=True)
|
websocket: typing.Any = pydantic.Field(exclude=True)
|
||||||
"""WebSocket连接对象 (quart.websocket)"""
|
"""WebSocket连接对象 (quart.websocket)"""
|
||||||
|
|
||||||
@@ -78,15 +65,13 @@ class WebSocketConnectionManager:
|
|||||||
websocket: typing.Any,
|
websocket: typing.Any,
|
||||||
pipeline_uuid: str,
|
pipeline_uuid: str,
|
||||||
session_type: str,
|
session_type: str,
|
||||||
metadata: dict | None = None,
|
metadata: dict = None,
|
||||||
session_id: str | None = None,
|
|
||||||
) -> WebSocketConnection:
|
) -> WebSocketConnection:
|
||||||
"""Register a WebSocket connection and its optional embed session."""
|
"""添加新的WebSocket连接"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
connection = WebSocketConnection(
|
connection = WebSocketConnection(
|
||||||
pipeline_uuid=pipeline_uuid,
|
pipeline_uuid=pipeline_uuid,
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
session_id=session_id,
|
|
||||||
websocket=websocket,
|
websocket=websocket,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
@@ -135,25 +120,10 @@ class WebSocketConnectionManager:
|
|||||||
|
|
||||||
logger.debug(f'WebSocket connection disconnected: {connection_id}')
|
logger.debug(f'WebSocket connection disconnected: {connection_id}')
|
||||||
|
|
||||||
async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
|
async def get_connection(self, connection_id: str) -> typing.Optional[WebSocketConnection]:
|
||||||
"""Get a connection by its transport identifier."""
|
"""获取指定连接"""
|
||||||
return self.connections.get(connection_id)
|
return self.connections.get(connection_id)
|
||||||
|
|
||||||
async def get_connection_by_session_id(
|
|
||||||
self,
|
|
||||||
session_id: str,
|
|
||||||
pipeline_uuid: str | None = None,
|
|
||||||
) -> WebSocketConnection | None:
|
|
||||||
"""Get an active embed connection by its stable browser session identifier."""
|
|
||||||
for connection in self.connections.values():
|
|
||||||
if (
|
|
||||||
connection.session_id == session_id
|
|
||||||
and connection.is_active
|
|
||||||
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
|
|
||||||
):
|
|
||||||
return connection
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
|
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
|
||||||
"""获取指定流水线的所有连接"""
|
"""获取指定流水线的所有连接"""
|
||||||
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
|
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
|
||||||
@@ -164,30 +134,20 @@ class WebSocketConnectionManager:
|
|||||||
connection_ids = self.session_connections.get(session_type, set())
|
connection_ids = self.session_connections.get(session_type, set())
|
||||||
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||||
|
|
||||||
async def broadcast_to_pipeline(
|
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None):
|
||||||
self,
|
"""向指定流水线的所有连接广播消息
|
||||||
pipeline_uuid: str,
|
|
||||||
message: dict,
|
|
||||||
session_type: str | None = None,
|
|
||||||
session_id: typing.Any = _SESSION_FILTER_UNSET,
|
|
||||||
):
|
|
||||||
"""Broadcast a message to matching connections for one pipeline.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pipeline_uuid: Pipeline identifier.
|
pipeline_uuid: 流水线UUID
|
||||||
message: Serialized message to enqueue.
|
message: 要广播的消息
|
||||||
session_type: Optional session-type filter.
|
session_type: 可选的会话类型过滤器,如果提供则只向匹配的session_type连接广播
|
||||||
session_id: Embed conversation filter. Omit it to broadcast across
|
|
||||||
conversations; pass ``None`` to target non-embed connections.
|
|
||||||
"""
|
"""
|
||||||
connections = await self.get_connections_by_pipeline(pipeline_uuid)
|
connections = await self.get_connections_by_pipeline(pipeline_uuid)
|
||||||
|
|
||||||
|
# 如果指定了session_type,只向匹配的连接广播
|
||||||
if session_type is not None:
|
if session_type is not None:
|
||||||
connections = [conn for conn in connections if conn.session_type == session_type]
|
connections = [conn for conn in connections if conn.session_type == session_type]
|
||||||
|
|
||||||
if session_id is not _SESSION_FILTER_UNSET:
|
|
||||||
connections = [conn for conn in connections if conn.session_id == session_id]
|
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
tasks.append(self.send_to_connection(conn.connection_id, message))
|
tasks.append(self.send_to_connection(conn.connection_id, message))
|
||||||
|
|||||||
@@ -11,13 +11,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import (
|
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
||||||
WecomBotClient,
|
|
||||||
extract_template_card_action,
|
|
||||||
extract_template_card_event_payload,
|
|
||||||
extract_template_card_selections,
|
|
||||||
parse_select_button_action,
|
|
||||||
)
|
|
||||||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
||||||
|
|
||||||
|
|
||||||
@@ -302,7 +296,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
listeners: dict = {}
|
listeners: dict = {}
|
||||||
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
|
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
|
||||||
_STREAM_MAPPING_TTL = 600 # 10 minutes
|
_STREAM_MAPPING_TTL = 600 # 10 minutes
|
||||||
ap: typing.Any = None
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
enable_webhook = config.get('enable-webhook', False)
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
@@ -343,25 +336,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
_stream_to_monitoring_msg={},
|
_stream_to_monitoring_msg={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Both WecomBotClient (webhook) and WecomBotWsClient (ws long-conn)
|
|
||||||
# expose ``set_card_action_callback``. Wire the click handler so
|
|
||||||
# Dify human-input button taps resume the workflow on either mode.
|
|
||||||
if hasattr(self.bot, 'set_card_action_callback'):
|
|
||||||
self.bot.set_card_action_callback(self._on_card_action)
|
|
||||||
|
|
||||||
# Hand the client a `source` block so every interactive
|
|
||||||
# template_card it emits carries the LangBot logo + name at the
|
|
||||||
# top — the WeCom analogue of DingTalk's Avatar header.
|
|
||||||
# Always on; icon_url accepts plain HTTPS URLs (no upload needed).
|
|
||||||
if hasattr(self.bot, 'set_card_source'):
|
|
||||||
self.bot.set_card_source(
|
|
||||||
{
|
|
||||||
'icon_url': 'https://raw.githubusercontent.com/RockChinQ/LangBot/master/res/logo-blue.png',
|
|
||||||
'desc': 'LangBot',
|
|
||||||
'desc_color': 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -371,37 +345,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
|
if _ws_mode:
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
# Synthetic events (button-click resume queries) have no inbound
|
req_id = event.get('req_id', '')
|
||||||
# platform object. Fall back to a proactive send so error
|
|
||||||
# messages and one-shot replies still reach the user.
|
|
||||||
if event is None:
|
|
||||||
if _ws_mode:
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
chat_id = str(message_source.group.id)
|
|
||||||
else:
|
|
||||||
chat_id = str(message_source.sender.id)
|
|
||||||
try:
|
|
||||||
await self.bot.send_message(chat_id, content)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(
|
|
||||||
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.logger.warning(
|
|
||||||
'WeComBot webhook mode cannot reply to a synthetic event '
|
|
||||||
'(no req_id and no proactive-send credentials); dropping.'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if _ws_mode:
|
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
|
||||||
if req_id:
|
if req_id:
|
||||||
await self.bot.reply_text(req_id, content)
|
await self.bot.reply_text(req_id, content)
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(event.message_id, content)
|
await self.bot.set_message(event.message_id, content)
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(event.message_id, content)
|
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -412,56 +364,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
|
msg_id = message_source.source_platform_object.message_id
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
# Synthetic events (e.g. button-click triggered form resume) have
|
|
||||||
# no inbound platform message — no msg_id, no req_id, no stream
|
|
||||||
# session. The output must go via the proactive-send path instead
|
|
||||||
# of the stream/reply path.
|
|
||||||
spo = message_source.source_platform_object
|
|
||||||
if spo is None:
|
|
||||||
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
|
|
||||||
|
|
||||||
msg_id = spo.message_id
|
|
||||||
|
|
||||||
# Dify human-input pause: when the runner attaches `_form_data` to
|
|
||||||
# the final chunk, hand the button_interaction card off to the
|
|
||||||
# underlying client. In webhook mode the card is queued for the
|
|
||||||
# next followup poll; in ws mode it's sent as a reply frame
|
|
||||||
# immediately. Falls back to plain text when the bot has no active
|
|
||||||
# stream session for this msg_id (rare).
|
|
||||||
form_data = getattr(bot_message, '_form_data', None)
|
|
||||||
if form_data and is_final:
|
|
||||||
if hasattr(self.bot, 'push_form_pause'):
|
|
||||||
ok, stream_id, task_id = await self.bot.push_form_pause(msg_id, form_data)
|
|
||||||
if ok:
|
|
||||||
await self.logger.info(
|
|
||||||
f'WeComBot: pending button_interaction registered '
|
|
||||||
f'stream_id={stream_id} task_id={task_id} ws_mode={_ws_mode}'
|
|
||||||
)
|
|
||||||
return {'stream': True, 'form': True, 'task_id': task_id}
|
|
||||||
await self.logger.warning(
|
|
||||||
'WeComBot: cannot register form pause (no active stream session); falling back to plain text'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
|
|
||||||
|
|
||||||
fallback = _format_human_input_text(
|
|
||||||
form_data.get('node_title', ''),
|
|
||||||
form_data.get('form_content', ''),
|
|
||||||
form_data.get('actions', []) or [],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
fallback = content or '(人工输入)'
|
|
||||||
if _ws_mode:
|
|
||||||
event = message_source.source_platform_object
|
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
|
||||||
if req_id:
|
|
||||||
await self.bot.reply_text(req_id, fallback)
|
|
||||||
else:
|
|
||||||
await self.bot.set_message(msg_id, fallback)
|
|
||||||
return {'stream': False, 'form': True, 'fallback': True}
|
|
||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
@@ -480,142 +385,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""Whether streaming output is enabled for this bot instance."""
|
"""Whether streaming output is enabled for this bot instance."""
|
||||||
return self.config.get('enable-stream-reply', True)
|
return self.config.get('enable-stream-reply', True)
|
||||||
|
|
||||||
async def _handle_synthetic_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
bot_message,
|
|
||||||
content: str,
|
|
||||||
is_final: bool,
|
|
||||||
ws_mode: bool,
|
|
||||||
) -> dict:
|
|
||||||
"""Handle reply_message_chunk for synthetic events (button clicks).
|
|
||||||
|
|
||||||
Synthetic events have no inbound message → no msg_id, no req_id,
|
|
||||||
no stream session. We can't do incremental streaming, so we
|
|
||||||
buffer chunks per-conversation and flush on ``is_final`` via the
|
|
||||||
proactive send path.
|
|
||||||
|
|
||||||
Buffer keyed by ``(launcher_type, launcher_id)`` from the
|
|
||||||
synthetic event itself. Only ws mode has a usable proactive-send
|
|
||||||
path right now (``ws_client.send_message`` /
|
|
||||||
``ws_client.send_template_card``); webhook mode requires a
|
|
||||||
corpid/secret we don't have, so it logs and drops.
|
|
||||||
"""
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
chat_id = str(message_source.group.id)
|
|
||||||
else:
|
|
||||||
chat_id = str(message_source.sender.id)
|
|
||||||
|
|
||||||
form_data = getattr(bot_message, '_form_data', None)
|
|
||||||
|
|
||||||
# Buffer streaming content until is_final.
|
|
||||||
buf_key = chat_id
|
|
||||||
if not hasattr(self, '_synthetic_buffers'):
|
|
||||||
# Attribute-not-declared trick: pydantic forbids dynamic attrs
|
|
||||||
# on the model, but plain instance dicts via object.__setattr__
|
|
||||||
# do work. Lazy-create on first call.
|
|
||||||
object.__setattr__(self, '_synthetic_buffers', {})
|
|
||||||
buffers: dict[str, str] = self._synthetic_buffers
|
|
||||||
if content and not form_data:
|
|
||||||
previous = buffers.get(buf_key, '')
|
|
||||||
if previous and content.startswith(previous):
|
|
||||||
buffers[buf_key] = content
|
|
||||||
elif previous and previous.endswith(content):
|
|
||||||
buffers[buf_key] = previous
|
|
||||||
else:
|
|
||||||
buffers[buf_key] = previous + content
|
|
||||||
|
|
||||||
if not is_final:
|
|
||||||
return {'stream': True, 'synthetic': True, 'buffered': True}
|
|
||||||
|
|
||||||
final_content = buffers.pop(buf_key, '')
|
|
||||||
if content:
|
|
||||||
if final_content and content.startswith(final_content):
|
|
||||||
final_content = content
|
|
||||||
elif final_content and final_content.endswith(content):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
final_content = final_content + content
|
|
||||||
|
|
||||||
if not ws_mode:
|
|
||||||
await self.logger.warning(
|
|
||||||
'WeComBot webhook mode cannot proactively push synthetic-event '
|
|
||||||
'output (no corpid/secret); the resume reply is dropped. '
|
|
||||||
f'content_len={len(final_content)} form_data_present={form_data is not None}'
|
|
||||||
)
|
|
||||||
return {'stream': False, 'synthetic': True, 'dropped': True}
|
|
||||||
|
|
||||||
# ws mode: proactive send.
|
|
||||||
try:
|
|
||||||
if form_data:
|
|
||||||
# Determine user_id / chat_id for the routing context of any
|
|
||||||
# subsequent click on this card.
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
routing_chat_id = str(message_source.group.id)
|
|
||||||
routing_user_id = str(message_source.sender.id)
|
|
||||||
else:
|
|
||||||
routing_chat_id = ''
|
|
||||||
routing_user_id = str(message_source.sender.id)
|
|
||||||
payload = self._build_button_interaction_payload_from_form(
|
|
||||||
form_data,
|
|
||||||
user_id=routing_user_id,
|
|
||||||
chat_id=routing_chat_id,
|
|
||||||
)
|
|
||||||
await self.bot.send_template_card(chat_id, payload)
|
|
||||||
await self.logger.info(
|
|
||||||
f'WeComBot ws: proactively sent template_card for synthetic event '
|
|
||||||
f'chat_id={chat_id} form_token={form_data.get("form_token")!r} '
|
|
||||||
f'workflow_run_id={form_data.get("workflow_run_id")!r}'
|
|
||||||
)
|
|
||||||
elif final_content:
|
|
||||||
await self.bot.send_message(chat_id, final_content)
|
|
||||||
await self.logger.info(
|
|
||||||
f'WeComBot ws: proactively sent text for synthetic event chat_id={chat_id} len={len(final_content)}'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'WeComBot: synthetic event proactive send failed: {traceback.format_exc()}')
|
|
||||||
return {'stream': False, 'synthetic': True, 'error': True}
|
|
||||||
|
|
||||||
return {'stream': True, 'synthetic': True}
|
|
||||||
|
|
||||||
def _build_button_interaction_payload_from_form(
|
|
||||||
self, form_data: dict, *, user_id: str = '', chat_id: str = ''
|
|
||||||
) -> dict:
|
|
||||||
"""Build a button_interaction payload + track task_id for click resolution.
|
|
||||||
|
|
||||||
Unlike the inbound-event path (where push_form_pause registers the
|
|
||||||
task_id with the active stream session), proactive sends still
|
|
||||||
need the task_id registered so button clicks find pending_form.
|
|
||||||
For ws mode we stash it directly on the ws_client's pending dict.
|
|
||||||
"""
|
|
||||||
from langbot.libs.wecom_ai_bot_api.api import build_human_input_template_card_payload
|
|
||||||
import secrets as _secrets
|
|
||||||
|
|
||||||
task_id = f'dify-{_secrets.token_hex(12)}'
|
|
||||||
source = getattr(self.bot, 'card_source', None)
|
|
||||||
payload = build_human_input_template_card_payload(
|
|
||||||
form_data,
|
|
||||||
task_id,
|
|
||||||
source=source,
|
|
||||||
select_as_buttons=not self.config.get('enable-webhook', False),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register task_id → form_data so the click callback can find it.
|
|
||||||
# user_id / chat_id are required so _on_card_action can route the
|
|
||||||
# resulting synthetic query back to the right user. msg_id / req_id
|
|
||||||
# / stream_id are intentionally empty — synthetic cards have no
|
|
||||||
# inbound message to anchor on.
|
|
||||||
if hasattr(self.bot, '_pending_forms_by_task'):
|
|
||||||
self.bot._pending_forms_by_task[task_id] = {
|
|
||||||
'form_data': form_data,
|
|
||||||
'msg_id': '',
|
|
||||||
'user_id': user_id,
|
|
||||||
'chat_id': chat_id,
|
|
||||||
'stream_id': '',
|
|
||||||
'req_id': '',
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
|
|
||||||
async def send_message(self, target_type, target_id, message):
|
async def send_message(self, target_type, target_id, message):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
@@ -762,191 +531,3 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Dify human-input button-interaction click handling
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _on_card_action(self, session, action_id: str, task_id: str, raw_event: dict) -> None:
|
|
||||||
"""Translate a button click on a button_interaction card into a
|
|
||||||
synthetic ``_dify_form_action`` query enqueued on the pool.
|
|
||||||
|
|
||||||
Pattern mirrors DingTalk / Lark / Telegram so the runner's
|
|
||||||
``_merge_pending_form_action`` path resumes the workflow.
|
|
||||||
"""
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
|
||||||
|
|
||||||
form = session.pending_form or {}
|
|
||||||
await self.logger.info(
|
|
||||||
f'WeComBot _on_card_action: task_id={task_id} action_id={action_id!r} '
|
|
||||||
f'form_token={form.get("form_token")!r} workflow_run_id={form.get("workflow_run_id")!r} '
|
|
||||||
f'session.user_id={session.user_id!r} session.chat_id={session.chat_id!r}'
|
|
||||||
)
|
|
||||||
|
|
||||||
actions = form.get('actions') or []
|
|
||||||
tce = extract_template_card_event_payload(raw_event) if isinstance(raw_event, dict) else {}
|
|
||||||
_, _, card_type = extract_template_card_action(tce)
|
|
||||||
selections = extract_template_card_selections(tce, form)
|
|
||||||
if not selections:
|
|
||||||
selections = parse_select_button_action(action_id, form)
|
|
||||||
await self.logger.info(
|
|
||||||
f'WeComBot template_card selections: task_id={task_id} card_type={card_type} selections={selections}'
|
|
||||||
)
|
|
||||||
if card_type == 'multiple_interaction' and not selections:
|
|
||||||
await self.logger.warning(
|
|
||||||
f'WeComBot: multiple_interaction callback has no parseable selections; raw={str(tce)[:1000]}'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
is_select_submit = card_type == 'multiple_interaction' or bool(selections)
|
|
||||||
|
|
||||||
clean_action_id = '' if is_select_submit else (action_id or '').strip()
|
|
||||||
action_title = clean_action_id
|
|
||||||
for a in actions:
|
|
||||||
if str(a.get('id', '')) == clean_action_id:
|
|
||||||
action_title = a.get('title') or clean_action_id
|
|
||||||
break
|
|
||||||
|
|
||||||
inputs = dict(form.get('inputs') or {})
|
|
||||||
inputs.update(selections)
|
|
||||||
|
|
||||||
def _missing_fields_after_select() -> list[str]:
|
|
||||||
missing: list[str] = []
|
|
||||||
for field in form.get('input_defs') or form.get('all_input_defs') or []:
|
|
||||||
field_name = str(field.get('output_variable_name') or '').strip()
|
|
||||||
if not field_name:
|
|
||||||
continue
|
|
||||||
if inputs.get(field_name) in (None, '', []):
|
|
||||||
missing.append(field_name)
|
|
||||||
return missing
|
|
||||||
|
|
||||||
input_progress = False
|
|
||||||
if is_select_submit:
|
|
||||||
missing_fields = _missing_fields_after_select()
|
|
||||||
if not missing_fields and len(actions) == 1:
|
|
||||||
action = actions[0]
|
|
||||||
clean_action_id = str(action.get('id') or '').strip()
|
|
||||||
action_title = action.get('title') or clean_action_id
|
|
||||||
elif not missing_fields and len(actions) > 1:
|
|
||||||
if not self.config.get('enable-webhook', False):
|
|
||||||
action_form_data = {
|
|
||||||
'form_content': form.get('raw_form_content') or form.get('form_content') or '',
|
|
||||||
'raw_form_content': form.get('raw_form_content') or form.get('form_content') or '',
|
|
||||||
'input_defs': [],
|
|
||||||
'all_input_defs': form.get('all_input_defs') or form.get('input_defs') or [],
|
|
||||||
'inputs': inputs,
|
|
||||||
'actions': actions,
|
|
||||||
'node_title': form.get('node_title', ''),
|
|
||||||
'workflow_run_id': form.get('workflow_run_id', ''),
|
|
||||||
'form_token': form.get('form_token', ''),
|
|
||||||
'pipeline_uuid': form.get('pipeline_uuid', ''),
|
|
||||||
'_action_select_only': True,
|
|
||||||
}
|
|
||||||
target_chat_id = session.chat_id or session.user_id or ''
|
|
||||||
try:
|
|
||||||
payload = self._build_button_interaction_payload_from_form(
|
|
||||||
action_form_data,
|
|
||||||
user_id=session.user_id or '',
|
|
||||||
chat_id=session.chat_id or '',
|
|
||||||
)
|
|
||||||
await self.bot.send_template_card(target_chat_id, payload)
|
|
||||||
await self.logger.info(
|
|
||||||
f'WeComBot: sent action-select button card after select submit '
|
|
||||||
f'task_id={task_id} action_count={len(actions)}'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(
|
|
||||||
f'WeComBot: failed to send action-select button card: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await self.logger.warning(
|
|
||||||
'WeComBot webhook mode cannot proactively send action-select button card after select submit'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
input_progress = True
|
|
||||||
action_title = 'Submit'
|
|
||||||
|
|
||||||
launcher_id = session.user_id or session.chat_id or ''
|
|
||||||
sender_user_id = session.user_id or launcher_id
|
|
||||||
# WeCom AI bot has both single-chat and group-chat; chat_id present
|
|
||||||
# indicates group context.
|
|
||||||
if session.chat_id:
|
|
||||||
launcher_type = provider_session.LauncherTypes.GROUP
|
|
||||||
launcher_id = session.chat_id
|
|
||||||
else:
|
|
||||||
launcher_type = provider_session.LauncherTypes.PERSON
|
|
||||||
launcher_id = session.user_id or ''
|
|
||||||
|
|
||||||
form_action_data = {
|
|
||||||
'form_token': form.get('form_token', ''),
|
|
||||||
'workflow_run_id': form.get('workflow_run_id', ''),
|
|
||||||
'action_id': clean_action_id,
|
|
||||||
'action_title': action_title,
|
|
||||||
'node_title': form.get('node_title', ''),
|
|
||||||
'user': f'{launcher_type.value}_{launcher_id}',
|
|
||||||
'inputs': inputs,
|
|
||||||
}
|
|
||||||
if input_progress:
|
|
||||||
form_action_data['_input_progress'] = True
|
|
||||||
|
|
||||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
|
|
||||||
|
|
||||||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
|
||||||
synthetic_event = platform_events.GroupMessage(
|
|
||||||
sender=platform_entities.GroupMember(
|
|
||||||
id=sender_user_id,
|
|
||||||
member_name='',
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
group=platform_entities.Group(
|
|
||||||
id=launcher_id,
|
|
||||||
name='',
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
),
|
|
||||||
special_title='',
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
time=int(time.time()),
|
|
||||||
source_platform_object=None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
synthetic_event = platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(
|
|
||||||
id=sender_user_id,
|
|
||||||
nickname='',
|
|
||||||
remark='',
|
|
||||||
),
|
|
||||||
message_chain=message_chain,
|
|
||||||
time=int(time.time()),
|
|
||||||
source_platform_object=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ap is None:
|
|
||||||
await self.logger.error('WeComBot: ap not injected; cannot enqueue button-click query')
|
|
||||||
return
|
|
||||||
|
|
||||||
bot_uuid = ''
|
|
||||||
pipeline_uuid = form.get('pipeline_uuid') or None
|
|
||||||
for bot in self.ap.platform_mgr.bots:
|
|
||||||
if bot.adapter is self:
|
|
||||||
bot_uuid = bot.bot_entity.uuid
|
|
||||||
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.ap.query_pool.add_query(
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
launcher_type=launcher_type,
|
|
||||||
launcher_id=launcher_id,
|
|
||||||
sender_id=sender_user_id,
|
|
||||||
message_event=synthetic_event,
|
|
||||||
message_chain=message_chain,
|
|
||||||
adapter=self,
|
|
||||||
pipeline_uuid=pipeline_uuid,
|
|
||||||
variables={
|
|
||||||
'_dify_form_action': form_action_data,
|
|
||||||
'_routed_by_rule': True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self.logger.info(f'WeComBot: button-click query enqueued action_id={clean_action_id!r}')
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'WeComBot: enqueue button-click query failed: {traceback.format_exc()}')
|
|
||||||
|
|||||||
@@ -13,151 +13,6 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
|
||||||
|
|
||||||
class _ThinkStripState:
|
|
||||||
"""Stateful filter that drops think blocks across chunks."""
|
|
||||||
|
|
||||||
_THINK_OPEN = '<think>'
|
|
||||||
_THINK_CLOSE = '</think>'
|
|
||||||
_LEGACY_OPEN = 'CRETIRE_REASONING_BEGINk'
|
|
||||||
_LEGACY_CLOSE = 'CRETIRE_REASONING_ENDk'
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._pairs: tuple[tuple[str, str], ...] = (
|
|
||||||
(self._THINK_OPEN, self._THINK_CLOSE),
|
|
||||||
(self._LEGACY_OPEN, self._LEGACY_CLOSE),
|
|
||||||
)
|
|
||||||
self._open_tags = tuple(open_tag for open_tag, _close_tag in self._pairs)
|
|
||||||
self._buf = ''
|
|
||||||
self._close_tag: str | None = None
|
|
||||||
self._pending_initial = True
|
|
||||||
|
|
||||||
def feed(self, chunk: str) -> str:
|
|
||||||
"""Feed a streaming delta and return user-visible content."""
|
|
||||||
if not chunk:
|
|
||||||
return chunk
|
|
||||||
|
|
||||||
text = self._buf + chunk
|
|
||||||
if self._close_tag is not None:
|
|
||||||
return self._consume_think_body(text)
|
|
||||||
|
|
||||||
return self._process_visible_text(text)
|
|
||||||
|
|
||||||
def flush(self) -> str:
|
|
||||||
"""Release buffered visible content when the stream ends."""
|
|
||||||
if self._close_tag is not None:
|
|
||||||
self._buf = ''
|
|
||||||
self._close_tag = None
|
|
||||||
return ''
|
|
||||||
|
|
||||||
pending, self._buf = self._buf, ''
|
|
||||||
self._close_tag = None
|
|
||||||
return pending
|
|
||||||
|
|
||||||
def _consume_think_body(self, text: str) -> str:
|
|
||||||
close_tag = self._close_tag
|
|
||||||
if close_tag is None:
|
|
||||||
return text
|
|
||||||
|
|
||||||
close_idx = text.find(close_tag)
|
|
||||||
if close_idx != -1:
|
|
||||||
self._close_tag = None
|
|
||||||
self._buf = ''
|
|
||||||
self._pending_initial = False
|
|
||||||
return self._process_visible_text(text[close_idx + len(close_tag) :])
|
|
||||||
|
|
||||||
self._buf = self._close_prefix(text, close_tag)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def _process_visible_text(self, text: str) -> str:
|
|
||||||
out: list[str] = []
|
|
||||||
index = 0
|
|
||||||
|
|
||||||
while index < len(text):
|
|
||||||
if self._pending_initial:
|
|
||||||
open_idx, open_tag, close_tag = self._find_next_open(text, index)
|
|
||||||
orphan_close_idx, orphan_close_tag = self._find_next_close(text, index)
|
|
||||||
|
|
||||||
if orphan_close_idx != -1 and (open_idx == -1 or orphan_close_idx < open_idx):
|
|
||||||
self._pending_initial = False
|
|
||||||
index = orphan_close_idx + len(orphan_close_tag)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if open_idx == -1:
|
|
||||||
self._buf = text[index:]
|
|
||||||
return ''.join(out)
|
|
||||||
|
|
||||||
if open_idx > index:
|
|
||||||
self._pending_initial = False
|
|
||||||
out.append(text[index:open_idx])
|
|
||||||
index = open_idx
|
|
||||||
continue
|
|
||||||
|
|
||||||
open_idx, open_tag, close_tag = self._find_next_open(text, index)
|
|
||||||
if open_idx == -1:
|
|
||||||
emit_end = self._visible_emit_end(text, index)
|
|
||||||
out.append(text[index:emit_end])
|
|
||||||
if emit_end > index:
|
|
||||||
self._pending_initial = False
|
|
||||||
self._buf = text[emit_end:]
|
|
||||||
return ''.join(out)
|
|
||||||
|
|
||||||
out.append(text[index:open_idx])
|
|
||||||
if open_idx > index:
|
|
||||||
self._pending_initial = False
|
|
||||||
body_start = open_idx + len(open_tag)
|
|
||||||
close_idx = text.find(close_tag, body_start)
|
|
||||||
if close_idx == -1:
|
|
||||||
self._close_tag = close_tag
|
|
||||||
self._buf = self._close_prefix(text[body_start:], close_tag)
|
|
||||||
return ''.join(out)
|
|
||||||
|
|
||||||
self._pending_initial = False
|
|
||||||
index = close_idx + len(close_tag)
|
|
||||||
|
|
||||||
self._buf = ''
|
|
||||||
return ''.join(out)
|
|
||||||
|
|
||||||
def _find_next_open(self, text: str, start: int) -> tuple[int, str, str]:
|
|
||||||
best_idx = -1
|
|
||||||
best_open = ''
|
|
||||||
best_close = ''
|
|
||||||
for open_tag, close_tag in self._pairs:
|
|
||||||
idx = text.find(open_tag, start)
|
|
||||||
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
|
||||||
best_idx = idx
|
|
||||||
best_open = open_tag
|
|
||||||
best_close = close_tag
|
|
||||||
return best_idx, best_open, best_close
|
|
||||||
|
|
||||||
def _find_next_close(self, text: str, start: int) -> tuple[int, str]:
|
|
||||||
best_idx = -1
|
|
||||||
best_close = ''
|
|
||||||
for _open_tag, close_tag in self._pairs:
|
|
||||||
idx = text.find(close_tag, start)
|
|
||||||
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
|
||||||
best_idx = idx
|
|
||||||
best_close = close_tag
|
|
||||||
return best_idx, best_close
|
|
||||||
|
|
||||||
def _visible_emit_end(self, text: str, start: int) -> int:
|
|
||||||
visible = text[start:]
|
|
||||||
limit = min(len(visible), max(len(open_tag) for open_tag in self._open_tags) - 1)
|
|
||||||
for keep in range(limit, 0, -1):
|
|
||||||
suffix = visible[-keep:]
|
|
||||||
if any(open_tag.startswith(suffix) for open_tag in self._open_tags):
|
|
||||||
return len(text) - keep
|
|
||||||
return len(text)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _close_prefix(text: str, close_tag: str) -> str:
|
|
||||||
limit = min(len(text), len(close_tag) - 1)
|
|
||||||
for keep in range(limit, 0, -1):
|
|
||||||
suffix = text[-keep:]
|
|
||||||
if close_tag.startswith(suffix):
|
|
||||||
return suffix
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
class LiteLLMRequester(requester.ProviderAPIRequester):
|
class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||||
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
|
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
|
||||||
|
|
||||||
@@ -382,25 +237,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
|
|
||||||
return req_messages
|
return req_messages
|
||||||
|
|
||||||
_THINK_PATTERNS: tuple[str, ...] = (
|
|
||||||
r'^\s*(?:(?!<think>).)*?</think>\s*',
|
|
||||||
r'^\s*(?:(?!CRETIRE_REASONING_BEGINk).)*?CRETIRE_REASONING_ENDk\s*',
|
|
||||||
r'<think>.*?</think>',
|
|
||||||
r'CRETIRE_REASONING_BEGINk.*?CRETIRE_REASONING_ENDk',
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _strip_think(cls, content: str) -> str:
|
|
||||||
"""Strip chain-of-thought blocks from ``content``."""
|
|
||||||
if not content:
|
|
||||||
return content
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
for pattern in cls._THINK_PATTERNS:
|
|
||||||
content = re.sub(pattern, '', content, flags=re.DOTALL)
|
|
||||||
return content.strip()
|
|
||||||
|
|
||||||
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
|
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
|
||||||
"""Process thinking/reasoning content.
|
"""Process thinking/reasoning content.
|
||||||
|
|
||||||
@@ -412,12 +248,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
Returns:
|
Returns:
|
||||||
Processed content string
|
Processed content string
|
||||||
"""
|
"""
|
||||||
if remove_think and content:
|
# Extract and handle thinking tags
|
||||||
content = self._strip_think(content)
|
if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content:
|
||||||
|
import re
|
||||||
|
|
||||||
if reasoning_content and not remove_think:
|
think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk'
|
||||||
content = f'<think>\n{reasoning_content}\n</think>\n{content or ""}'.strip()
|
|
||||||
|
|
||||||
|
if remove_think:
|
||||||
|
# Remove thinking tags and their content from output
|
||||||
|
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
|
||||||
|
# else: preserve thinking content as-is
|
||||||
|
|
||||||
|
# Handle separate reasoning_content field
|
||||||
|
# Currently we don't include reasoning_content in user-facing output regardless of remove_think
|
||||||
|
# because it's typically internal model reasoning, not user-visible thinking
|
||||||
return content or ''
|
return content or ''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -726,7 +570,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
chunk_idx = 0
|
chunk_idx = 0
|
||||||
role = 'assistant'
|
role = 'assistant'
|
||||||
tool_call_state: dict[int, dict[str, typing.Any]] = {}
|
tool_call_state: dict[int, dict[str, typing.Any]] = {}
|
||||||
think_state = _ThinkStripState() if remove_think else None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await acompletion(**args)
|
response = await acompletion(**args)
|
||||||
@@ -770,12 +613,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
# Use reasoning_content as the displayed content
|
# Use reasoning_content as the displayed content
|
||||||
delta_content = reasoning_content
|
delta_content = reasoning_content
|
||||||
|
|
||||||
if think_state is not None and delta_content:
|
|
||||||
delta_content = think_state.feed(delta_content)
|
|
||||||
if not delta_content:
|
|
||||||
chunk_idx += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
|
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
|
||||||
|
|
||||||
if chunk_idx == 0 and not delta_content and not tool_calls:
|
if chunk_idx == 0 and not delta_content and not tool_calls:
|
||||||
@@ -797,15 +634,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
yield provider_message.MessageChunk(**chunk_data)
|
yield provider_message.MessageChunk(**chunk_data)
|
||||||
chunk_idx += 1
|
chunk_idx += 1
|
||||||
|
|
||||||
if think_state is not None:
|
|
||||||
pending_content = think_state.flush()
|
|
||||||
if pending_content:
|
|
||||||
yield provider_message.MessageChunk(
|
|
||||||
role=role,
|
|
||||||
content=pending_content,
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._handle_litellm_error(e)
|
self._handle_litellm_error(e)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
|
|
||||||
<rect width="64" height="64" rx="14" fill="#111827"/>
|
|
||||||
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
|
|
||||||
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 265 B |
@@ -1,29 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: LLMAPIRequester
|
|
||||||
metadata:
|
|
||||||
name: tokenlab-chat-completions
|
|
||||||
label:
|
|
||||||
en_US: TokenLab
|
|
||||||
zh_Hans: TokenLab
|
|
||||||
icon: tokenlab.svg
|
|
||||||
spec:
|
|
||||||
litellm_provider: openai
|
|
||||||
config:
|
|
||||||
- name: base_url
|
|
||||||
label:
|
|
||||||
en_US: Base URL
|
|
||||||
zh_Hans: 基础 URL
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: https://api.tokenlab.sh/v1
|
|
||||||
- name: timeout
|
|
||||||
label:
|
|
||||||
en_US: Timeout
|
|
||||||
zh_Hans: 超时时间
|
|
||||||
type: integer
|
|
||||||
required: true
|
|
||||||
default: 120
|
|
||||||
alias: "tokenlab TokenLab gpt claude gemini deepseek qwen kimi minimax glm grok openai-compatible"
|
|
||||||
support_type:
|
|
||||||
- llm
|
|
||||||
provider_category: maas
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -49,23 +49,12 @@ def _model_has_ability(model: modelmgr_requester.RuntimeLLMModel, ability: str)
|
|||||||
class _StreamAccumulator:
|
class _StreamAccumulator:
|
||||||
"""Accumulate streamed content and fragmented OpenAI-style tool calls."""
|
"""Accumulate streamed content and fragmented OpenAI-style tool calls."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, msg_sequence: int = 0, initial_content: str | None = None):
|
||||||
self,
|
|
||||||
msg_sequence: int = 0,
|
|
||||||
initial_content: str | None = None,
|
|
||||||
remove_think: bool = False,
|
|
||||||
):
|
|
||||||
self.tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
self.tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
||||||
self.msg_idx = 0
|
self.msg_idx = 0
|
||||||
self.accumulated_content = initial_content or ''
|
self.accumulated_content = initial_content or ''
|
||||||
self.last_role = 'assistant'
|
self.last_role = 'assistant'
|
||||||
self.msg_sequence = msg_sequence
|
self.msg_sequence = msg_sequence
|
||||||
self.remove_think = remove_think
|
|
||||||
self._think_state = None
|
|
||||||
if remove_think:
|
|
||||||
from ..modelmgr.requesters.litellmchat import _ThinkStripState
|
|
||||||
|
|
||||||
self._think_state = _ThinkStripState()
|
|
||||||
|
|
||||||
def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None:
|
def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None:
|
||||||
self.msg_idx += 1
|
self.msg_idx += 1
|
||||||
@@ -74,10 +63,7 @@ class _StreamAccumulator:
|
|||||||
self.last_role = msg.role
|
self.last_role = msg.role
|
||||||
|
|
||||||
if msg.content:
|
if msg.content:
|
||||||
content = msg.content
|
self.accumulated_content += msg.content
|
||||||
if self._think_state is not None:
|
|
||||||
content = self._think_state.feed(content)
|
|
||||||
self.accumulated_content += content
|
|
||||||
|
|
||||||
if msg.tool_calls:
|
if msg.tool_calls:
|
||||||
for tool_call in msg.tool_calls:
|
for tool_call in msg.tool_calls:
|
||||||
@@ -93,14 +79,11 @@ class _StreamAccumulator:
|
|||||||
if tool_call.function and tool_call.function.arguments:
|
if tool_call.function and tool_call.function.arguments:
|
||||||
self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||||
|
|
||||||
if msg.is_final:
|
|
||||||
self._flush_think_state()
|
|
||||||
|
|
||||||
if self.msg_idx % 8 == 0 or msg.is_final:
|
if self.msg_idx % 8 == 0 or msg.is_final:
|
||||||
self.msg_sequence += 1
|
self.msg_sequence += 1
|
||||||
return provider_message.MessageChunk(
|
return provider_message.MessageChunk(
|
||||||
role=self.last_role,
|
role=self.last_role,
|
||||||
content=self._maybe_strip_think(self.accumulated_content),
|
content=self.accumulated_content,
|
||||||
tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None,
|
tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None,
|
||||||
is_final=msg.is_final,
|
is_final=msg.is_final,
|
||||||
msg_sequence=self.msg_sequence,
|
msg_sequence=self.msg_sequence,
|
||||||
@@ -109,29 +92,13 @@ class _StreamAccumulator:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def final_message(self) -> provider_message.MessageChunk:
|
def final_message(self) -> provider_message.MessageChunk:
|
||||||
self._flush_think_state()
|
|
||||||
return provider_message.MessageChunk(
|
return provider_message.MessageChunk(
|
||||||
role=self.last_role,
|
role=self.last_role,
|
||||||
content=self._maybe_strip_think(self.accumulated_content),
|
content=self.accumulated_content,
|
||||||
tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None,
|
tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None,
|
||||||
msg_sequence=self.msg_sequence,
|
msg_sequence=self.msg_sequence,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _maybe_strip_think(self, content: str) -> str:
|
|
||||||
if not self.remove_think or not content:
|
|
||||||
return content
|
|
||||||
|
|
||||||
from ..modelmgr.requesters.litellmchat import LiteLLMRequester
|
|
||||||
|
|
||||||
return LiteLLMRequester._strip_think(content)
|
|
||||||
|
|
||||||
def _flush_think_state(self) -> None:
|
|
||||||
if self._think_state is None:
|
|
||||||
return
|
|
||||||
pending = self._think_state.flush()
|
|
||||||
if pending:
|
|
||||||
self.accumulated_content += pending
|
|
||||||
|
|
||||||
|
|
||||||
@runner.runner_class('local-agent')
|
@runner.runner_class('local-agent')
|
||||||
class LocalAgentRunner(runner.RequestRunner):
|
class LocalAgentRunner(runner.RequestRunner):
|
||||||
@@ -481,7 +448,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
is_stream = False
|
is_stream = False
|
||||||
|
|
||||||
remove_think = ((query.pipeline_config.get('output') or {}).get('misc') or {}).get('remove-think', False)
|
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||||
|
|
||||||
# Build ordered candidate list (primary + fallbacks)
|
# Build ordered candidate list (primary + fallbacks)
|
||||||
candidates = await self._get_model_candidates(query)
|
candidates = await self._get_model_candidates(query)
|
||||||
@@ -505,7 +472,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
final_msg = msg
|
final_msg = msg
|
||||||
else:
|
else:
|
||||||
# Streaming: invoke with fallback
|
# Streaming: invoke with fallback
|
||||||
stream_accumulator = _StreamAccumulator(msg_sequence=1, remove_think=remove_think)
|
stream_accumulator = _StreamAccumulator(msg_sequence=1)
|
||||||
|
|
||||||
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
||||||
query,
|
query,
|
||||||
@@ -609,7 +576,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
stream_accumulator = _StreamAccumulator(
|
stream_accumulator = _StreamAccumulator(
|
||||||
msg_sequence=first_end_sequence,
|
msg_sequence=first_end_sequence,
|
||||||
initial_content=first_content,
|
initial_content=first_content,
|
||||||
remove_think=remove_think,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
|
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
|
||||||
|
|||||||
@@ -196,16 +196,6 @@ class _TransportReconnect(Exception):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class _CallerReconnect(Exception):
|
|
||||||
"""Internal signal: a tool/resource call hit a server-expired session
|
|
||||||
(e.g. a UDP/HTTP MCP server's own session timeout) and asked the owning
|
|
||||||
lifecycle loop to rebuild the connection.
|
|
||||||
|
|
||||||
Like _TransportReconnect, this does NOT consume the fatal retry budget —
|
|
||||||
the server-side timeout is expected, recurring behavior, not a failure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeMCPSession:
|
class RuntimeMCPSession:
|
||||||
"""运行时 MCP 会话"""
|
"""运行时 MCP 会话"""
|
||||||
|
|
||||||
@@ -275,12 +265,6 @@ class RuntimeMCPSession:
|
|||||||
self._lifecycle_task = None
|
self._lifecycle_task = None
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
# Signaled by callers (invoke_mcp_tool / read_resource_envelope) when
|
|
||||||
# they see a server-expired session; the lifecycle loop reconnects and
|
|
||||||
# sets _reconnected_event so all callers waiting on this cycle resume
|
|
||||||
# together, instead of each racing to rebuild the session itself.
|
|
||||||
self._reconnect_event = asyncio.Event()
|
|
||||||
self._reconnected_event: asyncio.Event | None = None
|
|
||||||
# Set transiently when a WS transport drop should NOT stop the managed
|
# Set transiently when a WS transport drop should NOT stop the managed
|
||||||
# process (it will be re-attached on the next initialize()).
|
# process (it will be re-attached on the next initialize()).
|
||||||
self._preserve_managed_process = False
|
self._preserve_managed_process = False
|
||||||
@@ -445,19 +429,12 @@ class RuntimeMCPSession:
|
|||||||
if self._uses_box_stdio():
|
if self._uses_box_stdio():
|
||||||
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
|
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
|
||||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
||||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
[shutdown_task, monitor_task, reconnect_task],
|
[shutdown_task, monitor_task],
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
if reconnect_task in done and not self._shutdown_event.is_set():
|
|
||||||
self._reconnect_event.clear()
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'MCP session {self.server_name}: caller requested reconnect (server session expired)'
|
|
||||||
)
|
|
||||||
raise _CallerReconnect('Caller requested reconnect after session expiry')
|
|
||||||
for task in done:
|
for task in done:
|
||||||
if task is monitor_task and not self._shutdown_event.is_set():
|
if task is monitor_task and not self._shutdown_event.is_set():
|
||||||
# The monitor completed. This is EITHER the managed
|
# The monitor completed. This is EITHER the managed
|
||||||
@@ -485,20 +462,7 @@ class RuntimeMCPSession:
|
|||||||
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
||||||
raise Exception('Box managed process exited unexpectedly')
|
raise Exception('Box managed process exited unexpectedly')
|
||||||
else:
|
else:
|
||||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
await self._shutdown_event.wait()
|
||||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
|
||||||
done, pending = await asyncio.wait(
|
|
||||||
[shutdown_task, reconnect_task],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
if reconnect_task in done and not self._shutdown_event.is_set():
|
|
||||||
self._reconnect_event.clear()
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'MCP session {self.server_name}: caller requested reconnect (server session expired)'
|
|
||||||
)
|
|
||||||
raise _CallerReconnect('Caller requested reconnect after session expiry')
|
|
||||||
|
|
||||||
except _ColdStartRetry:
|
except _ColdStartRetry:
|
||||||
# Cold-start in progress: set the preserve flag BEFORE the finally
|
# Cold-start in progress: set the preserve flag BEFORE the finally
|
||||||
@@ -557,43 +521,6 @@ class RuntimeMCPSession:
|
|||||||
self.error_phase = None
|
self.error_phase = None
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
except _CallerReconnect:
|
|
||||||
# A tool/resource call hit a server-expired session and asked us
|
|
||||||
# to rebuild the connection. Same treatment as _TransportReconnect:
|
|
||||||
# reconnect immediately WITHOUT consuming the fatal retry budget.
|
|
||||||
# Wake any callers waiting in _trigger_reconnect() regardless of
|
|
||||||
# outcome so they don't block for the full timeout.
|
|
||||||
reconnected_event = self._reconnected_event
|
|
||||||
if self._shutdown_event.is_set():
|
|
||||||
if reconnected_event is not None:
|
|
||||||
reconnected_event.set()
|
|
||||||
return
|
|
||||||
self.status = MCPSessionStatus.CONNECTING
|
|
||||||
self.error_message = None
|
|
||||||
self.error_phase = None
|
|
||||||
try:
|
|
||||||
if self.server_config['mode'] == 'stdio':
|
|
||||||
await self._init_stdio_python_server()
|
|
||||||
elif self.server_config['mode'] == 'remote':
|
|
||||||
await self._init_remote_server()
|
|
||||||
elif self.server_config['mode'] == 'sse':
|
|
||||||
await self._init_sse_server()
|
|
||||||
elif self.server_config['mode'] == 'http':
|
|
||||||
await self._init_streamable_http_server()
|
|
||||||
await self.refresh()
|
|
||||||
self.status = MCPSessionStatus.CONNECTED
|
|
||||||
self.ap.logger.info(f'MCP session {self.server_name} reconnected successfully after session expiry')
|
|
||||||
except Exception as reconnect_err:
|
|
||||||
self.status = MCPSessionStatus.ERROR
|
|
||||||
self.error_message = str(reconnect_err)
|
|
||||||
self.ap.logger.error(
|
|
||||||
f'MCP session {self.server_name}: reconnect after session expiry failed: '
|
|
||||||
f'{self._describe_exception(reconnect_err)}'
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if reconnected_event is not None:
|
|
||||||
reconnected_event.set()
|
|
||||||
continue
|
|
||||||
except _ColdStartRetry as e:
|
except _ColdStartRetry as e:
|
||||||
# The managed process is alive but still cold-starting (e.g.
|
# The managed process is alive but still cold-starting (e.g.
|
||||||
# `npx -y <pkg>` is still installing) and cannot yet answer the
|
# `npx -y <pkg>` is still installing) and cannot yet answer the
|
||||||
@@ -696,47 +623,6 @@ class RuntimeMCPSession:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_session_terminated(exc: BaseException) -> bool:
|
|
||||||
"""Whether exc indicates the server-side session expired.
|
|
||||||
|
|
||||||
Long-lived MCP servers (notably UDP/HTTP transports) commonly enforce
|
|
||||||
their own session timeout (e.g. ~30 minutes); once it fires, any call
|
|
||||||
on the cached session raises this rather than a transport-level error.
|
|
||||||
"""
|
|
||||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
|
|
||||||
msg = str(leaf).lower()
|
|
||||||
if 'session terminated' in msg or 'session expired' in msg:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
_RECONNECT_WAIT_TIMEOUT = 30.0
|
|
||||||
|
|
||||||
async def _trigger_reconnect(self) -> bool:
|
|
||||||
"""Ask the owning lifecycle loop to rebuild the session and wait for it.
|
|
||||||
|
|
||||||
Concurrent callers that hit the timeout at the same time all signal
|
|
||||||
the same _reconnect_event and await the same _reconnected_event, so
|
|
||||||
the lifecycle loop reconnects once and every caller resumes together
|
|
||||||
— instead of each caller racing to rebuild the session itself.
|
|
||||||
|
|
||||||
Returns True if reconnection succeeded within the timeout.
|
|
||||||
"""
|
|
||||||
if self._shutdown_event.is_set():
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self._reconnected_event is None or self._reconnected_event.is_set():
|
|
||||||
self._reconnected_event = asyncio.Event()
|
|
||||||
reconnected_event = self._reconnected_event
|
|
||||||
self._reconnect_event.set()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(reconnected_event.wait(), timeout=self._RECONNECT_WAIT_TIMEOUT)
|
|
||||||
return self.status == MCPSessionStatus.CONNECTED
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
self.ap.logger.warning(f'MCP session {self.server_name} reconnect timed out')
|
|
||||||
return False
|
|
||||||
|
|
||||||
_MONITOR_POLL_INTERVAL = 5
|
_MONITOR_POLL_INTERVAL = 5
|
||||||
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
||||||
|
|
||||||
@@ -945,21 +831,10 @@ class RuntimeMCPSession:
|
|||||||
arguments: dict,
|
arguments: dict,
|
||||||
query: pipeline_query.Query | None = None,
|
query: pipeline_query.Query | None = None,
|
||||||
) -> list[provider_message.ContentElement]:
|
) -> list[provider_message.ContentElement]:
|
||||||
for attempt in range(2):
|
|
||||||
if not self.session:
|
if not self.session:
|
||||||
raise Exception('MCP session is not connected')
|
raise Exception('MCP session is not connected')
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.session.call_tool(tool_name, arguments)
|
result = await self.session.call_tool(tool_name, arguments)
|
||||||
except Exception as e:
|
|
||||||
if attempt == 0 and self._is_session_terminated(e):
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'MCP tool {tool_name} on {self.server_name} got session terminated, triggering reconnect...'
|
|
||||||
)
|
|
||||||
if await self._trigger_reconnect():
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
|
|
||||||
if result.isError:
|
if result.isError:
|
||||||
error_texts = []
|
error_texts = []
|
||||||
for content in result.content:
|
for content in result.content:
|
||||||
@@ -972,8 +847,6 @@ class RuntimeMCPSession:
|
|||||||
result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name))
|
result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name))
|
||||||
return result_contents
|
return result_contents
|
||||||
|
|
||||||
raise Exception('MCP session is not connected')
|
|
||||||
|
|
||||||
def get_tools(self) -> list[resource_tool.LLMTool]:
|
def get_tools(self) -> list[resource_tool.LLMTool]:
|
||||||
return self.functions
|
return self.functions
|
||||||
|
|
||||||
@@ -1037,21 +910,7 @@ class RuntimeMCPSession:
|
|||||||
self._record_resource_read_trace(query, envelope)
|
self._record_resource_read_trace(query, envelope)
|
||||||
return envelope
|
return envelope
|
||||||
|
|
||||||
result = None
|
|
||||||
for attempt in range(2):
|
|
||||||
if not self.session:
|
|
||||||
raise Exception('MCP session is not connected')
|
|
||||||
try:
|
|
||||||
result = await self.session.read_resource(AnyUrl(uri))
|
result = await self.session.read_resource(AnyUrl(uri))
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
if attempt == 0 and self._is_session_terminated(e):
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'MCP resource read on {self.server_name} got session terminated, triggering reconnect...'
|
|
||||||
)
|
|
||||||
if await self._trigger_reconnect():
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
contents: list[dict] = []
|
contents: list[dict] = []
|
||||||
total_bytes = 0
|
total_bytes = 0
|
||||||
truncated_any = False
|
truncated_any = False
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -303,60 +303,11 @@
|
|||||||
'<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM6 20V4h7v5h5v11H6z"/><path d="M8 17l2.5-3.5L13 17l2-2.5L18 17H8z"/></svg>';
|
'<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM6 20V4h7v5h5v11H6z"/><path d="M8 17l2.5-3.5L13 17l2-2.5L18 17H8z"/></svg>';
|
||||||
|
|
||||||
// ========== State ==========
|
// ========== State ==========
|
||||||
function createSessionId() {
|
|
||||||
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
|
||||||
return window.crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
var bytes = new Uint8Array(16);
|
|
||||||
if (!window.crypto || typeof window.crypto.getRandomValues !== "function") {
|
|
||||||
throw new Error("Secure random number generation is unavailable");
|
|
||||||
}
|
|
||||||
window.crypto.getRandomValues(bytes);
|
|
||||||
bytes[6] = (bytes[6] & 15) | 64;
|
|
||||||
bytes[8] = (bytes[8] & 63) | 128;
|
|
||||||
var hex = Array.prototype.map
|
|
||||||
.call(bytes, function (value) {
|
|
||||||
return value.toString(16).padStart(2, "0");
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return (
|
|
||||||
hex.slice(0, 8) +
|
|
||||||
"-" +
|
|
||||||
hex.slice(8, 12) +
|
|
||||||
"-" +
|
|
||||||
hex.slice(12, 16) +
|
|
||||||
"-" +
|
|
||||||
hex.slice(16, 20) +
|
|
||||||
"-" +
|
|
||||||
hex.slice(20)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateSessionId() {
|
|
||||||
var storageKey = "langbot_embed_session_" + CONFIG.botUuid;
|
|
||||||
try {
|
|
||||||
var stored = window.sessionStorage.getItem(storageKey);
|
|
||||||
if (
|
|
||||||
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(
|
|
||||||
stored || "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return stored;
|
|
||||||
var created = createSessionId();
|
|
||||||
window.sessionStorage.setItem(storageKey, created);
|
|
||||||
return created;
|
|
||||||
} catch (e) {
|
|
||||||
return createSessionId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = {
|
var state = {
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
ws: null,
|
ws: null,
|
||||||
connectionId: null,
|
connectionId: null,
|
||||||
sessionId: getOrCreateSessionId(),
|
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
heartbeatTimer: null,
|
heartbeatTimer: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
@@ -364,9 +315,6 @@
|
|||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
streamingMsgId: null,
|
streamingMsgId: null,
|
||||||
historyLoaded: false,
|
historyLoaded: false,
|
||||||
hasConnected: false,
|
|
||||||
messageVersion: 0,
|
|
||||||
historyReloadTimer: null,
|
|
||||||
pendingImage: null,
|
pendingImage: null,
|
||||||
feedbackState: {},
|
feedbackState: {},
|
||||||
};
|
};
|
||||||
@@ -525,9 +473,7 @@
|
|||||||
"/api/v1/embed/" +
|
"/api/v1/embed/" +
|
||||||
CONFIG.botUuid +
|
CONFIG.botUuid +
|
||||||
"/ws/connect?session_type=" +
|
"/ws/connect?session_type=" +
|
||||||
CONFIG.sessionType +
|
CONFIG.sessionType;
|
||||||
"&session_id=" +
|
|
||||||
encodeURIComponent(state.sessionId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.ws = new WebSocket(url);
|
state.ws = new WebSocket(url);
|
||||||
@@ -574,8 +520,6 @@
|
|||||||
case "connected":
|
case "connected":
|
||||||
state.isConnected = true;
|
state.isConnected = true;
|
||||||
state.connectionId = data.connection_id;
|
state.connectionId = data.connection_id;
|
||||||
if (state.hasConnected) loadHistory(true);
|
|
||||||
state.hasConnected = true;
|
|
||||||
updateStatusDot();
|
updateStatusDot();
|
||||||
updateSendBtn();
|
updateSendBtn();
|
||||||
break;
|
break;
|
||||||
@@ -643,7 +587,6 @@
|
|||||||
|
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
state.messages[existingIdx] = msg;
|
state.messages[existingIdx] = msg;
|
||||||
state.messageVersion++;
|
|
||||||
updateMessageEl(existingIdx, msg);
|
updateMessageEl(existingIdx, msg);
|
||||||
} else {
|
} else {
|
||||||
addMessage(msg);
|
addMessage(msg);
|
||||||
@@ -713,27 +656,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== Message History ==========
|
// ========== Message History ==========
|
||||||
function scheduleHistoryReload() {
|
function loadHistory() {
|
||||||
if (state.historyReloadTimer) clearTimeout(state.historyReloadTimer);
|
if (state.historyLoaded) return;
|
||||||
state.historyReloadTimer = setTimeout(function () {
|
|
||||||
state.historyReloadTimer = null;
|
|
||||||
loadHistory(true);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadHistory(force) {
|
|
||||||
if (state.historyLoaded && !force) return;
|
|
||||||
state.historyLoaded = true;
|
state.historyLoaded = true;
|
||||||
var messageVersion = state.messageVersion;
|
|
||||||
|
|
||||||
var url =
|
var url =
|
||||||
CONFIG.baseUrl +
|
CONFIG.baseUrl +
|
||||||
"/api/v1/embed/" +
|
"/api/v1/embed/" +
|
||||||
CONFIG.botUuid +
|
CONFIG.botUuid +
|
||||||
"/messages/" +
|
"/messages/" +
|
||||||
CONFIG.sessionType +
|
CONFIG.sessionType;
|
||||||
"?session_id=" +
|
|
||||||
encodeURIComponent(state.sessionId);
|
|
||||||
var headers = {};
|
var headers = {};
|
||||||
if (state.sessionToken)
|
if (state.sessionToken)
|
||||||
headers["Authorization"] = "Bearer " + state.sessionToken;
|
headers["Authorization"] = "Bearer " + state.sessionToken;
|
||||||
@@ -743,16 +675,6 @@
|
|||||||
})
|
})
|
||||||
.then(function (json) {
|
.then(function (json) {
|
||||||
if (json.code === 0 && json.data && json.data.messages) {
|
if (json.code === 0 && json.data && json.data.messages) {
|
||||||
if (force && messageVersion !== state.messageVersion) {
|
|
||||||
scheduleHistoryReload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (force) {
|
|
||||||
state.messages = [];
|
|
||||||
state.isStreaming = false;
|
|
||||||
state.streamingMsgId = null;
|
|
||||||
renderMessages();
|
|
||||||
}
|
|
||||||
var msgs = json.data.messages;
|
var msgs = json.data.messages;
|
||||||
for (var i = 0; i < msgs.length; i++) {
|
for (var i = 0; i < msgs.length; i++) {
|
||||||
addMessage(msgs[i], true);
|
addMessage(msgs[i], true);
|
||||||
@@ -771,16 +693,13 @@
|
|||||||
"/api/v1/embed/" +
|
"/api/v1/embed/" +
|
||||||
CONFIG.botUuid +
|
CONFIG.botUuid +
|
||||||
"/reset/" +
|
"/reset/" +
|
||||||
CONFIG.sessionType +
|
CONFIG.sessionType;
|
||||||
"?session_id=" +
|
|
||||||
encodeURIComponent(state.sessionId);
|
|
||||||
var headers = {};
|
var headers = {};
|
||||||
if (state.sessionToken)
|
if (state.sessionToken)
|
||||||
headers["Authorization"] = "Bearer " + state.sessionToken;
|
headers["Authorization"] = "Bearer " + state.sessionToken;
|
||||||
fetch(url, { method: "POST", headers: headers })
|
fetch(url, { method: "POST", headers: headers })
|
||||||
.then(function () {
|
.then(function () {
|
||||||
state.messages = [];
|
state.messages = [];
|
||||||
state.messageVersion++;
|
|
||||||
state.isStreaming = false;
|
state.isStreaming = false;
|
||||||
state.streamingMsgId = null;
|
state.streamingMsgId = null;
|
||||||
state.historyLoaded = true;
|
state.historyLoaded = true;
|
||||||
@@ -794,7 +713,6 @@
|
|||||||
// ========== UI Rendering ==========
|
// ========== UI Rendering ==========
|
||||||
function addMessage(msg, silent) {
|
function addMessage(msg, silent) {
|
||||||
state.messages.push(msg);
|
state.messages.push(msg);
|
||||||
if (!silent) state.messageVersion++;
|
|
||||||
var el = createMessageEl(msg);
|
var el = createMessageEl(msg);
|
||||||
if (els.welcome) {
|
if (els.welcome) {
|
||||||
els.welcome.style.display = "none";
|
els.welcome.style.display = "none";
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from tests.factories import FakeApp
|
|||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
SESSION_ID = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module')
|
@pytest.fixture(scope='module')
|
||||||
@@ -192,10 +191,10 @@ class TestEmbedMessagesEndpoint:
|
|||||||
"""Tests for messages endpoint."""
|
"""Tests for messages endpoint."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_messages_person_success(self, quart_test_client, fake_embed_app):
|
async def test_get_messages_person_success(self, quart_test_client):
|
||||||
"""GET messages/person returns messages."""
|
"""GET messages/person returns messages."""
|
||||||
response = await quart_test_client.get(
|
response = await quart_test_client.get(
|
||||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person?session_id={SESSION_ID}',
|
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
|
||||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -203,30 +202,17 @@ class TestEmbedMessagesEndpoint:
|
|||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data['code'] == 0
|
assert data['code'] == 0
|
||||||
assert 'messages' in data['data']
|
assert 'messages' in data['data']
|
||||||
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.get_websocket_messages.assert_called_with(
|
|
||||||
'test-pipeline-uuid', 'person', SESSION_ID
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_messages_group_success(self, quart_test_client):
|
async def test_get_messages_group_success(self, quart_test_client):
|
||||||
"""GET messages/group returns messages."""
|
"""GET messages/group returns messages."""
|
||||||
response = await quart_test_client.get(
|
response = await quart_test_client.get(
|
||||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}',
|
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group',
|
||||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_messages_requires_session_id(self, quart_test_client):
|
|
||||||
"""GET messages without a client session identifier returns 400."""
|
|
||||||
response = await quart_test_client.get(
|
|
||||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
|
|
||||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_messages_invalid_session_type(self, quart_test_client):
|
async def test_get_messages_invalid_session_type(self, quart_test_client):
|
||||||
"""GET messages with invalid session_type returns 400."""
|
"""GET messages with invalid session_type returns 400."""
|
||||||
@@ -243,29 +229,16 @@ class TestEmbedResetEndpoint:
|
|||||||
"""Tests for session reset endpoint."""
|
"""Tests for session reset endpoint."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reset_session_person_success(self, quart_test_client, fake_embed_app):
|
async def test_reset_session_person_success(self, quart_test_client):
|
||||||
"""POST reset/person resets session."""
|
"""POST reset/person resets session."""
|
||||||
response = await quart_test_client.post(
|
response = await quart_test_client.post(
|
||||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person?session_id={SESSION_ID}',
|
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
|
||||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data['code'] == 0
|
assert data['code'] == 0
|
||||||
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.reset_session.assert_called_with(
|
|
||||||
'test-pipeline-uuid', 'person', SESSION_ID
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reset_session_requires_session_id(self, quart_test_client):
|
|
||||||
"""POST reset without a client session identifier returns 400."""
|
|
||||||
response = await quart_test_client.post(
|
|
||||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
|
|
||||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reset_session_invalid_uuid(self, quart_test_client):
|
async def test_reset_session_invalid_uuid(self, quart_test_client):
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import pytest
|
|||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
||||||
from langbot.pkg.platform.sources.aiocqhttp import (
|
from langbot.pkg.platform.sources.aiocqhttp import (
|
||||||
AiocqhttpAdapter,
|
AiocqhttpAdapter,
|
||||||
AiocqhttpEventConverter,
|
AiocqhttpEventConverter,
|
||||||
@@ -16,72 +15,6 @@ async def _convert_single(component: platform_message.MessageComponent):
|
|||||||
return message[0]
|
return message[0]
|
||||||
|
|
||||||
|
|
||||||
class _TestLogger:
|
|
||||||
def __init__(self):
|
|
||||||
self.messages = []
|
|
||||||
|
|
||||||
async def info(self, message):
|
|
||||||
self.messages.append(message)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_adapter():
|
|
||||||
logger = _TestLogger()
|
|
||||||
adapter = AiocqhttpAdapter.model_construct(
|
|
||||||
config={},
|
|
||||||
logger=logger,
|
|
||||||
bot=aiocqhttp.CQHttp(),
|
|
||||||
on_websocket_connection_event_cache=[],
|
|
||||||
_listener_wrappers={},
|
|
||||||
)
|
|
||||||
adapter.bot.on_websocket_connection(adapter._on_websocket_connection)
|
|
||||||
return adapter, logger
|
|
||||||
|
|
||||||
|
|
||||||
def test_connection_listener_is_registered_once_for_multiple_message_listeners():
|
|
||||||
adapter, _ = _make_adapter()
|
|
||||||
|
|
||||||
async def callback(event, source_adapter):
|
|
||||||
return None
|
|
||||||
|
|
||||||
adapter.register_listener(platform_events.FriendMessage, callback)
|
|
||||||
adapter.register_listener(platform_events.GroupMessage, callback)
|
|
||||||
adapter.register_listener(platform_events.FeedbackEvent, callback)
|
|
||||||
|
|
||||||
assert len(adapter.bot._bus._subscribers['meta_event.lifecycle.connect']) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connection_listener_only_suppresses_exact_duplicates():
|
|
||||||
adapter, logger = _make_adapter()
|
|
||||||
first = aiocqhttp.Event({'self_id': 1001, 'time': 10})
|
|
||||||
duplicate = aiocqhttp.Event({'self_id': 1001, 'time': 10})
|
|
||||||
second = aiocqhttp.Event({'self_id': 2002, 'time': 20})
|
|
||||||
|
|
||||||
await adapter._on_websocket_connection(first)
|
|
||||||
await adapter._on_websocket_connection(duplicate)
|
|
||||||
await adapter._on_websocket_connection(second)
|
|
||||||
|
|
||||||
assert adapter.on_websocket_connection_event_cache == [first, second]
|
|
||||||
assert logger.messages == [
|
|
||||||
'WebSocket connection established, bot id: 1001',
|
|
||||||
'WebSocket connection established, bot id: 2002',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unregister_listener_removes_registered_wrapper():
|
|
||||||
adapter, _ = _make_adapter()
|
|
||||||
|
|
||||||
async def callback(event, source_adapter):
|
|
||||||
return None
|
|
||||||
|
|
||||||
adapter.register_listener(platform_events.GroupMessage, callback)
|
|
||||||
assert len(adapter.bot._bus._subscribers['message.group']) == 1
|
|
||||||
|
|
||||||
adapter.unregister_listener(platform_events.GroupMessage, callback)
|
|
||||||
|
|
||||||
assert not adapter.bot._bus._subscribers['message.group']
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
('payload', 'expected'),
|
('payload', 'expected'),
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
"""Tests for DingTalk adapter helper behavior."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from langbot.pkg.platform.sources.dingtalk import (
|
|
||||||
DingTalkAdapter,
|
|
||||||
_dingtalk_card_markdown,
|
|
||||||
_dingtalk_clean_form_content,
|
|
||||||
_dingtalk_completed_input_lines,
|
|
||||||
_dingtalk_extract_component_inputs,
|
|
||||||
_dingtalk_form_component_params,
|
|
||||||
_dingtalk_missing_completed_input_lines,
|
|
||||||
_dingtalk_pending_input_defs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_select_component_params_expose_options():
|
|
||||||
params = _dingtalk_form_component_params(
|
|
||||||
{
|
|
||||||
'_current_input_field': 'choice',
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'inputs': {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert params['select_visible'] == 'true'
|
|
||||||
assert params['select_placeholder'] == 'choice'
|
|
||||||
assert params['select_options'] == ['A', 'B']
|
|
||||||
assert [option['value'] for option in params['index_o']] == ['A', 'B']
|
|
||||||
assert [option['value'] for option in params['test_index']] == ['A', 'B']
|
|
||||||
assert params['index_o'][0]['text']['zh_CN'] == 'A'
|
|
||||||
assert params['index_o'][0]['text']['en_US'] == 'A'
|
|
||||||
assert params['select_index'] == -1
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_extract_select_from_builtin_result_dict():
|
|
||||||
inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}})
|
|
||||||
|
|
||||||
assert inputs == {'select': 'B'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_extract_select_from_template_param_string():
|
|
||||||
inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'})
|
|
||||||
|
|
||||||
assert inputs == {'select': '{"index": 1, "value": "B"}'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_extract_input_and_select_together():
|
|
||||||
inputs = _dingtalk_extract_component_inputs(
|
|
||||||
{
|
|
||||||
'inputResult': {'value': 'looks good'},
|
|
||||||
'__built_in_selectResult__': {'index': 0, 'value': 'A'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert inputs == {'input': 'looks good', 'select': 'A'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_extract_component_inputs_strips_card_line_endings():
|
|
||||||
inputs = _dingtalk_extract_component_inputs(
|
|
||||||
{
|
|
||||||
'inputResult': {'value': '回复我测试\r\n'},
|
|
||||||
'selectResult': {'value': '1\r'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert inputs == {'input': '回复我测试', 'select': '1'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_pending_input_defs_includes_file_fields():
|
|
||||||
pending = _dingtalk_pending_input_defs(
|
|
||||||
{
|
|
||||||
'input_defs': [
|
|
||||||
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'files', 'type': 'file-list'},
|
|
||||||
],
|
|
||||||
'inputs': {'comment': 'ready'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [field['output_variable_name'] for field in pending] == ['files']
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_completed_input_lines_include_text_and_select_values():
|
|
||||||
lines = _dingtalk_completed_input_lines(
|
|
||||||
{
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'inputs': {'comment': 'looks good', 'choice': 'B'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert lines == ['✅ comment:looks good', '✅ choice:B']
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_completed_inputs_are_not_repeated_when_already_interleaved():
|
|
||||||
form_data = {
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
|
||||||
],
|
|
||||||
'inputs': {'us_input': '回复我测试\r', 'xiala': '1'},
|
|
||||||
}
|
|
||||||
form_content = '你好\n请输入你的问题\n✅ us_input:回复我测试\n请选择你的答案\n✅ xiala:1'
|
|
||||||
|
|
||||||
assert _dingtalk_missing_completed_input_lines(form_data, form_content) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_completed_inputs_are_appended_when_template_does_not_render_them():
|
|
||||||
form_data = {
|
|
||||||
'all_input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
|
|
||||||
'inputs': {'comment': 'ready'},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert _dingtalk_missing_completed_input_lines(form_data, 'Please review') == ['✅ comment:ready']
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_clean_form_content_uses_all_input_defs():
|
|
||||||
content = _dingtalk_clean_form_content(
|
|
||||||
{
|
|
||||||
'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
|
|
||||||
'input_defs': [],
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'choice', 'type': 'select'},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == 'Hello'
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_field_stage_keeps_prior_prompts_and_completed_values():
|
|
||||||
content = _dingtalk_clean_form_content(
|
|
||||||
{
|
|
||||||
'_current_input_field': 'choice',
|
|
||||||
'raw_form_content': ('Question\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
|
|
||||||
'form_content': 'Choose an answer',
|
|
||||||
'input_defs': [
|
|
||||||
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'choice', 'type': 'select'},
|
|
||||||
],
|
|
||||||
'inputs': {'comment': 'hello'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert '{{#$output.' not in content
|
|
||||||
assert content.index('Question') < content.index('comment')
|
|
||||||
assert content.index('comment') < content.index('Choose an answer')
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_final_action_stage_interleaves_prompts_and_completed_values():
|
|
||||||
content = _dingtalk_clean_form_content(
|
|
||||||
{
|
|
||||||
'_action_select_only': True,
|
|
||||||
'raw_form_content': ('11\nQuestion\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'comment', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'choice', 'type': 'select'},
|
|
||||||
],
|
|
||||||
'inputs': {'comment': 'hello', 'choice': 'B'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert '{{#$output.' not in content
|
|
||||||
assert content.startswith('11\nQuestion')
|
|
||||||
assert content.index('Question') < content.index('comment')
|
|
||||||
assert content.index('comment') < content.index('Choose an answer')
|
|
||||||
assert content.index('Choose an answer') < content.index('choice')
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_card_markdown_preserves_internal_line_breaks():
|
|
||||||
assert _dingtalk_card_markdown('11\nQuestion\nCompleted') == '11<br>Question<br>Completed'
|
|
||||||
|
|
||||||
|
|
||||||
def _build_card_action_adapter() -> DingTalkAdapter:
|
|
||||||
adapter = DingTalkAdapter.model_construct(
|
|
||||||
card_state={
|
|
||||||
'card-1': {
|
|
||||||
'session_key': 'group_group-1',
|
|
||||||
'launcher_type': 'group',
|
|
||||||
'launcher_id': 'group-1',
|
|
||||||
'sender_user_id': 'initiator-1',
|
|
||||||
'form_token': 'token-1',
|
|
||||||
'workflow_run_id': 'run-1',
|
|
||||||
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
|
||||||
'node_title': 'Review',
|
|
||||||
'form_content': 'Please review',
|
|
||||||
'input_defs': [],
|
|
||||||
'inputs': {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
active_turn_card={},
|
|
||||||
active_turn_text={},
|
|
||||||
)
|
|
||||||
adapter.logger = AsyncMock()
|
|
||||||
adapter.ap = MagicMock()
|
|
||||||
adapter.ap.platform_mgr.bots = []
|
|
||||||
adapter.ap.query_pool.add_query = AsyncMock()
|
|
||||||
return adapter
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dingtalk_group_card_action_uses_clicker_as_sender():
|
|
||||||
adapter = _build_card_action_adapter()
|
|
||||||
|
|
||||||
with patch.object(DingTalkAdapter, '_mark_card_resolved', new=AsyncMock()) as mark_resolved:
|
|
||||||
await adapter._on_card_action(
|
|
||||||
{
|
|
||||||
'out_track_id': 'card-1',
|
|
||||||
'user_id': 'reviewer-2',
|
|
||||||
'action_id': 'approve',
|
|
||||||
'params': {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
call = adapter.ap.query_pool.add_query.await_args
|
|
||||||
assert call.kwargs['launcher_id'] == 'group-1'
|
|
||||||
assert call.kwargs['sender_id'] == 'reviewer-2'
|
|
||||||
assert call.kwargs['message_event'].sender.id == 'reviewer-2'
|
|
||||||
mark_resolved.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dingtalk_unknown_card_action_is_rejected():
|
|
||||||
adapter = _build_card_action_adapter()
|
|
||||||
|
|
||||||
await adapter._on_card_action(
|
|
||||||
{
|
|
||||||
'out_track_id': 'card-1',
|
|
||||||
'user_id': 'reviewer-2',
|
|
||||||
'action_id': 'not-on-card',
|
|
||||||
'params': {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
adapter.ap.query_pool.add_query.assert_not_awaited()
|
|
||||||
adapter.logger.warning.assert_awaited_once()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Tests for DingTalk API payload helpers."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
|
|
||||||
params = _stringify_card_param_map(
|
|
||||||
{
|
|
||||||
'content': 'Pick one',
|
|
||||||
'btns': json.dumps([{'text': 'OK'}], ensure_ascii=False),
|
|
||||||
'select_options': ['A', 'B'],
|
|
||||||
'index_o': [
|
|
||||||
{
|
|
||||||
'value': 'A',
|
|
||||||
'text': {'zh_CN': 'A', 'en_US': 'A'},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'test_index': [
|
|
||||||
{
|
|
||||||
'value': 'A',
|
|
||||||
'text': {'zh_CN': 'A', 'en_US': 'A'},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'select_index': -1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert params['content'] == 'Pick one'
|
|
||||||
assert params['btns'] == '[{"text": "OK"}]'
|
|
||||||
assert params['select_options'] == '["A", "B"]'
|
|
||||||
assert json.loads(params['index_o'])[0]['value'] == 'A'
|
|
||||||
assert json.loads(params['test_index'])[0]['value'] == 'A'
|
|
||||||
assert params['select_index'] == '-1'
|
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_card_param_map_stringifies_unregistered_structures():
|
|
||||||
params = _stringify_card_param_map({'other': ['A'], 'empty': None})
|
|
||||||
|
|
||||||
assert params['other'] == '["A"]'
|
|
||||||
assert params['empty'] == ''
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""Tests for Lark adapter helper behavior."""
|
|
||||||
|
|
||||||
from langbot.pkg.platform.sources.lark import (
|
|
||||||
LarkAdapter,
|
|
||||||
_lark_clean_form_content,
|
|
||||||
_lark_completed_input_lines,
|
|
||||||
_lark_current_input_defs,
|
|
||||||
_lark_extract_action_form_inputs,
|
|
||||||
_lark_should_update_stream_element,
|
|
||||||
_lark_visible_form_content,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_current_input_defs_only_returns_active_stage():
|
|
||||||
input_defs = [
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
|
||||||
]
|
|
||||||
|
|
||||||
assert _lark_current_input_defs(
|
|
||||||
{
|
|
||||||
'_current_input_field': 'xiala',
|
|
||||||
'input_defs': input_defs,
|
|
||||||
}
|
|
||||||
) == [input_defs[1]]
|
|
||||||
assert (
|
|
||||||
_lark_current_input_defs(
|
|
||||||
{
|
|
||||||
'_action_select_only': True,
|
|
||||||
'input_defs': input_defs,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
== []
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_form_field_elements_only_render_active_stage():
|
|
||||||
adapter = LarkAdapter.model_construct()
|
|
||||||
form_data = {
|
|
||||||
'_current_input_field': 'xiala',
|
|
||||||
'input_defs': [
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{
|
|
||||||
'output_variable_name': 'xiala',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
elements, input_name_map, file_help_lines = adapter._build_lark_form_field_elements(form_data)
|
|
||||||
|
|
||||||
assert len(elements) == 1
|
|
||||||
assert elements[0]['tag'] == 'select_static'
|
|
||||||
assert elements[0]['label']['content'] == 'xiala'
|
|
||||||
assert list(input_name_map.values()) == ['xiala']
|
|
||||||
assert file_help_lines == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_form_stage_skips_closed_streaming_element_update():
|
|
||||||
assert not _lark_should_update_stream_element(
|
|
||||||
resume_from=False,
|
|
||||||
form_data={'_current_input_field': 'xiala'},
|
|
||||||
msg_seq=1,
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
assert _lark_should_update_stream_element(
|
|
||||||
resume_from=False,
|
|
||||||
form_data=None,
|
|
||||||
msg_seq=1,
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_final_action_stage_interleaves_prompts_and_completed_values():
|
|
||||||
form_content = _lark_visible_form_content(
|
|
||||||
{
|
|
||||||
'_action_select_only': True,
|
|
||||||
'raw_form_content': ('11\nQuestion\n{{#$output.us_input#}}\nChoose an answer\n{{#$output.xiala#}}\n'),
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
|
||||||
],
|
|
||||||
'inputs': {'us_input': 'hello', 'xiala': '2'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert '{{#$output.' not in form_content
|
|
||||||
assert form_content.startswith('11\nQuestion')
|
|
||||||
assert form_content.index('Question') < form_content.index('us_input')
|
|
||||||
assert form_content.index('us_input') < form_content.index('Choose an answer')
|
|
||||||
assert form_content.index('Choose an answer') < form_content.index('xiala')
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_completed_input_lines_include_text_select_and_files():
|
|
||||||
lines = _lark_completed_input_lines(
|
|
||||||
{
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
|
||||||
{'output_variable_name': 'files', 'type': 'file-list'},
|
|
||||||
],
|
|
||||||
'inputs': {
|
|
||||||
'us_input': '你好',
|
|
||||||
'xiala': 'or',
|
|
||||||
'files': [{'upload_file_id': 'file-1'}, {'upload_file_id': 'file-2'}],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert lines == [
|
|
||||||
'✅ us_input:你好',
|
|
||||||
'✅ xiala:or',
|
|
||||||
'✅ files:2 file(s)',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_clean_form_content_removes_all_input_placeholders():
|
|
||||||
content = _lark_clean_form_content(
|
|
||||||
'人工介入\n\n{{#$output.us_input#}}\n\n{{#$output.xiala#}}\n',
|
|
||||||
[
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == '人工介入'
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_extract_action_form_inputs_from_json_form_value():
|
|
||||||
class Action:
|
|
||||||
form_value = '{"Input_1_us_input_abcd12": "hello", "Select_2_xiala_abcd12": "B"}'
|
|
||||||
input_value = None
|
|
||||||
option = None
|
|
||||||
name = None
|
|
||||||
|
|
||||||
inputs = _lark_extract_action_form_inputs(
|
|
||||||
Action(),
|
|
||||||
{
|
|
||||||
'input_name_map': {
|
|
||||||
'Input_1_us_input_abcd12': 'us_input',
|
|
||||||
'Select_2_xiala_abcd12': 'xiala',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert inputs == {'us_input': 'hello', 'xiala': 'B'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_extract_action_form_inputs_from_webhook_dict_action():
|
|
||||||
inputs = _lark_extract_action_form_inputs(
|
|
||||||
{
|
|
||||||
'form_value': {
|
|
||||||
'Input_1_us_input_abcd12': 'hello',
|
|
||||||
'Select_2_xiala_abcd12': {'value': 'B', 'text': {'content': 'Option B'}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'input_name_map': {
|
|
||||||
'Input_1_us_input_abcd12': 'us_input',
|
|
||||||
'Select_2_xiala_abcd12': 'xiala',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert inputs == {'us_input': 'hello', 'xiala': {'value': 'B', 'text': {'content': 'Option B'}}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_extract_action_form_inputs_maps_dotted_component_names():
|
|
||||||
inputs = _lark_extract_action_form_inputs(
|
|
||||||
{
|
|
||||||
'form_value': {
|
|
||||||
'Form_1_token_abcd12.Input_1_us_input_abcd12': 'hello',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'input_name_map': {
|
|
||||||
'Input_1_us_input_abcd12': 'us_input',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert inputs == {'us_input': 'hello'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_lark_completed_input_lines_display_select_value_from_object():
|
|
||||||
lines = _lark_completed_input_lines(
|
|
||||||
{
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
|
||||||
],
|
|
||||||
'inputs': {'xiala': {'value': 'B', 'text': {'content': 'Option B'}}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert lines == ['✅ xiala:B']
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from langbot.pkg.platform.sources.lark import NonBlockingLarkWSClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lark_connection_url_lookup_does_not_block_main_event_loop(monkeypatch):
|
|
||||||
client = NonBlockingLarkWSClient('app-id', 'app-secret')
|
|
||||||
lookup_started = threading.Event()
|
|
||||||
release_lookup = threading.Event()
|
|
||||||
base_connect_urls: list[str] = []
|
|
||||||
|
|
||||||
def blocking_get_conn_url() -> str:
|
|
||||||
lookup_started.set()
|
|
||||||
if not release_lookup.wait(timeout=2):
|
|
||||||
raise TimeoutError('test did not release the connection lookup')
|
|
||||||
return 'wss://example.invalid/connect?device_id=device&service_id=service'
|
|
||||||
|
|
||||||
async def fake_base_connect(self):
|
|
||||||
base_connect_urls.append(self._get_conn_url())
|
|
||||||
|
|
||||||
monkeypatch.setattr(client, '_get_conn_url', blocking_get_conn_url)
|
|
||||||
monkeypatch.setattr('lark_oapi.ws.Client._connect', fake_base_connect)
|
|
||||||
|
|
||||||
connect_task = asyncio.create_task(client._connect())
|
|
||||||
await asyncio.wait_for(asyncio.to_thread(lookup_started.wait, 1), timeout=1)
|
|
||||||
|
|
||||||
# If the SDK's synchronous requests.post still runs on the event-loop
|
|
||||||
# thread, this sleep cannot complete until release_lookup is set.
|
|
||||||
await asyncio.wait_for(asyncio.sleep(0.01), timeout=0.1)
|
|
||||||
assert not connect_task.done()
|
|
||||||
|
|
||||||
release_lookup.set()
|
|
||||||
await asyncio.wait_for(connect_task, timeout=1)
|
|
||||||
|
|
||||||
assert base_connect_urls == ['wss://example.invalid/connect?device_id=device&service_id=service']
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
"""Tests for QQ Official keyboard payload helpers."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
|
|
||||||
from langbot.libs.qq_official_api.api import (
|
|
||||||
QQ_SELECT_ACTION_PREFIX,
|
|
||||||
build_keyboard_from_select_field,
|
|
||||||
get_select_field_options,
|
|
||||||
resolve_select_button_action,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _select_form_data() -> dict:
|
|
||||||
return {
|
|
||||||
'_current_input_field': 'choice',
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_qq_select_field_builds_callback_buttons():
|
|
||||||
keyboard = build_keyboard_from_select_field(_select_form_data(), buttons_per_row=2)
|
|
||||||
|
|
||||||
rows = keyboard['content']['rows']
|
|
||||||
assert [[button['render_data']['label'] for button in row['buttons']] for row in rows] == [
|
|
||||||
['A', 'B'],
|
|
||||||
['C'],
|
|
||||||
]
|
|
||||||
assert rows[0]['buttons'][0]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}0'
|
|
||||||
assert rows[0]['buttons'][1]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}1'
|
|
||||||
|
|
||||||
|
|
||||||
def test_qq_select_button_resolves_field_and_value():
|
|
||||||
form_data = _select_form_data()
|
|
||||||
|
|
||||||
assert get_select_field_options(form_data) == ('choice', ['A', 'B', 'C'])
|
|
||||||
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}1') == ('choice', 'B')
|
|
||||||
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_qq_select_keyboard_fits_twenty_five_options():
|
|
||||||
form_data = _select_form_data()
|
|
||||||
form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)]
|
|
||||||
|
|
||||||
rows = build_keyboard_from_select_field(form_data)['content']['rows']
|
|
||||||
|
|
||||||
assert len(rows) == 5
|
|
||||||
assert all(len(row['buttons']) == 5 for row in rows)
|
|
||||||
|
|
||||||
|
|
||||||
def test_qq_non_select_field_does_not_build_keyboard():
|
|
||||||
form_data = {
|
|
||||||
'_current_input_field': 'comment',
|
|
||||||
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
|
|
||||||
}
|
|
||||||
|
|
||||||
assert build_keyboard_from_select_field(form_data)['content']['rows'] == []
|
|
||||||
|
|
||||||
|
|
||||||
def _stream_test_adapter():
|
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
|
||||||
|
|
||||||
adapter = QQOfficialAdapter.model_construct()
|
|
||||||
adapter.logger = AsyncMock()
|
|
||||||
adapter.bot = MagicMock()
|
|
||||||
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
|
||||||
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
|
|
||||||
adapter.ap = None
|
|
||||||
adapter._stream_ctx = {}
|
|
||||||
adapter._stream_ctx_ts = {}
|
|
||||||
adapter._fallback_text = {}
|
|
||||||
adapter._fallback_text_ts = {}
|
|
||||||
return adapter
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
|
||||||
adapter = _stream_test_adapter()
|
|
||||||
adapter._stream_ctx['message-1'] = {
|
|
||||||
'user_openid': 'user-1',
|
|
||||||
'msg_id': 'source-1',
|
|
||||||
'stream_msg_id': None,
|
|
||||||
'msg_seq': 1,
|
|
||||||
'index': 0,
|
|
||||||
'last_update_ts': 0,
|
|
||||||
'accumulated_text': '',
|
|
||||||
'sent_length': 0,
|
|
||||||
'session_started': False,
|
|
||||||
}
|
|
||||||
adapter._stream_ctx_ts['message-1'] = time.time()
|
|
||||||
source = MagicMock()
|
|
||||||
|
|
||||||
await adapter.reply_message_chunk(
|
|
||||||
source,
|
|
||||||
{'resp_message_id': 'message-1'},
|
|
||||||
platform_message.MessageChain([platform_message.Plain(text='<think>one')]),
|
|
||||||
)
|
|
||||||
await adapter.reply_message_chunk(
|
|
||||||
source,
|
|
||||||
{'resp_message_id': 'message-1'},
|
|
||||||
platform_message.MessageChain([platform_message.Plain(text='<think>one two')]),
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
|
||||||
'<think>one',
|
|
||||||
' two',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
|
||||||
|
|
||||||
adapter = _stream_test_adapter()
|
|
||||||
source = MagicMock()
|
|
||||||
|
|
||||||
with patch.object(QQOfficialAdapter, 'reply_message', new=AsyncMock()) as reply_message:
|
|
||||||
await adapter.reply_message_chunk(
|
|
||||||
source,
|
|
||||||
{'resp_message_id': 'message-1'},
|
|
||||||
platform_message.MessageChain([platform_message.Plain(text='Hel')]),
|
|
||||||
)
|
|
||||||
await adapter.reply_message_chunk(
|
|
||||||
source,
|
|
||||||
{'resp_message_id': 'message-1'},
|
|
||||||
platform_message.MessageChain([platform_message.Plain(text='Hello')]),
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
sent_chain = reply_message.await_args.args[1]
|
|
||||||
assert str(sent_chain) == 'Hello'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_qq_text_field_prompt_keeps_form_content():
|
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
|
||||||
|
|
||||||
adapter = _stream_test_adapter()
|
|
||||||
adapter._pending_forms = {}
|
|
||||||
adapter._session_event_ids = {}
|
|
||||||
adapter._anchor_msg_seq = {}
|
|
||||||
source = MagicMock()
|
|
||||||
source.d_id = 'source-1'
|
|
||||||
source.t = 'C2C_MESSAGE_CREATE'
|
|
||||||
event = MagicMock()
|
|
||||||
event.source_platform_object = source
|
|
||||||
event.sender.id = 'user-1'
|
|
||||||
form_data = {
|
|
||||||
'_current_input_field': 'us_input',
|
|
||||||
'node_title': 'Manual input',
|
|
||||||
'form_content': '1234\nEnter your question',
|
|
||||||
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
|
|
||||||
'actions': [{'id': 'yes', 'title': 'yes'}],
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(QQOfficialAdapter, '_resolve_target_from_event', return_value=('c2c', 'user-1')):
|
|
||||||
await adapter._handle_form_chunk(event, platform_message.MessageChain([]), form_data)
|
|
||||||
|
|
||||||
send_call = adapter.bot.send_markdown_keyboard.await_args.kwargs
|
|
||||||
assert send_call['markdown_content'] == '### Manual input\n\n1234\nEnter your question'
|
|
||||||
assert send_call['keyboard'] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_qq_select_click_enqueues_input_progress_query():
|
|
||||||
import langbot.pkg.core.app # noqa: F401
|
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
|
||||||
|
|
||||||
adapter = QQOfficialAdapter.model_construct()
|
|
||||||
adapter.logger = AsyncMock()
|
|
||||||
adapter.bot = MagicMock()
|
|
||||||
adapter.bot.ack_interaction = AsyncMock()
|
|
||||||
adapter.ap = MagicMock()
|
|
||||||
adapter.ap.platform_mgr.bots = []
|
|
||||||
adapter.ap.query_pool.add_query = AsyncMock()
|
|
||||||
adapter._pending_forms = {
|
|
||||||
'group_group-1': {
|
|
||||||
'form_data': {
|
|
||||||
**_select_form_data(),
|
|
||||||
'form_token': 'token-1',
|
|
||||||
'workflow_run_id': 'run-1',
|
|
||||||
'node_title': 'Review',
|
|
||||||
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
|
||||||
},
|
|
||||||
'sender_id': 'initiator-1',
|
|
||||||
'posted_at': time.time(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
adapter._session_event_ids = {}
|
|
||||||
adapter._anchor_msg_seq = {}
|
|
||||||
|
|
||||||
await adapter._handle_interaction_create(
|
|
||||||
{
|
|
||||||
'id': 'interaction-1',
|
|
||||||
'chat_type': 1,
|
|
||||||
'group_openid': 'group-1',
|
|
||||||
'member_openid': 'reviewer-2',
|
|
||||||
'data': {'resolved': {'button_data': f'{QQ_SELECT_ACTION_PREFIX}1'}},
|
|
||||||
},
|
|
||||||
ws_event_id='event-1',
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
call = adapter.ap.query_pool.add_query.await_args
|
|
||||||
form_action = call.kwargs['variables']['_dify_form_action']
|
|
||||||
assert call.kwargs['launcher_id'] == 'group-1'
|
|
||||||
assert call.kwargs['sender_id'] == 'reviewer-2'
|
|
||||||
assert form_action['action_id'] == ''
|
|
||||||
assert form_action['inputs'] == {'select': 'B'}
|
|
||||||
assert form_action['_current_input_field'] == 'choice'
|
|
||||||
assert form_action['_input_progress'] is True
|
|
||||||
adapter.bot.ack_interaction.assert_awaited_once_with('interaction-1', code=0)
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""Tests for Telegram Dify form callback helpers."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from telegram import ForceReply
|
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
from langbot.pkg.platform.sources.telegram import (
|
|
||||||
TelegramAdapter,
|
|
||||||
_telegram_form_action_from_callback,
|
|
||||||
_telegram_select_field_options,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _select_form_data() -> dict:
|
|
||||||
return {
|
|
||||||
'_current_input_field': 'choice',
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_select_field_options_are_extracted():
|
|
||||||
assert _telegram_select_field_options(_select_form_data()) == ('choice', ['A', 'B', 'C'])
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_select_callback_becomes_input_progress():
|
|
||||||
assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == {
|
|
||||||
'action_id': '',
|
|
||||||
'inputs': {'select': {'index': 1}},
|
|
||||||
'_input_progress': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_action_callback_remains_final_action():
|
|
||||||
assert _telegram_form_action_from_callback({'f': 1, 'a': 'approve'}) == {
|
|
||||||
'action_id': 'approve',
|
|
||||||
'inputs': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_invalid_select_callback_is_rejected():
|
|
||||||
assert _telegram_form_action_from_callback({'f': 1, 'x': -1}) is None
|
|
||||||
assert _telegram_form_action_from_callback({'f': 1, 'x': 'invalid'}) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_form_callback_cache_consumes_the_whole_form_group():
|
|
||||||
adapter = TelegramAdapter.model_construct()
|
|
||||||
adapter._form_action_titles = {}
|
|
||||||
adapter._cache_form_action_titles({'callback-a': 'A', 'callback-b': 'B'}, now=100.0)
|
|
||||||
|
|
||||||
assert adapter._take_form_action_title('callback-a', now=101.0) == 'A'
|
|
||||||
assert adapter._take_form_action_title('callback-a', now=101.0) is None
|
|
||||||
assert adapter._take_form_action_title('callback-b', now=101.0) is None
|
|
||||||
assert adapter._form_action_titles == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_form_callback_cache_prunes_expired_entries():
|
|
||||||
adapter = TelegramAdapter.model_construct()
|
|
||||||
adapter._form_action_titles = {}
|
|
||||||
adapter._cache_form_action_titles({'callback-a': 'A'}, now=100.0)
|
|
||||||
|
|
||||||
assert adapter._take_form_action_title('callback-a', now=100.0 + adapter._FORM_ACTION_CACHE_TTL) is None
|
|
||||||
assert adapter._form_action_titles == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_telegram_form_callback_cache_preserves_pipeline_uuid():
|
|
||||||
adapter = TelegramAdapter.model_construct()
|
|
||||||
adapter._form_action_titles = {}
|
|
||||||
adapter._cache_form_action_titles(
|
|
||||||
{'callback-a': 'Approve'},
|
|
||||||
pipeline_uuid='pipeline-routed',
|
|
||||||
now=100.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert adapter._take_form_action_context('callback-a', now=101.0) == (
|
|
||||||
'Approve',
|
|
||||||
'pipeline-routed',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_telegram_select_field_sends_two_column_inline_keyboard():
|
|
||||||
bot = MagicMock()
|
|
||||||
bot.send_message = AsyncMock()
|
|
||||||
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
|
||||||
adapter._form_action_titles = {}
|
|
||||||
|
|
||||||
update = MagicMock()
|
|
||||||
update.effective_chat.id = 123
|
|
||||||
update.effective_message.message_thread_id = None
|
|
||||||
event = platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
|
||||||
message_chain=platform_message.MessageChain([]),
|
|
||||||
source_platform_object=update,
|
|
||||||
)
|
|
||||||
form_data = {
|
|
||||||
**_select_form_data(),
|
|
||||||
'node_title': 'Review',
|
|
||||||
'form_content': 'Choose one',
|
|
||||||
'workflow_run_id': 'workflow-run-12345678',
|
|
||||||
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
|
||||||
}
|
|
||||||
|
|
||||||
await adapter._send_form_action_buttons(event, form_data)
|
|
||||||
|
|
||||||
args = bot.send_message.await_args.kwargs
|
|
||||||
rows = args['reply_markup'].inline_keyboard
|
|
||||||
assert [[button.text for button in row] for row in rows] == [['A', 'B'], ['C']]
|
|
||||||
callback_data = rows[0][1].callback_data
|
|
||||||
assert len(callback_data.encode('utf-8')) <= 64
|
|
||||||
assert json.loads(callback_data)['x'] == 1
|
|
||||||
assert callback_data in adapter._form_action_titles
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_telegram_text_field_does_not_show_action_buttons():
|
|
||||||
bot = MagicMock()
|
|
||||||
bot.send_message = AsyncMock()
|
|
||||||
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
|
||||||
adapter._form_action_titles = {}
|
|
||||||
|
|
||||||
update = MagicMock()
|
|
||||||
update.effective_chat.id = 123
|
|
||||||
update.effective_message.message_thread_id = None
|
|
||||||
event = platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
|
||||||
message_chain=platform_message.MessageChain([]),
|
|
||||||
source_platform_object=update,
|
|
||||||
)
|
|
||||||
form_data = {
|
|
||||||
'_current_input_field': 'us_input',
|
|
||||||
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
|
|
||||||
'node_title': '人工介入',
|
|
||||||
'form_content': 'us_input (paragraph): reply "us_input: <value>"',
|
|
||||||
'workflow_run_id': 'workflow-run-12345678',
|
|
||||||
'actions': [{'id': 'yes', 'title': 'yes'}, {'id': 'no', 'title': 'no'}],
|
|
||||||
}
|
|
||||||
|
|
||||||
await adapter._send_form_action_buttons(event, form_data)
|
|
||||||
|
|
||||||
args = bot.send_message.await_args.kwargs
|
|
||||||
assert isinstance(args['reply_markup'], ForceReply)
|
|
||||||
assert args['reply_markup'].selective is False
|
|
||||||
assert args['reply_markup'].input_field_placeholder == 'us_input'
|
|
||||||
assert 'Please reply' not in args['text']
|
|
||||||
assert args['text'].startswith('[人工介入]')
|
|
||||||
assert 'us_input (paragraph)' in args['text']
|
|
||||||
assert adapter._form_action_titles == {}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
"""Regression tests for isolated embed-widget conversations."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, Mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
||||||
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
|
|
||||||
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
|
|
||||||
from langbot.pkg.platform.sources.websocket_manager import WebSocketConnectionManager, is_valid_session_id
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_only_reaches_connections_in_same_browser_session():
|
|
||||||
manager = WebSocketConnectionManager()
|
|
||||||
first = await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-1',
|
|
||||||
session_type='person',
|
|
||||||
session_id='session-a',
|
|
||||||
)
|
|
||||||
second = await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-1',
|
|
||||||
session_type='person',
|
|
||||||
session_id='session-b',
|
|
||||||
)
|
|
||||||
dashboard = await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-1',
|
|
||||||
session_type='person',
|
|
||||||
)
|
|
||||||
|
|
||||||
await manager.broadcast_to_pipeline(
|
|
||||||
'pipeline-1',
|
|
||||||
{'type': 'response'},
|
|
||||||
session_type='person',
|
|
||||||
session_id='session-a',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await first.send_queue.get() == {'type': 'response'}
|
|
||||||
assert second.send_queue.empty()
|
|
||||||
assert dashboard.send_queue.empty()
|
|
||||||
|
|
||||||
await manager.broadcast_to_pipeline(
|
|
||||||
'pipeline-1',
|
|
||||||
{'type': 'dashboard-response'},
|
|
||||||
session_type='person',
|
|
||||||
session_id=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await dashboard.send_queue.get() == {'type': 'dashboard-response'}
|
|
||||||
assert first.send_queue.empty()
|
|
||||||
assert second.send_queue.empty()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_embed_event_uses_stable_session_launcher(monkeypatch):
|
|
||||||
manager = WebSocketConnectionManager()
|
|
||||||
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
|
||||||
connection = await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-1',
|
|
||||||
session_type='person',
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
|
||||||
|
|
||||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
|
||||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
|
||||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
|
||||||
received = []
|
|
||||||
|
|
||||||
async def listener(event, _callback_adapter):
|
|
||||||
received.append(event)
|
|
||||||
|
|
||||||
adapter.listeners = {platform_events.FriendMessage: listener}
|
|
||||||
await adapter.handle_websocket_message(
|
|
||||||
connection,
|
|
||||||
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
|
|
||||||
manager = WebSocketConnectionManager()
|
|
||||||
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
|
||||||
connection = await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-1',
|
|
||||||
session_type='group',
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
|
||||||
|
|
||||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
|
||||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
|
||||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
|
||||||
received = []
|
|
||||||
|
|
||||||
async def listener(event, _callback_adapter):
|
|
||||||
received.append(event)
|
|
||||||
|
|
||||||
adapter.listeners = {platform_events.GroupMessage: listener}
|
|
||||||
await adapter.handle_websocket_message(
|
|
||||||
connection,
|
|
||||||
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
|
|
||||||
assert received[0].sender.group.id == f'websocketgroup_pipeline-1:{session_id}'
|
|
||||||
|
|
||||||
dashboard = await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-1',
|
|
||||||
session_type='group',
|
|
||||||
)
|
|
||||||
await adapter.handle_websocket_message(
|
|
||||||
dashboard,
|
|
||||||
{'stream': False, 'message': [{'type': 'Plain', 'text': 'dashboard'}]},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
assert received[1].sender.id == f'websocket_{dashboard.connection_id}'
|
|
||||||
assert received[1].sender.group.id == 'websocketgroup'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch):
|
|
||||||
manager = WebSocketConnectionManager()
|
|
||||||
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
|
||||||
await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-2',
|
|
||||||
session_type='person',
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
connection = await manager.add_connection(
|
|
||||||
websocket=Mock(),
|
|
||||||
pipeline_uuid='pipeline-1',
|
|
||||||
session_type='person',
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
|
||||||
|
|
||||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
|
||||||
message_source = Mock()
|
|
||||||
message_source.sender.id = f'websocket_pipeline-1:{session_id}'
|
|
||||||
|
|
||||||
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
|
|
||||||
assert await adapter._get_connection_from_target(f'websocketgroup_pipeline-1:{session_id}') is connection
|
|
||||||
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is connection
|
|
||||||
|
|
||||||
await manager.remove_connection(connection.connection_id)
|
|
||||||
|
|
||||||
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
|
|
||||||
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_ids_must_be_canonical_random_uuids():
|
|
||||||
assert is_valid_session_id('31c0f2e9-b115-4ee6-8f15-3e624d6456b1')
|
|
||||||
assert not is_valid_session_id('session-a')
|
|
||||||
assert not is_valid_session_id('00000000-0000-0000-0000-000000000000')
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_read_does_not_allocate_unknown_session():
|
|
||||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
|
||||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
|
||||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
|
||||||
|
|
||||||
assert adapter.get_websocket_messages('pipeline-1', 'person', 'missing-session') == []
|
|
||||||
assert adapter.websocket_person_session.message_lists == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_and_reset_are_scoped_to_browser_session():
|
|
||||||
matching_provider_session = Mock(
|
|
||||||
launcher_type=Mock(value='person'),
|
|
||||||
launcher_id='websocket_pipeline-1:session-a',
|
|
||||||
)
|
|
||||||
matching_group_provider_session = Mock(
|
|
||||||
launcher_type=Mock(value='group'),
|
|
||||||
launcher_id='websocketgroup_pipeline-1:session-a',
|
|
||||||
)
|
|
||||||
other_session = Mock(
|
|
||||||
launcher_type=Mock(value='person'),
|
|
||||||
launcher_id='websocket_pipeline-1:session-b',
|
|
||||||
)
|
|
||||||
ap = Mock()
|
|
||||||
ap.sess_mgr.session_list = [
|
|
||||||
matching_provider_session,
|
|
||||||
matching_group_provider_session,
|
|
||||||
other_session,
|
|
||||||
]
|
|
||||||
adapter = WebSocketAdapter.model_construct(
|
|
||||||
ap=ap,
|
|
||||||
logger=AsyncMock(),
|
|
||||||
)
|
|
||||||
adapter.websocket_person_session = Mock()
|
|
||||||
adapter.websocket_group_session = Mock()
|
|
||||||
|
|
||||||
session_a = [
|
|
||||||
WebSocketMessage(
|
|
||||||
id=1,
|
|
||||||
role='user',
|
|
||||||
content='private-a',
|
|
||||||
message_chain=[],
|
|
||||||
timestamp='2026-07-13T00:00:00',
|
|
||||||
)
|
|
||||||
]
|
|
||||||
session_b = [
|
|
||||||
WebSocketMessage(
|
|
||||||
id=1,
|
|
||||||
role='user',
|
|
||||||
content='private-b',
|
|
||||||
message_chain=[],
|
|
||||||
timestamp='2026-07-13T00:00:00',
|
|
||||||
)
|
|
||||||
]
|
|
||||||
histories = {
|
|
||||||
'pipeline-1:session-a': session_a,
|
|
||||||
'pipeline-1:session-b': session_b,
|
|
||||||
}
|
|
||||||
stream_indexes = {
|
|
||||||
'pipeline-1:session-a': {'response-a': 0},
|
|
||||||
'pipeline-1:session-b': {'response-b': 0},
|
|
||||||
}
|
|
||||||
adapter.websocket_person_session.get_message_list.side_effect = histories.__getitem__
|
|
||||||
adapter.websocket_person_session.message_lists = histories
|
|
||||||
adapter.websocket_person_session.stream_message_indexes = stream_indexes
|
|
||||||
adapter.websocket_group_session.message_lists = {}
|
|
||||||
adapter.websocket_group_session.stream_message_indexes = {}
|
|
||||||
|
|
||||||
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-a')[0]['content'] == 'private-a'
|
|
||||||
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-b')[0]['content'] == 'private-b'
|
|
||||||
|
|
||||||
adapter.reset_session('pipeline-1', 'person', 'session-a')
|
|
||||||
|
|
||||||
assert histories['pipeline-1:session-a'] == []
|
|
||||||
assert stream_indexes['pipeline-1:session-a'] == {}
|
|
||||||
assert histories['pipeline-1:session-b'] == session_b
|
|
||||||
assert stream_indexes['pipeline-1:session-b'] == {'response-b': 0}
|
|
||||||
assert ap.sess_mgr.session_list == [matching_group_provider_session, other_session]
|
|
||||||
|
|
||||||
adapter.reset_session('pipeline-1', 'group', 'session-a')
|
|
||||||
|
|
||||||
assert ap.sess_mgr.session_list == [other_session]
|
|
||||||
|
|
||||||
|
|
||||||
def test_widget_sends_stable_session_id_to_all_conversation_endpoints():
|
|
||||||
widget_path = Path(__file__).parents[3] / 'src/langbot/templates/embed/widget.js'
|
|
||||||
widget = widget_path.read_text(encoding='utf-8')
|
|
||||||
|
|
||||||
assert 'langbot_embed_session_' in widget
|
|
||||||
assert 'window.sessionStorage' in widget
|
|
||||||
assert 'window.localStorage' not in widget
|
|
||||||
assert 'session_id=' in widget
|
|
||||||
assert widget.count('encodeURIComponent(state.sessionId)') >= 3
|
|
||||||
assert 'loadHistory(true)' in widget
|
|
||||||
assert 'messageVersion !== state.messageVersion' in widget
|
|
||||||
assert 'scheduleHistoryReload();' in widget
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
import sys
|
|
||||||
import types
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
logger_module = types.ModuleType('langbot.pkg.platform.logger')
|
|
||||||
logger_module.EventLogger = object
|
|
||||||
sys.modules.setdefault('langbot.pkg.platform.logger', logger_module)
|
|
||||||
|
|
||||||
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
|
|
||||||
WecomBotClient,
|
|
||||||
build_button_interaction_payload,
|
|
||||||
build_human_input_template_card_payload,
|
|
||||||
build_button_interaction_update_card,
|
|
||||||
build_multiple_interaction_update_card,
|
|
||||||
extract_template_card_event_payload,
|
|
||||||
extract_template_card_selections,
|
|
||||||
extract_wecom_event_type,
|
|
||||||
extract_template_card_action,
|
|
||||||
build_human_input_text_prompt,
|
|
||||||
parse_select_button_action,
|
|
||||||
)
|
|
||||||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_template_card_action_supports_nested_button_key():
|
|
||||||
task_id, event_key, card_type = extract_template_card_action(
|
|
||||||
{
|
|
||||||
'taskId': 'task-1',
|
|
||||||
'cardType': 'button_interaction',
|
|
||||||
'button': {'key': 'approve'},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert task_id == 'task-1'
|
|
||||||
assert event_key == 'approve'
|
|
||||||
assert card_type == 'button_interaction'
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_wecom_event_type_supports_top_level_template_card_event():
|
|
||||||
payload = {
|
|
||||||
'eventtype': 'template_card_event',
|
|
||||||
'template_card_event': {
|
|
||||||
'TaskId': 'task-1',
|
|
||||||
'CardType': 'multiple_interaction',
|
|
||||||
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert extract_wecom_event_type(payload) == 'template_card_event'
|
|
||||||
assert extract_template_card_event_payload(payload)['TaskId'] == 'task-1'
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_wecom_event_type_infers_template_card_event_from_top_level_card_fields():
|
|
||||||
payload = {
|
|
||||||
'TaskId': 'task-1',
|
|
||||||
'CardType': 'button_interaction',
|
|
||||||
'EventKey': 'approve',
|
|
||||||
}
|
|
||||||
|
|
||||||
assert extract_wecom_event_type(payload) == 'template_card_event'
|
|
||||||
assert extract_template_card_event_payload(payload)['EventKey'] == 'approve'
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_button_interaction_update_card_marks_clicked_button():
|
|
||||||
card = build_button_interaction_update_card(
|
|
||||||
{
|
|
||||||
'node_title': 'Manual Review',
|
|
||||||
'form_content': 'Please choose one action.',
|
|
||||||
'actions': [
|
|
||||||
{'id': 'approve', 'title': 'Approve', 'button_style': 'primary'},
|
|
||||||
{'id': 'reject', 'title': 'Reject', 'button_style': 'danger'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
action_id='reject',
|
|
||||||
source={'desc': 'LangBot'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert card['main_title'] == {'title': 'Manual Review'}
|
|
||||||
assert card['sub_title_text'] == 'Please choose one action.'
|
|
||||||
assert card['button_list'][0] == {'text': 'Approve', 'style': 2, 'key': 'approve'}
|
|
||||||
assert card['button_list'][1] == {
|
|
||||||
'text': '✅ Reject',
|
|
||||||
'style': 1,
|
|
||||||
'key': 'reject',
|
|
||||||
'replace_text': '✅ Reject',
|
|
||||||
}
|
|
||||||
assert card['source'] == {'desc': 'LangBot'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_button_interaction_payload_uses_preselected_button_styles_before_click():
|
|
||||||
payload = build_button_interaction_payload(
|
|
||||||
{
|
|
||||||
'node_title': 'Manual Review',
|
|
||||||
'actions': [
|
|
||||||
{'id': 'approve', 'title': 'Approve', 'button_style': 'primary'},
|
|
||||||
{'id': 'reject', 'title': 'Reject', 'button_style': 'danger'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload['template_card']['button_list'] == [
|
|
||||||
{'text': 'Approve', 'style': 2, 'key': 'approve'},
|
|
||||||
{'text': 'Reject', 'style': 2, 'key': 'reject'},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_payload_uses_multiple_interaction_for_pending_select_field():
|
|
||||||
payload = build_human_input_template_card_payload(
|
|
||||||
{
|
|
||||||
'node_title': 'Manual Review',
|
|
||||||
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'inputs': {},
|
|
||||||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
source={'desc': 'LangBot'},
|
|
||||||
)
|
|
||||||
|
|
||||||
card = payload['template_card']
|
|
||||||
assert card['card_type'] == 'multiple_interaction'
|
|
||||||
assert card['source'] == {'desc': 'LangBot'}
|
|
||||||
assert card['select_list'] == [
|
|
||||||
{
|
|
||||||
'question_key': 'choice',
|
|
||||||
'title': 'choice',
|
|
||||||
'selected_id': 'opt_1',
|
|
||||||
'option_list': [
|
|
||||||
{'id': 'opt_1', 'text': 'A'},
|
|
||||||
{'id': 'opt_2', 'text': 'B'},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
assert card['submit_button'] == {'text': 'Submit', 'key': 'submit_human_input'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws():
|
|
||||||
form_data = {
|
|
||||||
'node_title': 'Manual Review',
|
|
||||||
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'inputs': {},
|
|
||||||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
|
||||||
}
|
|
||||||
payload = build_human_input_template_card_payload(
|
|
||||||
form_data,
|
|
||||||
task_id='task-1',
|
|
||||||
select_as_buttons=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
card = payload['template_card']
|
|
||||||
assert card['card_type'] == 'button_interaction'
|
|
||||||
assert card['button_list'][0]['text'] == 'A'
|
|
||||||
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_input_card_uses_current_stage_content_without_direct_reply_prompt():
|
|
||||||
payload = build_human_input_template_card_payload(
|
|
||||||
{
|
|
||||||
'node_title': '人工介入',
|
|
||||||
'form_content': '11\n请输入你的问题\n\n{{#$output.us_input#}}',
|
|
||||||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'us_input',
|
|
||||||
'type': 'paragraph',
|
|
||||||
'label': '请输入你的问题',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'inputs': {},
|
|
||||||
'actions': [{'id': 'yes', 'title': 'yes'}],
|
|
||||||
'_current_input_field': 'us_input',
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
)
|
|
||||||
|
|
||||||
card = payload['template_card']
|
|
||||||
assert 'desc' not in card['main_title']
|
|
||||||
assert card['sub_title_text'] == '11\n请输入你的问题'
|
|
||||||
assert card['button_list'] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_human_input_text_prompt_for_current_text_field():
|
|
||||||
prompt = build_human_input_text_prompt(
|
|
||||||
{
|
|
||||||
'node_title': '人工介入',
|
|
||||||
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
|
||||||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'us_input',
|
|
||||||
'type': 'paragraph',
|
|
||||||
'label': '请输入你的问题',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'_current_input_field': 'us_input',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert prompt == '人工介入\n\n11\n请输入你的问题'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
|
|
||||||
client = WecomBotWsClient('bot-id', 'secret', object())
|
|
||||||
client._stream_ids['msg-1'] = 'req-1|stream-1'
|
|
||||||
client._stream_sessions['msg-1'] = {'user_id': 'user-1'}
|
|
||||||
sent = []
|
|
||||||
|
|
||||||
async def fake_reply_text(req_id, content):
|
|
||||||
sent.append((req_id, content))
|
|
||||||
return {}
|
|
||||||
|
|
||||||
client.reply_text = fake_reply_text
|
|
||||||
|
|
||||||
ok, stream_id, task_id = await client.push_form_pause(
|
|
||||||
'msg-1',
|
|
||||||
{
|
|
||||||
'node_title': '人工介入',
|
|
||||||
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
|
||||||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'us_input',
|
|
||||||
'type': 'paragraph',
|
|
||||||
'label': '请输入你的问题',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'_current_input_field': 'us_input',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ok is True
|
|
||||||
assert stream_id == 'stream-1'
|
|
||||||
assert task_id is None
|
|
||||||
assert sent == [('req-1', '人工介入\n\n11\n请输入你的问题')]
|
|
||||||
assert client._pending_forms_by_task == {}
|
|
||||||
assert 'msg-1' not in client._stream_ids
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ws_stream_sends_cumulative_snapshots_to_wecom():
|
|
||||||
client = WecomBotWsClient('bot-id', 'secret', object())
|
|
||||||
client._stream_ids['msg-1'] = 'req-1|stream-1'
|
|
||||||
client._stream_sessions['msg-1'] = {}
|
|
||||||
sent = []
|
|
||||||
|
|
||||||
async def fake_reply_stream(req_id, stream_id, content, finish=False, feedback_id=''):
|
|
||||||
sent.append((req_id, stream_id, content, finish))
|
|
||||||
return {}
|
|
||||||
|
|
||||||
client.reply_stream = fake_reply_stream
|
|
||||||
|
|
||||||
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
|
||||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
|
||||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
|
||||||
|
|
||||||
assert sent == [
|
|
||||||
('req-1', 'stream-1', '你', False),
|
|
||||||
('req-1', 'stream-1', '你好', False),
|
|
||||||
('req-1', 'stream-1', '你好', True),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
|
|
||||||
client = WecomBotClient('', '', '', object(), unified_mode=True)
|
|
||||||
session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-1', 'chatid': '', 'from': {'userid': 'user-1'}})
|
|
||||||
|
|
||||||
assert await client.push_stream_chunk('msg-1', '你', is_final=False)
|
|
||||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
|
|
||||||
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
|
|
||||||
|
|
||||||
chunks = [
|
|
||||||
await client.stream_sessions.consume(session.stream_id),
|
|
||||||
await client.stream_sessions.consume(session.stream_id),
|
|
||||||
await client.stream_sessions.consume(session.stream_id),
|
|
||||||
]
|
|
||||||
assert [(chunk.content, chunk.is_final) for chunk in chunks] == [
|
|
||||||
('你', False),
|
|
||||||
('你好', False),
|
|
||||||
('你好', True),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_human_input_payload_keeps_action_select_stage_as_buttons():
|
|
||||||
payload = build_human_input_template_card_payload(
|
|
||||||
{
|
|
||||||
'node_title': 'Manual Review',
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'inputs': {'choice': 'B'},
|
|
||||||
'actions': [
|
|
||||||
{'id': 'approve', 'title': 'Approve'},
|
|
||||||
{'id': 'reject', 'title': 'Reject'},
|
|
||||||
],
|
|
||||||
'_action_select_only': True,
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
)
|
|
||||||
|
|
||||||
card = payload['template_card']
|
|
||||||
assert card['card_type'] == 'button_interaction'
|
|
||||||
assert card['button_list'] == [
|
|
||||||
{'text': 'Approve', 'style': 2, 'key': 'approve'},
|
|
||||||
{'text': 'Reject', 'style': 2, 'key': 'reject'},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_template_card_selections_maps_selected_id_to_option_text():
|
|
||||||
selections = extract_template_card_selections(
|
|
||||||
{
|
|
||||||
'SelectedItems': [
|
|
||||||
{'QuestionKey': 'choice', 'SelectedId': 'opt_2'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert selections == {'choice': 'B'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_template_card_selections_reads_nested_response_data_json():
|
|
||||||
selections = extract_template_card_selections(
|
|
||||||
{
|
|
||||||
'CardType': 'multiple_interaction',
|
|
||||||
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert selections == {'choice': 'B'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_template_card_selections_reads_response_data_direct_mapping():
|
|
||||||
selections = extract_template_card_selections(
|
|
||||||
{
|
|
||||||
'CardType': 'multiple_interaction',
|
|
||||||
'EventKey': 'submit_human_input',
|
|
||||||
'ResponseData': '{"choice":"opt_2"}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert selections == {'choice': 'B'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_multiple_interaction_update_card_disables_selected_value_without_submitted_text():
|
|
||||||
card = build_multiple_interaction_update_card(
|
|
||||||
{
|
|
||||||
'node_title': 'Manual Review',
|
|
||||||
'form_content': 'Choose a label\n{{#$output.choice#}}',
|
|
||||||
'raw_form_content': 'Choose a label\n{{#$output.choice#}}',
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'choice',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'_current_input_field': 'choice',
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
selections={'choice': 'B'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert card['card_type'] == 'multiple_interaction'
|
|
||||||
assert card['main_title']['desc'] == 'Choose a label\n✅ choice:B'
|
|
||||||
assert card['submit_button']['text'] == '✅'
|
|
||||||
assert card['select_list'][0]['disable'] is True
|
|
||||||
assert card['select_list'][0]['selected_id'] == 'opt_2'
|
|
||||||
|
|
||||||
|
|
||||||
def test_select_stage_only_shows_current_prompt_in_a_separate_message():
|
|
||||||
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'
|
|
||||||
payload = build_human_input_template_card_payload(
|
|
||||||
{
|
|
||||||
'node_title': '人工介入',
|
|
||||||
'form_content': '请选择你的答案\n{{#$output.xiala#}}',
|
|
||||||
'raw_form_content': raw_content,
|
|
||||||
'input_defs': [
|
|
||||||
{
|
|
||||||
'output_variable_name': 'xiala',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{
|
|
||||||
'output_variable_name': 'xiala',
|
|
||||||
'type': 'select',
|
|
||||||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'inputs': {'us_input': '你叫啥'},
|
|
||||||
'_current_input_field': 'xiala',
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload['template_card']['main_title']['desc'] == '请选择你的答案'
|
|
||||||
|
|
||||||
|
|
||||||
def test_action_stage_only_shows_content_after_fields_without_placeholders():
|
|
||||||
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n请选择操作'
|
|
||||||
payload = build_human_input_template_card_payload(
|
|
||||||
{
|
|
||||||
'node_title': '人工介入',
|
|
||||||
'form_content': raw_content,
|
|
||||||
'raw_form_content': raw_content,
|
|
||||||
'input_defs': [],
|
|
||||||
'all_input_defs': [
|
|
||||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
|
||||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
|
||||||
],
|
|
||||||
'inputs': {'us_input': '你叫啥', 'xiala': '2'},
|
|
||||||
'actions': [
|
|
||||||
{'id': 'yes', 'title': 'yes'},
|
|
||||||
{'id': 'no', 'title': 'no'},
|
|
||||||
],
|
|
||||||
'_action_select_only': True,
|
|
||||||
},
|
|
||||||
task_id='task-1',
|
|
||||||
)
|
|
||||||
|
|
||||||
card = payload['template_card']
|
|
||||||
assert card['sub_title_text'] == '请选择操作'
|
|
||||||
assert '{{#$output.' not in card['sub_title_text']
|
|
||||||
assert [button['text'] for button in card['button_list']] == ['yes', 'no']
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -279,122 +279,6 @@ class TestInvokeLLMStreamUsage:
|
|||||||
|
|
||||||
assert query.variables['_stream_usage']['total_tokens'] == 12
|
assert query.variables['_stream_usage']['total_tokens'] == 12
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stream_removes_leading_think_across_chunks(self):
|
|
||||||
"""A leading think block split across chunks must be removed."""
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
mock_ap = Mock()
|
|
||||||
mock_ap.tool_mgr = Mock()
|
|
||||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
|
||||||
model = MockRuntimeModel('minimax-m3', 'test-api-key')
|
|
||||||
|
|
||||||
chunks = [
|
|
||||||
self._make_chunk(content='<thi'),
|
|
||||||
self._make_chunk(content='nk>hidden'),
|
|
||||||
self._make_chunk(content=' reasoning</thi'),
|
|
||||||
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def _aiter(*args, **kwargs):
|
|
||||||
for c in chunks:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
query = Mock(spec=pipeline_query.Query)
|
|
||||||
query.variables = {}
|
|
||||||
messages = [provider_message.Message(role='user', content='Hi')]
|
|
||||||
|
|
||||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
|
||||||
collected = [
|
|
||||||
chunk
|
|
||||||
async for chunk in requester.invoke_llm_stream(
|
|
||||||
query=query,
|
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
remove_think=True,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stream_removes_initial_orphan_think_close(self):
|
|
||||||
"""Initial reasoning content without an open tag is removed until </think>."""
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
mock_ap = Mock()
|
|
||||||
mock_ap.tool_mgr = Mock()
|
|
||||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
|
||||||
model = MockRuntimeModel('minimax-m3', 'test-api-key')
|
|
||||||
|
|
||||||
chunks = [
|
|
||||||
self._make_chunk(content='hidden reasoning'),
|
|
||||||
self._make_chunk(content=' still hidden</thi'),
|
|
||||||
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def _aiter(*args, **kwargs):
|
|
||||||
for c in chunks:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
query = Mock(spec=pipeline_query.Query)
|
|
||||||
query.variables = {}
|
|
||||||
messages = [provider_message.Message(role='user', content='Hi')]
|
|
||||||
|
|
||||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
|
||||||
collected = [
|
|
||||||
chunk
|
|
||||||
async for chunk in requester.invoke_llm_stream(
|
|
||||||
query=query,
|
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
remove_think=True,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stream_removes_non_leading_think_content(self):
|
|
||||||
"""A think block in the answer body is removed with its content."""
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
mock_ap = Mock()
|
|
||||||
mock_ap.tool_mgr = Mock()
|
|
||||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
|
||||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
|
||||||
|
|
||||||
chunks = [
|
|
||||||
self._make_chunk(content='Use <think>x</think> as an XML-like example.', finish_reason='stop'),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def _aiter(*args, **kwargs):
|
|
||||||
for c in chunks:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
query = Mock(spec=pipeline_query.Query)
|
|
||||||
query.variables = {}
|
|
||||||
messages = [provider_message.Message(role='user', content='Hi')]
|
|
||||||
|
|
||||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
|
||||||
collected = [
|
|
||||||
chunk
|
|
||||||
async for chunk in requester.invoke_llm_stream(
|
|
||||||
query=query,
|
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
remove_think=True,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert ''.join(chunk.content or '' for chunk in collected) == 'Use as an XML-like example.'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stream_tool_call_delta_missing_id_and_name(self):
|
async def test_stream_tool_call_delta_missing_id_and_name(self):
|
||||||
"""LiteLLM may stream tool-call argument deltas with id/name set to None."""
|
"""LiteLLM may stream tool-call argument deltas with id/name set to None."""
|
||||||
@@ -598,38 +482,6 @@ class TestProcessThinkingContent:
|
|||||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||||
assert result == 'The answer is 42.'
|
assert result == 'The answer is 42.'
|
||||||
|
|
||||||
def test_remove_leading_think_tag(self):
|
|
||||||
"""Test removing a leading <think> block when remove_think=True"""
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
|
||||||
|
|
||||||
content = '<think>Let me think...</think> The answer is 42.'
|
|
||||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
|
||||||
assert result == 'The answer is 42.'
|
|
||||||
|
|
||||||
def test_remove_non_leading_think_tag(self):
|
|
||||||
"""Test removing <think> and its content in the answer body"""
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
|
||||||
|
|
||||||
content = 'Use <think>example</think> in the document.'
|
|
||||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
|
||||||
assert result == 'Use in the document.'
|
|
||||||
|
|
||||||
def test_remove_initial_orphan_think_close(self):
|
|
||||||
"""Test removing leading reasoning content when only </think> is visible"""
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
|
||||||
|
|
||||||
content = 'hidden reasoning</think> Visible answer.'
|
|
||||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
|
||||||
assert result == 'Visible answer.'
|
|
||||||
|
|
||||||
def test_remove_multiple_think_tags(self):
|
|
||||||
"""Test removing multiple <think> blocks"""
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
|
||||||
|
|
||||||
content = '<think>hidden</think> Keep <think>example</think>.'
|
|
||||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
|
||||||
assert result == 'Keep .'
|
|
||||||
|
|
||||||
def test_preserve_thinking_markers(self):
|
def test_preserve_thinking_markers(self):
|
||||||
"""Test preserving thinking markers when remove_think=False"""
|
"""Test preserving thinking markers when remove_think=False"""
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||||
@@ -639,20 +491,6 @@ class TestProcessThinkingContent:
|
|||||||
assert 'CRETIRE_REASONING_BEGINk' in result
|
assert 'CRETIRE_REASONING_BEGINk' in result
|
||||||
assert 'The answer is 42.' in result
|
assert 'The answer is 42.' in result
|
||||||
|
|
||||||
def test_preserve_reasoning_content_when_remove_think_false(self):
|
|
||||||
"""Test showing separate reasoning_content when remove_think=False"""
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
|
||||||
|
|
||||||
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=False)
|
|
||||||
assert result == '<think>\nLet me think...\n</think>\nThe answer is 42.'
|
|
||||||
|
|
||||||
def test_hide_reasoning_content_when_remove_think_true(self):
|
|
||||||
"""Test hiding separate reasoning_content when remove_think=True"""
|
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
|
||||||
|
|
||||||
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=True)
|
|
||||||
assert result == 'The answer is 42.'
|
|
||||||
|
|
||||||
def test_empty_content(self):
|
def test_empty_content(self):
|
||||||
"""Test empty content"""
|
"""Test empty content"""
|
||||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||||
|
|||||||
@@ -163,51 +163,6 @@ def test_stream_accumulator_merges_fragmented_tool_call_arguments():
|
|||||||
assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}'
|
assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}'
|
||||||
|
|
||||||
|
|
||||||
def test_stream_accumulator_strips_leading_think_from_tool_round_content():
|
|
||||||
accumulator = _StreamAccumulator(
|
|
||||||
msg_sequence=3,
|
|
||||||
initial_content='I will search for LangBot.',
|
|
||||||
remove_think=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='<thi')) is None
|
|
||||||
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='nk>hidden')) is None
|
|
||||||
emitted = accumulator.add(
|
|
||||||
provider_message.MessageChunk(
|
|
||||||
role='assistant',
|
|
||||||
content=' reasoning</think>Here is the answer.',
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert emitted is not None
|
|
||||||
assert emitted.content == 'I will search for LangBot.Here is the answer.'
|
|
||||||
assert '<think>' not in emitted.content
|
|
||||||
assert 'hidden reasoning' not in emitted.content
|
|
||||||
|
|
||||||
|
|
||||||
def test_stream_accumulator_strips_initial_orphan_think_close_from_tool_round_content():
|
|
||||||
accumulator = _StreamAccumulator(
|
|
||||||
msg_sequence=3,
|
|
||||||
initial_content='I will search for LangBot.',
|
|
||||||
remove_think=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='hidden reasoning')) is None
|
|
||||||
emitted = accumulator.add(
|
|
||||||
provider_message.MessageChunk(
|
|
||||||
role='assistant',
|
|
||||||
content=' still hidden</think>Here is the answer.',
|
|
||||||
is_final=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert emitted is not None
|
|
||||||
assert emitted.content == 'I will search for LangBot.Here is the answer.'
|
|
||||||
assert '</think>' not in emitted.content
|
|
||||||
assert 'hidden reasoning' not in emitted.content
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_localagent_uses_exec_for_exact_calculation():
|
async def test_localagent_uses_exec_for_exact_calculation():
|
||||||
provider = RecordingProvider()
|
provider = RecordingProvider()
|
||||||
|
|||||||
@@ -2008,7 +2008,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.6"
|
version = "4.10.5"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
|
|||||||
@@ -269,10 +269,6 @@ export default function BotForm({
|
|||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
login_platform: item.login_platform,
|
login_platform: item.login_platform,
|
||||||
url: item.url,
|
|
||||||
download_filename: item.download_filename,
|
|
||||||
help_links: item.help_links,
|
|
||||||
help_label: item.help_label,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,15 +24,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Copy, Check, Globe, Info, QrCode } from 'lucide-react';
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Globe,
|
|
||||||
Info,
|
|
||||||
QrCode,
|
|
||||||
Download,
|
|
||||||
ExternalLink,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -41,7 +33,6 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { systemInfo } from '@/app/infra/http';
|
import { systemInfo } from '@/app/infra/http';
|
||||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the value referenced by a `show_if.field` string.
|
* Resolve the value referenced by a `show_if.field` string.
|
||||||
@@ -300,52 +291,6 @@ function WebhookUrlField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownloadLinkField({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
filename,
|
|
||||||
helpUrl,
|
|
||||||
helpLabel,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
url: string;
|
|
||||||
filename?: string;
|
|
||||||
helpUrl?: string | null;
|
|
||||||
helpLabel: string;
|
|
||||||
}) {
|
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
|
||||||
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormItem className="min-w-0">
|
|
||||||
<FormLabel className="break-words">{label}</FormLabel>
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<a href={downloadUrl} download={filename}>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{helpUrl && (
|
|
||||||
<Button asChild variant="ghost" size="sm">
|
|
||||||
<a href={helpUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
{helpLabel}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="max-w-2xl text-sm break-words text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display-only component for `__system.*` fields (e.g. the deployment's
|
* Display-only component for `__system.*` fields (e.g. the deployment's
|
||||||
* outbound IPs that the operator must add to a platform's trusted-IP list).
|
* outbound IPs that the operator must add to a platform's trusted-IP list).
|
||||||
@@ -460,7 +405,7 @@ export default function DynamicFormComponent({
|
|||||||
}) {
|
}) {
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
const previousInitialValues = useRef(initialValues);
|
const previousInitialValues = useRef(initialValues);
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Normalize a form value according to its field type.
|
// Normalize a form value according to its field type.
|
||||||
// This ensures legacy/malformed data (e.g. a plain string for
|
// This ensures legacy/malformed data (e.g. a plain string for
|
||||||
@@ -515,7 +460,6 @@ export default function DynamicFormComponent({
|
|||||||
item.type !== 'webhook-url' &&
|
item.type !== 'webhook-url' &&
|
||||||
item.type !== 'embed-code' &&
|
item.type !== 'embed-code' &&
|
||||||
item.type !== 'qr-code-login' &&
|
item.type !== 'qr-code-login' &&
|
||||||
item.type !== 'download-link' &&
|
|
||||||
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
|
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
|
||||||
),
|
),
|
||||||
[itemConfigList],
|
[itemConfigList],
|
||||||
@@ -833,30 +777,6 @@ export default function DynamicFormComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.type === 'download-link') {
|
|
||||||
if (!config.url) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DownloadLinkField
|
|
||||||
key={config.id}
|
|
||||||
label={extractI18nObject(config.label)}
|
|
||||||
description={
|
|
||||||
config.description
|
|
||||||
? extractI18nObject(config.description)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
url={config.url}
|
|
||||||
filename={config.download_filename}
|
|
||||||
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
|
|
||||||
helpLabel={
|
|
||||||
config.help_label
|
|
||||||
? extractI18nObject(config.help_label)
|
|
||||||
: t('bots.viewAdapterDocs')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||||
if (config.type === 'qr-code-login') {
|
if (config.type === 'qr-code-login') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
options?: IDynamicFormItemOption[];
|
options?: IDynamicFormItemOption[];
|
||||||
show_if?: IShowIfCondition;
|
show_if?: IShowIfCondition;
|
||||||
login_platform?: string;
|
login_platform?: string;
|
||||||
url?: string;
|
|
||||||
download_filename?: string;
|
|
||||||
help_links?: Record<string, string>;
|
|
||||||
help_label?: I18nObject;
|
|
||||||
|
|
||||||
constructor(params: IDynamicFormItemSchema) {
|
constructor(params: IDynamicFormItemSchema) {
|
||||||
this.id = params.id;
|
this.id = params.id;
|
||||||
@@ -34,10 +30,6 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
this.options = params.options;
|
this.options = params.options;
|
||||||
this.show_if = params.show_if;
|
this.show_if = params.show_if;
|
||||||
this.login_platform = params.login_platform;
|
this.login_platform = params.login_platform;
|
||||||
this.url = params.url;
|
|
||||||
this.download_filename = params.download_filename;
|
|
||||||
this.help_links = params.help_links;
|
|
||||||
this.help_label = params.help_label;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
export type QrLoginPlatform =
|
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||||
| 'feishu'
|
|
||||||
| 'weixin'
|
|
||||||
| 'dingtalk'
|
|
||||||
| 'wecombot'
|
|
||||||
| 'qqofficial';
|
|
||||||
|
|
||||||
interface PlatformConfig {
|
interface PlatformConfig {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -34,7 +29,6 @@ interface PlatformConfig {
|
|||||||
apiBase: string;
|
apiBase: string;
|
||||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||||
successNoteKey?: string;
|
successNoteKey?: string;
|
||||||
boundByKey?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||||
@@ -98,22 +92,6 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
|||||||
}),
|
}),
|
||||||
successNoteKey: 'wecombot.robotNameNote',
|
successNoteKey: 'wecombot.robotNameNote',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
titleKey: 'qqofficial.createBinding',
|
|
||||||
connectingKey: 'qqofficial.connecting',
|
|
||||||
scanQRCodeKey: 'qqofficial.scanQRCode',
|
|
||||||
waitingKey: 'qqofficial.waitingForScan',
|
|
||||||
successKey: 'qqofficial.bindSuccess',
|
|
||||||
failedKey: 'qqofficial.bindFailed',
|
|
||||||
retryKey: 'qqofficial.retry',
|
|
||||||
apiBase: '/api/v1/platform/adapters/qqofficial/bind',
|
|
||||||
extractSuccess: (data) => ({
|
|
||||||
appid: data.appid,
|
|
||||||
secret: data.secret,
|
|
||||||
}),
|
|
||||||
successNoteKey: 'qqofficial.tokenNote',
|
|
||||||
boundByKey: 'qqofficial.boundBy',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface QrCodeLoginDialogProps {
|
interface QrCodeLoginDialogProps {
|
||||||
@@ -140,7 +118,6 @@ export default function QrCodeLoginDialog({
|
|||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
const [expireIn, setExpireIn] = useState(0);
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [successMeta, setSuccessMeta] = useState('');
|
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -201,7 +178,6 @@ export default function QrCodeLoginDialog({
|
|||||||
setQrDataUrl('');
|
setQrDataUrl('');
|
||||||
setExpireIn(0);
|
setExpireIn(0);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setSuccessMeta('');
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
@@ -299,13 +275,6 @@ export default function QrCodeLoginDialog({
|
|||||||
sessionIdRef.current = null;
|
sessionIdRef.current = null;
|
||||||
cleanup();
|
cleanup();
|
||||||
setState('success');
|
setState('success');
|
||||||
// Platform may return extra audit metadata (e.g. QQ Official returns
|
|
||||||
// the scanner's user_openid) — surface it briefly before the dialog closes.
|
|
||||||
if (rest.user_openid && cfg.boundByKey) {
|
|
||||||
setSuccessMeta(
|
|
||||||
tRef.current(cfg.boundByKey, { openid: rest.user_openid }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||||
onOpenChangeRef.current(false);
|
onOpenChangeRef.current(false);
|
||||||
@@ -426,11 +395,6 @@ export default function QrCodeLoginDialog({
|
|||||||
<p className="text-sm text-green-600 font-medium">
|
<p className="text-sm text-green-600 font-medium">
|
||||||
{t(platformConfig.successKey)}
|
{t(platformConfig.successKey)}
|
||||||
</p>
|
</p>
|
||||||
{successMeta && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
|
|
||||||
{successMeta}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{platformConfig.successNoteKey && (
|
{platformConfig.successNoteKey && (
|
||||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||||
{t(platformConfig.successNoteKey)}
|
{t(platformConfig.successNoteKey)}
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ export interface IDynamicFormItemSchema {
|
|||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
accept?: string; // For file type: accepted MIME types
|
accept?: string; // For file type: accepted MIME types
|
||||||
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
||||||
url?: string; // For download-link type: relative or absolute download URL
|
|
||||||
download_filename?: string; // Optional filename for download-link type
|
|
||||||
help_links?: Record<string, string>; // Optional docs links for display-only fields
|
|
||||||
help_label?: I18nObject; // Optional label for help_links
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DynamicFormItemType {
|
export enum DynamicFormItemType {
|
||||||
@@ -76,7 +72,6 @@ export enum DynamicFormItemType {
|
|||||||
WEBHOOK_URL = 'webhook-url',
|
WEBHOOK_URL = 'webhook-url',
|
||||||
EMBED_CODE = 'embed-code',
|
EMBED_CODE = 'embed-code',
|
||||||
QR_CODE_LOGIN = 'qr-code-login',
|
QR_CODE_LOGIN = 'qr-code-login',
|
||||||
DOWNLOAD_LINK = 'download-link',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFileConfig {
|
export interface IFileConfig {
|
||||||
|
|||||||
@@ -229,10 +229,6 @@ export default function WizardPage() {
|
|||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
login_platform: item.login_platform,
|
login_platform: item.login_platform,
|
||||||
url: item.url,
|
|
||||||
download_filename: item.download_filename,
|
|
||||||
help_links: item.help_links,
|
|
||||||
help_label: item.help_label,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [adapters, selectedAdapter]);
|
}, [adapters, selectedAdapter]);
|
||||||
@@ -253,10 +249,6 @@ export default function WizardPage() {
|
|||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
login_platform: item.login_platform,
|
login_platform: item.login_platform,
|
||||||
url: item.url,
|
|
||||||
download_filename: item.download_filename,
|
|
||||||
help_links: item.help_links,
|
|
||||||
help_label: item.help_label,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [selectedRunnerConfigStage]);
|
}, [selectedRunnerConfigStage]);
|
||||||
|
|||||||
@@ -1822,19 +1822,6 @@ const enUS = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: 'One-Click QR Binding for QQ Official Bot',
|
|
||||||
scanQRCode:
|
|
||||||
'Scan the QR code below with mobile QQ and authorize the binding in QQ Bot Assistant',
|
|
||||||
waitingForScan: 'Waiting for scan',
|
|
||||||
bindSuccess: 'Bound successfully! AppID and Secret have been filled in',
|
|
||||||
bindFailed: 'Binding failed',
|
|
||||||
connecting: 'Connecting to QQ service...',
|
|
||||||
retry: 'Retry',
|
|
||||||
tokenNote:
|
|
||||||
'The Token field is not used by the current adapter — you can leave it blank.',
|
|
||||||
boundBy: 'Bound by QQ user {{openid}}',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||||
invalidPage: 'Invalid plugin page',
|
invalidPage: 'Invalid plugin page',
|
||||||
|
|||||||
@@ -1740,20 +1740,6 @@ const esES = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: 'Vinculación QR con un clic para el bot oficial de QQ',
|
|
||||||
scanQRCode:
|
|
||||||
'Escanea el código QR siguiente con QQ móvil y autoriza la vinculación en «QQ Bot Assistant»',
|
|
||||||
waitingForScan: 'Esperando escaneo',
|
|
||||||
bindSuccess:
|
|
||||||
'¡Vinculación correcta! AppID y Secret se han rellenado automáticamente',
|
|
||||||
bindFailed: 'Error en la vinculación',
|
|
||||||
connecting: 'Conectando con el servicio de QQ...',
|
|
||||||
retry: 'Reintentar',
|
|
||||||
tokenNote:
|
|
||||||
'El campo Token no es utilizado por el adaptador actual; puedes dejarlo vacío.',
|
|
||||||
boundBy: 'Vinculado por el usuario QQ {{openid}}',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||||
invalidPage: 'Página de plugin no válida',
|
invalidPage: 'Página de plugin no válida',
|
||||||
|
|||||||
@@ -1733,19 +1733,6 @@ const jaJP = {
|
|||||||
retry: '再試行',
|
retry: '再試行',
|
||||||
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: 'ワンクリックで QQ 公式ボットを QR バインド',
|
|
||||||
scanQRCode:
|
|
||||||
'以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください',
|
|
||||||
waitingForScan: 'スキャン待ち',
|
|
||||||
bindSuccess: 'バインド成功!AppID と Secret が自動入力されました',
|
|
||||||
bindFailed: 'バインド失敗',
|
|
||||||
connecting: 'QQ サービスに接続中...',
|
|
||||||
retry: '再試行',
|
|
||||||
tokenNote:
|
|
||||||
'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。',
|
|
||||||
boundBy: 'QQ ユーザー {{openid}} によりバインドされました',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||||
invalidPage: '無効なプラグインページ',
|
invalidPage: '無効なプラグインページ',
|
||||||
|
|||||||
@@ -1711,19 +1711,6 @@ const ruRU = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: 'Привязка официального бота QQ по QR-коду',
|
|
||||||
scanQRCode:
|
|
||||||
'Отсканируйте QR-код ниже мобильным QQ и подтвердите привязку в «QQ Bot Assistant»',
|
|
||||||
waitingForScan: 'Ожидание сканирования',
|
|
||||||
bindSuccess: 'Привязка успешна! AppID и Secret заполнены автоматически',
|
|
||||||
bindFailed: 'Не удалось выполнить привязку',
|
|
||||||
connecting: 'Подключение к сервису QQ...',
|
|
||||||
retry: 'Повторить',
|
|
||||||
tokenNote:
|
|
||||||
'Поле Token не используется текущим адаптером — его можно оставить пустым.',
|
|
||||||
boundBy: 'Привязано пользователем QQ {{openid}}',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||||
invalidPage: 'Недопустимая страница плагина',
|
invalidPage: 'Недопустимая страница плагина',
|
||||||
|
|||||||
@@ -1672,18 +1672,6 @@ const thTH = {
|
|||||||
retry: 'ลองใหม่',
|
retry: 'ลองใหม่',
|
||||||
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: 'ผูกบอต QQ Official ด้วย QR คลิกเดียว',
|
|
||||||
scanQRCode:
|
|
||||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย QQ มือถือ แล้วอนุญาตการผูกใน «QQ Bot Assistant»',
|
|
||||||
waitingForScan: 'กำลังรอสแกน',
|
|
||||||
bindSuccess: 'ผูกสำเร็จ! AppID และ Secret ถูกกรอกอัตโนมัติแล้ว',
|
|
||||||
bindFailed: 'การผูกล้มเหลว',
|
|
||||||
connecting: 'กำลังเชื่อมต่อบริการ QQ...',
|
|
||||||
retry: 'ลองใหม่',
|
|
||||||
tokenNote: 'อะแดปเตอร์ปัจจุบันไม่ได้ใช้ฟิลด์ Token จึงเว้นว่างไว้ได้',
|
|
||||||
boundBy: 'ผูกโดยผู้ใช้ QQ {{openid}}',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||||
|
|||||||
@@ -1701,19 +1701,6 @@ const viVN = {
|
|||||||
retry: 'Thử lại',
|
retry: 'Thử lại',
|
||||||
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: 'Liên kết bot QQ Official bằng QR một chạm',
|
|
||||||
scanQRCode:
|
|
||||||
'Quét mã QR bên dưới bằng QQ trên di động và xác nhận liên kết trong «QQ Bot Assistant»',
|
|
||||||
waitingForScan: 'Đang chờ quét',
|
|
||||||
bindSuccess: 'Liên kết thành công! AppID và Secret đã được điền tự động',
|
|
||||||
bindFailed: 'Liên kết thất bại',
|
|
||||||
connecting: 'Đang kết nối tới dịch vụ QQ...',
|
|
||||||
retry: 'Thử lại',
|
|
||||||
tokenNote:
|
|
||||||
'Bộ chuyển đổi hiện tại không dùng trường Token; có thể để trống.',
|
|
||||||
boundBy: 'Được liên kết bởi người dùng QQ {{openid}}',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||||
invalidPage: 'Trang plugin không hợp lệ',
|
invalidPage: 'Trang plugin không hợp lệ',
|
||||||
|
|||||||
@@ -1741,17 +1741,6 @@ const zhHans = {
|
|||||||
retry: '重试',
|
retry: '重试',
|
||||||
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: '一键扫码绑定 QQ 机器人',
|
|
||||||
scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定',
|
|
||||||
waitingForScan: '等待扫码中',
|
|
||||||
bindSuccess: '绑定成功!AppID 与密钥已自动填入',
|
|
||||||
bindFailed: '绑定失败',
|
|
||||||
connecting: '正在连接 QQ 服务...',
|
|
||||||
retry: '重试',
|
|
||||||
tokenNote: 'Token 字段当前适配器未使用,留空即可。',
|
|
||||||
boundBy: '由 QQ 用户 {{openid}} 扫码绑定',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||||
invalidPage: '无效的插件页面',
|
invalidPage: '无效的插件页面',
|
||||||
|
|||||||
@@ -1650,17 +1650,6 @@ const zhHant = {
|
|||||||
retry: '重試',
|
retry: '重試',
|
||||||
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
||||||
},
|
},
|
||||||
qqofficial: {
|
|
||||||
createBinding: '一鍵掃碼綁定 QQ 機器人',
|
|
||||||
scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定',
|
|
||||||
waitingForScan: '等待掃碼中',
|
|
||||||
bindSuccess: '綁定成功!AppID 與密鑰已自動填入',
|
|
||||||
bindFailed: '綁定失敗',
|
|
||||||
connecting: '正在連線 QQ 服務...',
|
|
||||||
retry: '重試',
|
|
||||||
tokenNote: 'Token 欄位目前介面卡未使用,留空即可。',
|
|
||||||
boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定',
|
|
||||||
},
|
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||||
invalidPage: '無效的插件頁面',
|
invalidPage: '無效的插件頁面',
|
||||||
|
|||||||
Reference in New Issue
Block a user