Compare commits

..

104 Commits

Author SHA1 Message Date
Junyan Qin
68ef7bd2c4 chore: update project version to 4.7.0 and revise description for clarity 2026-01-05 20:06:01 +08:00
Junyan Qin
61dc5de085 fix: update help links in sidebar configuration to reflect new usage paths and add Japanese translations 2026-01-05 18:45:35 +08:00
Junyan Qin
63bdd71e22 fix: update models_gateway_api_url to include version in cloud service configuration 2026-01-05 17:58:50 +08:00
Junyan Qin
9ea5b50802 refactor: enhance layout and styling of ModelsDialog component for improved usability 2026-01-05 17:58:01 +08:00
Jinzhe Zeng
1cd586634d fix: split Wecom messages exceeding 2048-byte limit (#1901)
Co-authored-by: Oracle Public Cloud User <opc@arm1.subnet.vcn.oraclevcn.com>
2026-01-05 15:04:46 +08:00
Junyan Qin
45bedbe70e fix: update QQ Group link in README to the new group ID 2026-01-05 10:20:42 +08:00
Junyan Qin (Chin)
f7f1dde7b5 Merge pull request #1894 from langbot-app/feat/maas-support
refactor: model config dialog and introduce LangBot Models service integration
2026-01-03 15:47:23 +08:00
Junyan Qin
ba06555078 refactor: remove SQLite compatibility check for column cleanup in DB migration script 2026-01-03 15:43:40 +08:00
Junyan Qin
840fa39979 feat: add informational popover to registration page with tips on using Space for account authentication 2026-01-03 15:26:24 +08:00
Junyan Qin
b295416e6c fix: adjust ModelsDialog component to set a maximum width for better layout consistency 2026-01-03 01:06:17 +08:00
Junyan Qin
914f77ff37 refactor: standardize error handling across components by utilizing CustomApiError for improved error messaging 2026-01-03 00:56:25 +08:00
Junyan Qin
b0b7b914d8 feat: update README files to include new links for API integration, plugin market, and roadmap across multiple languages 2026-01-01 22:11:43 +08:00
Junyan Qin
12713aad45 feat: migrate cloud service URL configuration and update database version to 17 2026-01-01 21:40:55 +08:00
Junyan Qin
02e12cc1e4 feat: implement account email mismatch error handling and improve user feedback in authentication flows 2026-01-01 17:01:32 +08:00
Junyan Qin
61f08f3218 feat: add disable_models_service configuration to manage model service availability and update related components 2026-01-01 15:40:39 +08:00
Junyan Qin
75c2a063cc refactor: remove providerUuid prop from model components and enhance provider deletion confirmation UI 2026-01-01 15:07:37 +08:00
Junyan Qin
b4773c4e48 refactor: update model management components and enhance provider functionality 2026-01-01 14:58:06 +08:00
Junyan Qin (Chin)
fb73da8735 Merge branch 'master' into feat/maas-support 2026-01-01 13:07:45 +08:00
Junyan Qin
679e549b1d feat: implement loading states in SpaceOAuthCallback and HomeSidebar components using Suspense 2026-01-01 13:06:04 +08:00
Junyan Qin
898144e9f4 fix: remove unused HoverCard imports from DynamicFormItemComponent and clean up ModelsDialog constants 2026-01-01 12:53:39 +08:00
Junyan Qin
b99c5561fc fix: update cloud service URL retrieval and enhance model synchronization error handling 2026-01-01 12:50:26 +08:00
Copilot
b2f4b91979 perf: replace copy button toast notifications with checkmark feedback (#1898)
* Initial plan

* Replace copy button toast notifications with checkmark visual feedback

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Complete copy button checkmark feedback implementation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* revert pnpm-lock.yaml

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-01-01 11:53:13 +08:00
Junyan Qin
4528000fc4 refactor: model management 2026-01-01 02:00:24 +08:00
Junyan Qin
96e40eaf25 feat: enhance model creation with UUID preservation option and implement Space model synchronization in ModelManager 2025-12-31 22:25:07 +08:00
Junyan Qin
197258ae91 feat: add LangBot Space ChatCompletions requester and integrate with ModelsDialog and EmbeddingForm components 2025-12-30 21:52:52 +08:00
Junyan Qin
19f417174c feat: implement SpaceService for OAuth handling and user management, refactor UserService to utilize new service methods 2025-12-29 22:43:19 +08:00
Junyan Qin
9c82eeddeb feat: add endpoint for retrieving user space credits and implement caching mechanism in UserService 2025-12-29 22:23:11 +08:00
Junyan Qin
f11e01b549 refactor: rename 'allow_change_password' to 'allow_modify_login_info' and update related logic across the application 2025-12-29 21:14:05 +08:00
Junyan Qin
863b26c3fa refactor: update column drop logic in DBMigrateModelProviderRefactor for PostgreSQL compatibility 2025-12-29 20:42:06 +08:00
Junyan Qin
b788858f9e fix: handle case of empty token list in TokenManager to prevent errors 2025-12-29 12:18:45 +08:00
Junyan Qin
de8a7df6c2 feat: implement instance ID management and integrate with OAuth token exchange 2025-12-29 00:35:31 +08:00
Junyan Qin
ba5b481617 refactor: simplify theme toggle implementation in HomeSidebar and ThemeToggle components 2025-12-28 22:43:05 +08:00
Junyan Qin
07ad846e96 feat: update dependencies and enhance account settings dialog with password management and improved UI elements 2025-12-28 22:38:11 +08:00
Copilot
30945aafdd feat: support configurable WeCom API base URL for reverse proxy deployment (#1890)
* Initial plan

* Add api_base_url support to WeCom API libraries and adapters

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add api_base_url parameter to OAClient and adapters for Official Account and WeCom APIs

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-28 21:04:55 +08:00
Junyan Qin
24c15b4479 feat: implement account settings dialog for managing user passwords and binding Space accounts 2025-12-26 23:20:51 +08:00
Junyan Qin
1d4c5bbdf1 feat: enhance model abilities display in DynamicFormItem and ModelsDialog components with icons for vision and function call 2025-12-26 20:57:12 +08:00
Junyan Qin
57fcec011d feat: refactor model management to introduce provider structure, enhancing model organization and retrieval 2025-12-26 20:27:33 +08:00
Junyan Qin
455e3db28d feat: add Radix UI collapsible component for enhanced UI interactions 2025-12-26 00:49:35 +08:00
Junyan Qin
8caab43b00 feat: add Space integration for user authentication and model management with OAuth support 2025-12-26 00:35:47 +08:00
Junyan Qin
7479545339 feat: implement models dialog for managing LLM and embedding models with dynamic URL handling 2025-12-25 20:54:00 +08:00
Junyan Qin
10ee30695a feat: add error handling and alert display for model testing in EmbeddingForm and LLMForm 2025-12-24 16:12:41 +08:00
Junyan Qin
a9a262eaae feat: add new version notification dialog and version comparison logic 2025-12-24 12:43:52 +08:00
Junyan Qin
a8594b76cd fix: enable extra_args in LLMModelsService for model testing 2025-12-23 21:03:45 +08:00
Junyan Qin
11ee0fef5d chore: update Python versions in CI workflow 2025-12-23 14:27:09 +08:00
Junyan Qin
9a9ba34717 chore: bump version v4.6.5 2025-12-23 14:26:52 +08:00
Junyan Qin
312e47bf46 chore: bump langbot-plugin to 0.2.4 2025-12-23 14:22:13 +08:00
Junyan Qin
628865fd06 fix: add timeout to image fetching in get_qq_image_bytes function (#1859) 2025-12-23 14:17:16 +08:00
Junyan Qin
806a03cd53 fix: dingtalk adapter lifecycle mgm issues (#1844, #1853) 2025-12-23 14:00:41 +08:00
Junyan Qin
24bd90fcf6 fix: alter_user_message typing issues 2025-12-23 13:24:52 +08:00
Junyan Qin
d2765577c8 chore: provide '--no-sync' arg in dockerfile 2025-12-23 12:39:42 +08:00
fdc310
60ca688bcb Fix/Incomplete JSON data returned by N8N streaming data causes the loss of chunks. (#1880)
* fix: Incomplete JSON data returned by N8N streaming data causes the loss of chunks.
2025-12-23 09:42:26 +08:00
ICE
76d8eea41d fix: group bot at rule (#1882) 2025-12-22 20:20:41 +08:00
Junyan Qin
635c3a04d8 perf: ja-JP translation for New 2025-12-22 18:46:15 +08:00
Junyan Qin
dde97abe38 feat: enhance HomeSidebar with new integration options and updated translations 2025-12-22 18:43:19 +08:00
Copilot
90a22d894d fix: prevent memory overflow from excessive logging in streaming and query processing (#1879)
* Initial plan

* fix: reduce excessive logging to prevent memory overflow

- Add log file rotation (10MB max per file, 5 backups)
- Reduce streaming response logging (every 10th chunk instead of every chunk)
- Remove debug logging from controller tight loop
- Add summary logging after streaming completes

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* refactor: address code review feedback

- Extract log rotation config to module-level constants
- Keep first streaming chunk at INFO level for connection debugging
- Use DEBUG level for subsequent chunks

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* style: fix code formatting whitespace

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-12-22 18:25:24 +08:00
Junyan Qin
88ef9cd6ae chore: remove platform field from docker-compose.yaml 2025-12-21 20:31:09 +08:00
fdc310
e3595b5c57 Feat/lark file and audio (#1874)
* fix: n8n streaming no sequence bug

* feat:add lark file and audio
fix: webhook

* feat:add lark file and audio
fix: webhook

* 更新 n8nsvapi.py

* del : print and log
2025-12-21 01:30:05 +08:00
Junyan Qin (Chin)
ce82f87e43 feat: add SeekDB vector database support for knowledge bases (#1814)
* feat: add SeekDB vector database support for knowledge bases

This commit adds complete integration of OceanBase's SeekDB as a vector
database option for LangBot's knowledge base feature.

## Changes

### Core Implementation
- Add SeekDB adapter implementing VectorDatabase interface
  - Support both embedded and server deployment modes
  - HNSW indexing with cosine similarity
  - Async operations with error handling
  - Comprehensive logging

### System Integration
- Register SeekDB in VectorDBManager
- Add pyseekdb>=0.1.0 dependency
- Add SeekDB configuration template
- Update README with vector database section

### Documentation
- Complete integration guide with platform compatibility warnings
- Configuration examples for all deployment modes
- Troubleshooting guide for common issues
- Code examples demonstrating usage patterns
- Comprehensive test reports and status documentation

## Testing

Architecture validated end-to-end using ChromaDB:
- File upload → parsing → chunking → embedding → storage
- 828 bytes → 3 chunks → 3 vectors stored successfully
- BGE-M3 model (384 dimensions)
- Status: Completed 

## Platform Compatibility

### Embedded Mode
-  Linux: Fully supported
-  macOS: Not supported (pylibseekdb is Linux-only)
-  Windows: Not supported (pylibseekdb is Linux-only)

### Server Mode
-  Linux: Fully supported
- ⚠️ macOS: Known issue (oceanbase/seekdb#36)
- ⚠️ Windows: Untested

### Remote Connection
-  All platforms supported

## Known Issues

macOS Docker server mode affected by upstream bug:
https://github.com/oceanbase/seekdb/issues/36

Workaround: Use ChromaDB/Qdrant or connect to remote SeekDB server.

## Files Added
- src/langbot/pkg/vector/vdbs/seekdb.py
- docs/SEEKDB_INTEGRATION.md
- examples/seekdb_example.py
- SEEKDB_INTEGRATION_SUMMARY.md
- SEEKDB_INTEGRATION_COMPLETE.md
- SEEKDB_TEST_STATUS.md
- SEEKDB_FINAL_SUMMARY.md
- SEEKDB_INTEGRATION_DONE.md
- GITHUB_ISSUE_36_COMMENT.md

## Files Modified
- src/langbot/pkg/vector/mgr.py
- src/langbot/pkg/vector/vdbs/__init__.py
- pyproject.toml
- src/langbot/templates/config.yaml
- README.md
- README_EN.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

* chore: remove unused docs

* feature: minimal seekdb change (#1866)

* feat: add SeekDB embedding requester and configuration

This commit introduces a new SeekDB embedding requester, which utilizes the local embedding function from pyseekdb. It includes the necessary Python implementation and a corresponding YAML configuration file for integration. Additionally, a new SVG icon for SeekDB is added to enhance the visual representation in the UI.

* fix: update EmbeddingForm to conditionally render URL field based on model provider

This commit modifies the EmbeddingForm component to conditionally display the URL input field only when the current model provider is not 'seekdb-embedding'. Additionally, it updates the condition for rendering the API key field to exclude both 'ollama-chat' and 'seekdb-embedding' providers.

* chore: update Python version requirement in pyproject.toml to support Python 3.11

* fix: add config default value, when it makes fronted not show spec

* fix: seekdb.py clean metadata. change api

* fix: enhance error handling in SeekDB embedding initialization

This commit adds improved error handling to the SeekDB embedding function. It ensures that a RuntimeError is raised if the embedding function fails to initialize, and wraps the embedding call in a try-except block to catch and raise a RequesterError with a descriptive message in case of failure.

* refactor: update SeekDB database management to use AdminClient

This commit refactors the SeekDB database management logic to utilize the AdminClient for database operations. It replaces the previous temp_client with admin_client for listing and creating databases, ensuring a more robust interaction with the SeekDB API.

* refactor: update SeekDB embedding model initialization to use task manager

This commit refactors the SeekDB embedding model initialization by replacing the direct asyncio task creation with the task manager's create_task method. This change enhances task management and provides a clearer naming convention for the embedding model initialization task.

* perf: integration

* chore: remove unnecessary files

* fix: linter errors

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
Co-authored-by: 名为a的全局变量 <1051233107@qq.com>
2025-12-20 23:40:30 +08:00
fdc310
854b291c5a fix: n8n streaming no sequence bug (#1873) 2025-12-20 00:03:05 +08:00
Junyan Qin
9780fd059c chore: add back arm64 docker image (#1871) 2025-12-19 23:44:28 +08:00
Junyan Qin
adc65f66eb fix: pipeline duplication bug 2025-12-19 23:27:18 +08:00
Copilot
ae772074a1 feat: Add configurable password change toggle via system.allow_change_password (#1869)
* Initial plan

* Add password change toggle feature with config flag

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Feature implementation complete and validated

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* chore: remove lock

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-18 15:14:03 +08:00
dependabot[bot]
16c1e9edd1 chore(deps): bump next from 15.5.7 to 15.5.9 in /web (#1868)
Bumps [next](https://github.com/vercel/next.js) from 15.5.7 to 15.5.9.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.7...v15.5.9)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 12:21:02 +08:00
sheetung
3ab9ffb7b7 feat(plugins): add plugin new version detection (#1865)
* feat(plugins): 添加插件更新检测功能

* perf: card style

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-18 12:17:25 +08:00
Copilot
82e2123fe7 Fix Dify v1.11.0 conversation_id UUID validation error (#1860)
* Initial plan

* Fix Dify v1.11.0 conversation_id UUID validation error

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-12-12 18:35:47 +08:00
Junyan Qin
7a65f3d2f4 chore: update AGENTS.md 2025-12-12 17:35:02 +08:00
Junyan Qin
b5b5d499e5 feat: add back streaming switch for web chat 2025-12-11 18:54:16 +08:00
Hadong
173f9e9c30 feat(lark): 支持商店应用机器人 (#1855)
* feat(lark): 支持商店应用机器人

* feat(lark): app_type改成select模式,修复select配置无效,按照copilot建议隐藏log敏感信息

* fix: KeyError for backward compatibility

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-11 16:54:28 +08:00
Junyan Qin
a610c72067 chore: bump version 4.6.4 2025-12-10 14:22:57 +08:00
Junyan Qin
d210a49fae fix: react cve 2025-12-10 14:21:41 +08:00
Junyan Qin
b015c248ea chore: bump langbot-plugin to 0.2.3 2025-12-10 14:02:23 +08:00
Hadong
4a559ea770 feat: 飞书适配器加入“机器人进群欢迎语”配置 (#1852)
* feat(lark): 支持机器人进群发送欢迎消息

* perf: existence check and indent

---------

Co-authored-by: donghao <donghao@patsnap.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-09 16:37:03 +08:00
fdc310
e306751863 feat:add lark ubified_webhook and The configuration for the front-end regarding whether to enable webhooks for Lark is displayed. (#1850) 2025-12-09 13:30:45 +08:00
Junyan Qin
2f51f5f33e docs: apply README changes to all languages 2025-12-06 22:34:48 +08:00
Junyan Qin (Chin)
74a2a61fc1 Update README with new features and headings
Added a new heading and additional features to the README.
2025-12-06 22:21:49 +08:00
Junyan Qin
b6c0345b3e chore: bump version 4.6.3 2025-12-06 21:29:28 +08:00
Junyan Qin (Chin)
6421a6f5cb Feat/complete adapter features (#1849)
* feat: add voice and file supports for wecom

* feat: add   and  in query variables

* feat: supports for lark recv file message

* feat: kook recv voice msg

* feat: supports for Voice and File in discord

* chore: remove debug msg

* perf: remove unnecessary bot logs

* feat: implement bot log filtering and per label color (#1839)

* feat: add sender_name and group_name in query variables
2025-12-06 21:11:01 +08:00
Junyan Qin
daf56e5dc2 fix: test failed 2025-12-05 22:54:13 +08:00
Yaguang.Wang
cb7c9af25c feat: Expanded WeCom message parsing to capture msgtype, inline voice/video… (#1843)
* Expanded WeCom message parsing to capture msgtype, inline voice/video/file/link data, bounded base64 downloads, and richer mixed-message attachments (src/langbot/libs/wecom_ai_bot_api/api.py); added event accessors for new fields (src/langbot/libs/wecom_ai_bot_api/wecombotevent.py).
Converter now maps richer WeCom payloads (text, images, files, voice, video, links) into platform message chain with fallbacks when nothing parsable is present (src/langbot/pkg/platform/sources/wecombot.py).
Preprocessor now turns voice inputs into file URLs for downstream runners (src/langbot/pkg/pipeline/preproc/preproc.py).
Dify runner uploads all incoming files (images/audio/video/docs) after downloading or decoding data URLs, infers MIME types, and passes typed file descriptors into chat/workflow calls (src/langbot/pkg/provider/runners/difysvapi.py).

* Update src/langbot/pkg/platform/sources/wecombot.py

Fixed the issue of duplicate text in the comments.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/langbot/libs/wecom_ai_bot_api/api.py

Modify the way you approach challenges.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/langbot/pkg/platform/sources/wecombot.py

Changing the variable names makes more sense.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: use from_base64 for the voice file converting

---------

Co-authored-by: tabriswang <tabriswang@finecomn.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-05 22:33:15 +08:00
Junyan Qin
45e61befac fix: test failed 2025-12-05 22:30:44 +08:00
Junyan Qin
ea50ba10e6 perf: add en name in the wecom manifest 2025-12-05 21:28:56 +08:00
Junyan Qin
5c4a727e74 feat: make all db migrations SQL-only 2025-12-05 21:00:04 +08:00
Junyan Qin
867f05c4ad perf: make the timeout of emit_event 180s 2025-12-05 20:59:37 +08:00
Junyan Qin
b06b32306f feat: remove all unnecessary fields in GroupMember and implement MessageEvent field for pipeline events 2025-12-05 17:24:58 +08:00
Junyan Qin
dbfcb70f8d fix: sender_id not presented to Session 2025-12-05 17:13:30 +08:00
Junyan Qin
e64d56c4ac fix: bad protocol of default plugin debug url 2025-12-05 16:06:56 +08:00
Bruce
8f0da7943c Remove plugins volume from docker-compose (#1842) 2025-12-05 11:28:04 +08:00
Junyan Qin
e62ff7e520 fix: deps issues 2025-12-04 23:07:55 +08:00
Junyan Qin (Chin)
86e951916e feat: add milvus and pgvector as vector db (#1840)
* feat: add milvus and pgvector as vector db

* chore: update config.yaml template delete comments
2025-12-04 22:34:49 +08:00
Junyan Qin
6bf08466de chore: bump version 4.6.2 2025-12-04 20:30:02 +08:00
Junyan Qin
5e36dd480d docs: add KOOK in README 2025-12-04 13:56:56 +08:00
Junyan Qin (Chin)
0e2cd8c018 Feat/kook (#1834)
* feat: add adapter file

* fix: style for bot log

* fix: kook bugs
2025-12-04 13:40:38 +08:00
Junyan Qin (Chin)
b4f92eba38 feat(platform): add skip_pipeline parameter for webhook responses (#1837)
* feat(platform): add skip_pipeline parameter for webhook responses

Add support for skip_pipeline parameter in webhook responses, allowing
webhook targets to instruct LangBot to skip pipeline processing for
specific messages. When a webhook responds with skip_pipeline=true,
the message is treated as a notification only and bypasses the query pool.

Changes:
- webhook_pusher.py: Parse JSON responses and return skip_pipeline flag
- botmgr.py: Check skip_pipeline before adding messages to query pool
- docker-compose.yaml: Add DNS configuration to fix container networking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: webhook crud bug

* chore: revert docker-compose.yaml

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 13:40:26 +08:00
dependabot[bot]
905e48c8ed chore(deps): bump next from 15.4.7 to 15.4.8 in /web (#1836)
Bumps [next](https://github.com/vercel/next.js) from 15.4.7 to 15.4.8.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.4.7...v15.4.8)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.4.8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 11:33:55 +08:00
Junyan Qin
10ec79312e chore: bump version 4.6.1 2025-12-02 17:43:38 +08:00
Junyan Qin
24f779ff95 fix: websocket connect failed in prod env 2025-12-02 17:41:31 +08:00
Junyan Qin
08c0677de9 chore: bump version 4.6.0 2025-12-02 13:58:08 +08:00
Junyan Qin
cc5d32cf8a chore: bump langbot-plugin to 0.2.0 2025-12-01 22:15:38 +08:00
Junyan Qin
01a5133396 chore: update docker-compose.yaml 2025-12-01 22:14:38 +08:00
Guanchao Wang
0aa5188b29 Feat/unified webhook (#1793)
* fix: wecombot id

* feat: add unified webhook for wecom

* feat: add support for wecombot,wxoa,slack and qqo

* fix: slack adapter

* feat: qqo

* fix: errors when npm lint

* fix: qqo webhook

* feat: add wecomcs

* fix: modify wecomcs

* fix: import errors

* feat: add configurable webhook display prefix (#1797)

* Initial plan

* Add webhook_display_prefix configuration option

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: change config field name

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>

* feat: finish the fxxking line adapter

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2025-12-01 22:09:20 +08:00
Junyan Qin (Chin)
e49a161d0a feat: displaying plugin debug info (#1828) 2025-12-01 17:59:49 +08:00
Junyan Qin
0ddc3d60e7 fix: incorrect update date in kb card 2025-12-01 14:35:41 +08:00
Junyan Qin
51794176af perf: add comment for installing KB retriever plugins 2025-12-01 14:04:32 +08:00
Copilot
b634aa48dc feat(web): Add markdown rendering support to pipeline chat messages with toggle (#1826)
* Initial plan

* Add markdown rendering support to pipeline debug dialog messages with toggle button

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix code review feedback: remove conflicting styles and imports

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: styles

* fix: websocket message broadcasting cross-contamination between person and group channels

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-12-01 13:44:01 +08:00
211 changed files with 18652 additions and 14624 deletions

8
.dockerignore Normal file
View File

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

View File

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

View File

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

View File

@@ -8,16 +8,17 @@ LangBot is a open-source LLM native instant messaging bot development platform,
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
- `./pkg`: The core python package of the project backend.
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
- `./templates`: Templates of config files, components, etc.
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
- `./docker`: docker-compose deployment files.
- `./src/langbot`: The main python package of the project, below are the main modules in this package:
- `./pkg`: The core python package of the project backend.
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
- `./templates`: Templates of config files, components, etc.
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
- `./docker`: docker-compose deployment files.
## Backend Development
@@ -69,6 +70,7 @@ Plugin Runtime automatically starts each installed plugin and interacts through
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.
## Some Principles

View File

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

View File

@@ -1,33 +1,35 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>使用 LangBot 快速构建、调试、部署即时通信机器人。</h3>
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-1030838208-blue)](https://qm.qq.com/q/DxZZcNxM1W)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/langbot-app/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/langbot-app/LangBot)](https://github.com/langbot-app/LangBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">项目主页</a>
<a href="https://docs.langbot.app/zh/insight/features.html">规格特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文档</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a>
<a href="https://docs.langbot.app/zh/tags/readme.html">API 集成</a>
<a href="https://space.langbot.app">插件市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
</div>
</p>
LangBot 是一个开源的大语言模型原生即时通信机器人开发平台,旨在提供开箱即用的 IM 机器人开发体验,具有 Agent、RAG、MCP 等多种 LLM 应用功能,适配全球主流即时通信平台,并提供丰富的 API 接口,支持自定义开发。
## 📦 开始使用
@@ -83,8 +85,11 @@ docker compose up -d
## ✨ 特性
<img width="500" src="https://docs.langbot.app/ui/bot-page-zh-rounded.png" />
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
@@ -108,6 +113,7 @@ docker compose up -d
| 微信公众号 | ✅ | |
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
| KOOK | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |

View File

@@ -1,12 +1,14 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Quickly build, debug, and ship IM bots with LangBot.</h3>
English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,15 +17,16 @@ English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語]
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Home</a>
<a href="https://docs.langbot.app/en/insight/features.html">Features</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Submit Plugin</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API Integration</a>
<a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
</div>
</p>
LangBot is an open-source LLM native instant messaging robot development platform, aiming to provide out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, adapting to global instant messaging platforms, and providing rich API interfaces, supporting custom development.
## 📦 Getting Started
@@ -79,8 +82,11 @@ Click the Star and Watch button in the upper right corner of the repository to g
## ✨ Features
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
- 🧩 Plugin Extension, Active Community: High stability, high security production-level plugin system; Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
@@ -107,6 +113,7 @@ Or visit the demo environment: https://demo.langbot.dev/
| Personal WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,12 +1,14 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Cree, depure y despliegue bots de mensajería instantánea rápidamente con LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,15 +17,16 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Inicio</a>
<a href="https://docs.langbot.app/en/insight/features.html">Características</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Despliegue</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Enviar Plugin</a>
<a href="https://docs.langbot.app/en/tags/readme.html">Integración API</a>
<a href="https://space.langbot.app">Mercado de Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
</div>
</p>
LangBot es una plataforma de desarrollo de robots de mensajería instantánea nativa de LLM de código abierto, con el objetivo de proporcionar una experiencia de desarrollo de robots de mensajería instantánea lista para usar, con funciones de aplicación LLM como Agent, RAG, MCP, adaptándose a plataformas de mensajería instantánea globales y proporcionando interfaces API ricas, compatible con desarrollo personalizado.
## 📦 Comenzar
@@ -79,8 +82,11 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
## ✨ Características
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, etc.
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios.
- 🧩 Extensión de Plugin, Comunidad Activa: Sistema de plugin de alta estabilidad, alta seguridad de nivel de producción; Compatible con mecanismos de plugin impulsados por eventos, extensión de componentes, etc.; Integración del protocolo [MCP](https://modelcontextprotocol.io/) de Anthropic; Actualmente cuenta con cientos de plugins.
- 😻 Interfaz Web: Admite la gestión de instancias de LangBot a través del navegador. No es necesario escribir archivos de configuración manualmente.
@@ -107,6 +113,7 @@ O visite el entorno de demostración: https://demo.langbot.dev/
| WeChat Personal | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,12 +1,14 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Créez, déboguez et déployez rapidement des bots de messagerie instantanée avec LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,16 +17,16 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Accueil</a>
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Déploiement</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Soumettre un Plugin</a>
<a href="https://docs.langbot.app/en/tags/readme.html">Intégration API</a>
<a href="https://space.langbot.app">Marché des Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
</div>
</p>
LangBot est une plateforme de développement de robots de messagerie instantanée native LLM open source, visant à fournir une expérience de développement de robots de messagerie instantanée prête à l'emploi, avec des fonctionnalités d'application LLM telles qu'Agent, RAG, MCP, s'adaptant aux plateformes de messagerie instantanée mondiales et fournissant des interfaces API riches, prenant en charge le développement personnalisé.
## 📦 Commencer
#### Démarrage Rapide
@@ -79,8 +81,11 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
## ✨ Fonctionnalités
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, etc.
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios.
- 🧩 Extension de Plugin, Communauté Active : Système de plugin de haute stabilité, haute sécurité de niveau production; Prend en charge les mécanismes de plugin pilotés par événements, l'extension de composants, etc. ; Intégration du protocole [MCP](https://modelcontextprotocol.io/) d'Anthropic ; Dispose actuellement de centaines de plugins.
- 😻 Interface Web : Prend en charge la gestion des instances LangBot via le navigateur. Pas besoin d'écrire manuellement les fichiers de configuration.
@@ -107,6 +112,7 @@ Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
| WeChat Personnel | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,12 +1,14 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>LangBotでIMボットを素早く構築、デバッグ、デプロイ。</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,16 +17,16 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">ホーム</a>
<a href="https://docs.langbot.app/en/insight/guide.html">デプロイ</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">プラグイン</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">プラグインの提出</a>
<a href="https://docs.langbot.app/ja/insight/features.html">機能仕様</a>
<a href="https://docs.langbot.app/ja/insight/guide.html">デプロイ</a>
<a href="https://docs.langbot.app/ja/tags/readme.html">API統合</a>
<a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
</div>
</p>
LangBot は、エージェント、RAG、MCP などの LLM アプリケーション機能を備えた、オープンソースの LLM ネイティブのインスタントメッセージングロボット開発プラットフォームです。世界中のインスタントメッセージングプラットフォームに適応し、豊富な API インターフェースを提供し、カスタム開発をサポートします。
## 📦 始め方
#### クイックスタート
@@ -79,8 +81,11 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
## ✨ 機能
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
@@ -107,6 +112,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| 個人WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
### LLMs

View File

@@ -1,12 +1,14 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>LangBot으로 IM 봇을 빠르게 구축, 디버그 및 배포하세요.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,16 +17,16 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">홈</a>
<a href="https://docs.langbot.app/en/insight/features.html">기능 사양</a>
<a href="https://docs.langbot.app/en/insight/guide.html">배포</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">플러그인</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">플러그인 제출</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API 통합</a>
<a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
</div>
</p>
LangBot은 오픈 소스 LLM 네이티브 인스턴트 메시징 로봇 개발 플랫폼으로, Agent, RAG, MCP 등 다양한 LLM 애플리케이션 기능을 갖춘 즉시 사용 가능한 IM 로봇 개발 경험을 제공하며, 글로벌 인스턴트 메시징 플랫폼에 적응하고 풍부한 API 인터페이스를 제공하여 맞춤형 개발을 지원합니다.
## 📦 시작하기
#### 빠른 시작
@@ -79,8 +81,11 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
## ✨ 기능
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다.
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram 등을 지원합니다.
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.
- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
@@ -105,6 +110,7 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| 개인 WeChat | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -1,12 +1,14 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Быстро создавайте, отлаживайте и развертывайте IM-ботов с LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,16 +17,16 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Главная</a>
<a href="https://docs.langbot.app/en/insight/features.html">Характеристики</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Развертывание</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Плагин</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Отправить плагин</a>
<a href="https://docs.langbot.app/en/tags/readme.html">Интеграция API</a>
<a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
</div>
</p>
LangBot — это платформа разработки ботов для мгновенных сообщений на основе LLM с открытым исходным кодом, целью которой является предоставление готового к использованию опыта разработки ботов для IM, с функциями приложений LLM, такими как Agent, RAG, MCP, адаптацией к глобальным платформам мгновенных сообщений и предоставлением богатых API-интерфейсов, поддерживающих пользовательскую разработку.
## 📦 Начало работы
#### Быстрый старт
@@ -79,8 +81,11 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
## ✨ Функции
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다.
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram и т.д.
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д.
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.
- 🧩 Расширение плагинов, активное сообщество: Высокая стабильность, высокая безопасность уровня производства; Поддержка механизмов плагинов, управляемых событиями, расширения компонентов и т.д.; Интеграция протокола [MCP](https://modelcontextprotocol.io/) от Anthropic; В настоящее время сотни плагинов.
- 😻 Веб-интерфейс: Поддержка управления экземплярами LangBot через браузер. Нет необходимости вручную писать конфигурационные файлы.
@@ -105,6 +110,7 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| Личный WeChat | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -1,10 +1,12 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center"><a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>使用 LangBot 快速建構、除錯和部署 IM 機器人。</h3>
[English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,16 +17,16 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">主頁</a>
<a href="https://docs.langbot.app/zh/insight/features.html">規格特性</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">部署文件</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">外掛介紹</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交外掛</a>
<a href="https://docs.langbot.app/zh/tags/readme.html">API 整合</a>
<a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
</div>
</p>
LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台,旨在提供開箱即用的 IM 機器人開發體驗,具有 Agent、RAG、MCP 等多種 LLM 應用功能,適配全球主流即時通訊平台,並提供豐富的 API 介面,支援自定義開發。
## 📦 開始使用
#### 快速部署
@@ -79,8 +81,11 @@ docker compose up -d
## ✨ 特性
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
@@ -105,6 +110,7 @@ docker compose up -d
| 企微對外客服 | ✅ | |
| 企微智能機器人 | ✅ | |
| 微信公眾號 | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

@@ -1,12 +1,14 @@
<p align="center">
<a href="https://langbot.app">
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
</a>
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<h3>Xây dựng, gỡ lỗi và triển khai bot IM nhanh chóng với LangBot.</h3>
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
@@ -15,16 +17,16 @@
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
<a href="https://langbot.app">Trang chủ</a>
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Triển khai</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a>
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Gửi Plugin</a>
<a href="https://docs.langbot.app/en/tags/readme.html">Tích hợp API</a>
<a href="https://space.langbot.app">Chợ Plugin</a>
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
</div>
</p>
LangBot là một nền tảng phát triển robot nhắn tin tức thời gốc LLM mã nguồn mở, nhằm mục đích cung cấp trải nghiệm phát triển robot IM sẵn sàng sử dụng, với các chức năng ứng dụng LLM như Agent, RAG, MCP, thích ứng với các nền tảng nhắn tin tức thời toàn cầu và cung cấp giao diện API phong phú, hỗ trợ phát triển tùy chỉnh.
## 📦 Bắt đầu
#### Khởi động Nhanh
@@ -79,8 +81,11 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t
## ✨ Tính năng
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms.
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, v.v.
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v.
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau.
- 🧩 Mở rộng Plugin, Cộng đồng Hoạt động: Hỗ trợ các cơ chế plugin hướng sự kiện, mở rộng thành phần, v.v.; Tích hợp giao thức [MCP](https://modelcontextprotocol.io/) của Anthropic; Hiện có hàng trăng plugin.
- 😻 Giao diện Web: Hỗ trợ quản lý các phiên bản LangBot thông qua trình duyệt. Không cần viết tệp cấu hình thủ công.
@@ -105,6 +110,7 @@ Hoặc truy cập môi trường demo: https://demo.langbot.dev/
| WeComCS | ✅ | |
| WeCom AI Bot | ✅ | |
| WeChat Cá nhân | ✅ | |
| KOOK | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |

View File

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

259
docs/SEEKDB_INTEGRATION.md Normal file
View File

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

View File

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

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
"""LangBot - Production-grade platform for building IM bots"""
__version__ = '4.5.4'
__version__ = '4.7.0'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,12 +9,15 @@ class LLMModelsRouterGroup(group.RouterGroup):
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)}
)
return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.llm_model_service.create_llm_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
@@ -52,12 +55,19 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={
'models': await self.ap.embedding_models_service.get_embedding_models_by_provider(
provider_uuid
)
}
)
return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)

View File

@@ -0,0 +1,45 @@
import quart
from ... import group
@group.group_class('models/providers', '/api/v1/provider/providers')
class ModelProvidersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
providers = await self.ap.provider_service.get_providers()
# Add model counts
for provider in providers:
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
return self.success(data={'providers': providers})
elif quart.request.method == 'POST':
json_data = await quart.request.json
provider_uuid = await self.ap.provider_service.create_provider(json_data)
return self.success(data={'uuid': provider_uuid})
@self.route(
'/<provider_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(provider_uuid: str) -> str:
if quart.request.method == 'GET':
provider = await self.ap.provider_service.get_provider(provider_uuid)
if provider is None:
return self.http_status(404, -1, 'provider not found')
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
return self.success(data={'provider': provider})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.provider_service.update_provider(provider_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
try:
await self.ap.provider_service.delete_provider(provider_uuid)
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))

View File

@@ -17,11 +17,13 @@ class SystemRouterGroup(group.RouterGroup):
'enable_marketplace', True
),
'cloud_service_url': (
self.ap.instance_config.data.get('plugin', {}).get(
'cloud_service_url', 'https://space.langbot.app'
)
if 'cloud_service_url' in self.ap.instance_config.data.get('plugin', {})
else 'https://space.langbot.app'
self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app')
),
'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
),
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
'disable_models_service', False
),
}
)

View File

@@ -1,8 +1,10 @@
import quart
import argon2
import asyncio
import traceback
from .. import group
from .....entity.errors import account as account_errors
@group.group_class('user', '/api/v1/user')
@@ -33,6 +35,8 @@ class UserRouterGroup(group.RouterGroup):
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
except argon2.exceptions.VerifyMismatchError:
return self.fail(1, 'Invalid username or password')
except ValueError as e:
return self.fail(1, str(e))
return self.success(data={'token': token})
@@ -70,6 +74,13 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
# Check if password change is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = await quart.request.json
current_password = json_data['current_password']
@@ -83,3 +94,169 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, str(e))
return self.success(data={'user': user_email})
# Space OAuth endpoints (redirect flow)
@self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get Space OAuth authorization URL for redirect"""
redirect_uri = quart.request.args.get('redirect_uri', '')
state = quart.request.args.get('state', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
try:
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
except Exception as e:
return self.fail(1, str(e))
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Handle OAuth callback - exchange code for tokens and authenticate"""
json_data = await quart.request.json
code = json_data.get('code')
if not code:
return self.fail(1, 'Missing authorization code')
try:
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
# Authenticate and create/update local user
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in
)
return self.success(
data={
'token': jwt_token,
'user': user_obj.user,
}
)
except account_errors.AccountEmailMismatchError as e:
return self.fail(3, str(e))
except ValueError as e:
traceback.print_exc()
return self.fail(1, str(e))
except Exception as e:
traceback.print_exc()
return self.fail(2, f'OAuth callback failed: {str(e)}')
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get current user information including account type"""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
return self.success(
data={
'user': user_obj.user,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
}
)
@self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Get Space credits balance for current user"""
credits = await self.ap.space_service.get_credits(user_email)
return self.success(data={'credits': credits})
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get account info for login page (account type and has_password)"""
if not await self.ap.user_service.is_initialized():
return self.success(data={'initialized': False})
user_obj = await self.ap.user_service.get_first_user()
if user_obj is None:
return self.success(data={'initialized': False})
return self.success(
data={
'initialized': True,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
}
)
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Set password for Space account (first time) or change password"""
json_data = await quart.request.json
new_password = json_data.get('new_password')
current_password = json_data.get('current_password')
if not new_password:
return self.http_status(400, -1, 'New password is required')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
try:
await self.ap.user_service.set_password(user_email, new_password, current_password)
return self.success(data={'user': user_email})
except ValueError as e:
return self.http_status(400, -1, str(e))
except argon2.exceptions.VerifyMismatchError:
return self.http_status(400, -1, 'Current password is incorrect')
@self.route('/bind-space', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Bind Space account to existing local account"""
# Check if modifying login info is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state') # JWT token passed as state
if not code:
return self.http_status(400, -1, 'Missing authorization code')
if not state:
return self.http_status(400, -1, 'Missing state parameter')
# Verify state is a valid JWT token
try:
user_email = await self.ap.user_service.verify_jwt_token(state)
except Exception:
return self.http_status(401, -1, 'Invalid or expired state')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
if user_obj.account_type != 'local':
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
updated_user = await self.ap.user_service.bind_space_account(user_email, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
return self.success(
data={
'token': jwt_token,
'user': updated_user.user,
'account_type': updated_user.account_type,
}
)
except ValueError as e:
return self.http_status(400, -1, str(e))
except Exception as e:
return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')

View File

@@ -0,0 +1,49 @@
import quart
from .. import group
@group.group_class('webhook_mgmt', '/api/v1/webhooks')
class WebhookManagementRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'])
async def _() -> str:
if quart.request.method == 'GET':
webhooks = await self.ap.webhook_service.get_webhooks()
return self.success(data={'webhooks': webhooks})
elif quart.request.method == 'POST':
json_data = await quart.request.json
name = json_data.get('name', '')
url = json_data.get('url', '')
description = json_data.get('description', '')
enabled = json_data.get('enabled', True)
if not name:
return self.http_status(400, -1, 'Name is required')
if not url:
return self.http_status(400, -1, 'URL is required')
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
return self.success(data={'webhook': webhook})
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
async def _(webhook_id: int) -> str:
if quart.request.method == 'GET':
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
if webhook is None:
return self.http_status(404, -1, 'Webhook not found')
return self.success(data={'webhook': webhook})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
name = json_data.get('name')
url = json_data.get('url')
description = json_data.get('description')
enabled = json_data.get('enabled')
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.webhook_service.delete_webhook(webhook_id)
return self.success()

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,18 @@ from ....entity.persistence import pipeline as persistence_pipeline
from ....provider.modelmgr import requester as model_requester
def _parse_provider_api_keys(provider_dict: dict) -> dict:
"""Parse api_keys if it's a JSON string"""
if isinstance(provider_dict.get('api_keys'), str):
import json
try:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
return provider_dict
class LLMModelsService:
ap: app.Application
@@ -18,29 +30,72 @@ class LLMModelsService:
self.ap = ap
async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
"""Get all LLM models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
models = result.all()
masked_columns = []
if not include_secret:
masked_columns = ['api_keys']
# Get all providers for lookup
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
providers = {p.uuid: p for p in providers_result.all()}
return [
self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model, masked_columns)
for model in models
]
models_list = []
for model in models:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
provider_dict = _parse_provider_api_keys(provider_dict)
if not include_secret:
provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', []))
model_dict['provider'] = provider_dict
models_list.append(model_dict)
async def create_llm_model(self, model_data: dict) -> str:
model_data['uuid'] = str(uuid.uuid4())
return models_list
async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]:
"""Get LLM models by provider UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
async def create_llm_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new LLM model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
# Handle provider creation if needed
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
# Create new provider
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
llm_model = await self.get_llm_model(model_data['uuid'])
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.load_llm_model(llm_model)
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
# check if default pipeline has no model bound
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
@@ -56,21 +111,47 @@ class LLMModelsService:
return model_data['uuid']
async def get_llm_model(self, model_uuid: str) -> dict | None:
"""Get a single LLM model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
)
model = result.first()
if model is None:
return None
return self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
# Get provider
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
return model_dict
async def update_llm_model(self, model_uuid: str, model_data: dict) -> None:
"""Update an existing LLM model"""
if 'uuid' in model_data:
del model_data['uuid']
# Handle provider update if needed
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.LLMModel)
.where(persistence_model.LLMModel.uuid == model_uuid)
@@ -79,18 +160,25 @@ class LLMModelsService:
await self.ap.model_mgr.remove_llm_model(model_uuid)
llm_model = await self.get_llm_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.load_llm_model(llm_model)
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
async def delete_llm_model(self, model_uuid: str) -> None:
"""Delete an LLM model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
)
await self.ap.model_mgr.remove_llm_model(model_uuid)
async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:
"""Test an LLM model"""
runtime_llm_model: model_requester.RuntimeLLMModel | None = None
if model_uuid != '_':
@@ -98,25 +186,18 @@ class LLMModelsService:
if model.model_entity.uuid == model_uuid:
runtime_llm_model = model
break
if runtime_llm_model is None:
raise Exception('model not found')
else:
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data)
# Mon Nov 10 2025: Commented for some providers may not support thinking parameter
# # 有些模型厂商默认开启了思考功能,测试容易延迟
# extra_args = model_data.get('extra_args', {})
# if not extra_args or 'thinking' not in extra_args:
# extra_args['thinking'] = {'type': 'disabled'}
await runtime_llm_model.requester.invoke_llm(
extra_args = model_data.get('extra_args', {})
await runtime_llm_model.provider.requester.invoke_llm(
query=None,
model=runtime_llm_model,
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
funcs=[],
# extra_args=extra_args,
extra_args=extra_args,
)
@@ -127,42 +208,111 @@ class EmbeddingModelsService:
self.ap = ap
async def get_embedding_models(self) -> list[dict]:
"""Get all embedding models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model) for model in models]
async def create_embedding_model(self, model_data: dict) -> str:
model_data['uuid'] = str(uuid.uuid4())
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
providers = {p.uuid: p for p in providers_result.all()}
models_list = []
for model in models:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
models_list.append(model_dict)
return models_list
async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]:
"""Get embedding models by provider UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new embedding model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
)
embedding_model = await self.get_embedding_model(model_data['uuid'])
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.load_embedding_model(embedding_model)
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
return model_data['uuid']
async def get_embedding_model(self, model_uuid: str) -> dict | None:
"""Get a single embedding model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid
)
)
model = result.first()
if model is None:
return None
return self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
return model_dict
async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None:
"""Update an existing embedding model"""
if 'uuid' in model_data:
del model_data['uuid']
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.uuid == model_uuid)
@@ -171,20 +321,27 @@ class EmbeddingModelsService:
await self.ap.model_mgr.remove_embedding_model(model_uuid)
embedding_model = await self.get_embedding_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
await self.ap.model_mgr.load_embedding_model(embedding_model)
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
async def delete_embedding_model(self, model_uuid: str) -> None:
"""Delete an embedding model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.uuid == model_uuid
)
)
await self.ap.model_mgr.remove_embedding_model(model_uuid)
async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None:
"""Test an embedding model"""
runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None
if model_uuid != '_':
@@ -192,14 +349,12 @@ class EmbeddingModelsService:
if model.model_entity.uuid == model_uuid:
runtime_embedding_model = model
break
if runtime_embedding_model is None:
raise Exception('model not found')
else:
runtime_embedding_model = await self.ap.model_mgr.init_runtime_embedding_model(model_data)
runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)
await runtime_embedding_model.requester.invoke_embedding(
await runtime_embedding_model.provider.requester.invoke_embedding(
model=runtime_embedding_model,
input_text=['Hello, world!'],
extra_args={},

View File

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

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import uuid
import sqlalchemy
from ....core import app
from ....entity.persistence import model as persistence_model
class ModelProviderService:
"""Service for managing model providers"""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_providers(self) -> list[dict]:
"""Get all providers"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
providers = result.all()
providers_list = []
for p in providers:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, p)
# Parse api_keys if it's a JSON string
if isinstance(provider_dict.get('api_keys'), str):
import json
try:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
providers_list.append(provider_dict)
return providers_list
async def get_provider(self, provider_uuid: str) -> dict | None:
"""Get a single provider by UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
)
)
provider = result.first()
if provider is None:
return None
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
# Parse api_keys if it's a JSON string
if isinstance(provider_dict.get('api_keys'), str):
import json
try:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
return provider_dict
async def create_provider(self, provider_data: dict) -> str:
"""Create a new provider"""
provider_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
# load to runtime
runtime_provider = await self.ap.model_mgr.load_provider(provider_data)
self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider
return provider_data['uuid']
async def update_provider(self, provider_uuid: str, provider_data: dict) -> None:
"""Update an existing provider"""
if 'uuid' in provider_data:
del provider_data['uuid']
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
.values(**provider_data)
)
await self.ap.model_mgr.reload_provider(provider_uuid)
async def delete_provider(self, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
)
)
if llm_result.first() is not None:
raise ValueError('Cannot delete provider: LLM models still reference it')
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
)
)
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
)
)
await self.ap.model_mgr.remove_provider(provider_uuid)
async def get_provider_model_counts(self, provider_uuid: str) -> dict:
"""Get count of models using this provider"""
llm_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.LLMModel)
.where(persistence_model.LLMModel.provider_uuid == provider_uuid)
)
llm_count = llm_result.scalar() or 0
embedding_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.EmbeddingModel)
.where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid)
)
embedding_count = embedding_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count}
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
"""Find existing provider or create new one"""
# Try to find existing provider with same config
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == requester,
persistence_model.ModelProvider.base_url == base_url,
)
)
for provider in result.all():
if sorted(provider.api_keys or []) == sorted(api_keys or []):
return provider.uuid
# Create new provider
provider_name = requester
if base_url:
try:
from urllib.parse import urlparse
parsed = urlparse(base_url)
provider_name = parsed.netloc or requester
except Exception:
pass
return await self.create_provider(
{
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys or [],
}
)
async def update_space_model_provider_api_keys(self, api_key: str) -> None:
"""Update Space model provider API keys"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
.values(api_keys=[api_key])
)
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')

View File

@@ -0,0 +1,189 @@
from __future__ import annotations
import aiohttp
import typing
import datetime
import time
import sqlalchemy
from ....core import app
from ....entity.persistence import user
from ....entity.dto.space_model import SpaceModel
class SpaceService:
"""Service for interacting with LangBot Space API"""
ap: app.Application
_credits_cache: typing.Dict[str, typing.Tuple[int, float]] # {user_email: (credits, timestamp)}
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self._credits_cache = {}
def _get_space_config(self) -> typing.Dict[str, str]:
"""Get Space configuration from config file"""
space_config = self.ap.instance_config.data.get('space', {})
return {
'url': space_config.get('url', 'https://space.langbot.app'),
'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'),
}
async def _get_user_by_email(self, user_email: str) -> user.User | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email)
)
result_list = result.all()
return result_list[0] if result_list else None
async def _ensure_valid_token(self, user_email: str) -> str | None:
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
user_obj = await self._get_user_by_email(user_email)
if not user_obj or user_obj.account_type != 'space':
return None
if not user_obj.space_access_token:
return None
# Check if token is expired (with 60s buffer)
if user_obj.space_access_token_expires_at:
if datetime.datetime.now() >= user_obj.space_access_token_expires_at - datetime.timedelta(seconds=60):
# Token expired, try to refresh
if user_obj.space_refresh_token:
try:
new_token = await self._refresh_and_save_token(user_obj)
return new_token
except Exception:
return None
return None
return user_obj.space_access_token
async def _refresh_and_save_token(self, user_obj: user.User) -> str:
"""Refresh token and save to database"""
token_data = await self.refresh_token(user_obj.space_refresh_token)
access_token = token_data.get('access_token')
expires_in = token_data.get('expires_in', 0)
if not access_token:
raise ValueError('Failed to refresh token')
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == user_obj.user)
.values(
space_access_token=access_token,
space_access_token_expires_at=expires_at,
)
)
return access_token
# === Raw API calls (no token validation) ===
def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
"""Get the Space OAuth authorization URL for redirect"""
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
params = f'redirect_uri={redirect_uri}'
if state:
params += f'&state={state}'
return f'{authorize_url}?{params}'
async def exchange_oauth_code(self, code: str) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.post(
f'{space_url}/api/v1/accounts/oauth/token',
json={'code': code, 'instance_id': constants.instance_id},
) as response:
if response.status != 200:
raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}')
return data.get('data', {})
async def refresh_token(self, refresh_token: str) -> typing.Dict:
"""Refresh Space access token"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.post(
f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}
) as response:
if response.status != 200:
raise ValueError(f'Failed to refresh token: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to refresh token: {data.get("msg")}')
return data.get('data', {})
async def get_user_info_raw(self, access_token: str) -> typing.Dict:
"""Get user info from Space using access token (no validation)"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.get(
f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}
) as response:
if response.status != 200:
raise ValueError(f'Failed to get user info: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to get user info: {data.get("msg")}')
return data.get('data', {})
# === API calls with token validation ===
async def get_user_info(self, user_email: str) -> typing.Dict | None:
"""Get user info from Space (with token validation)"""
access_token = await self._ensure_valid_token(user_email)
if not access_token:
return None
return await self.get_user_info_raw(access_token)
async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None:
"""Get Space credits for user with caching (60s TTL)"""
cache_ttl = 60
if not force_refresh and user_email in self._credits_cache:
credits, ts = self._credits_cache[user_email]
if time.time() - ts < cache_ttl:
return credits
try:
info = await self.get_user_info(user_email)
if info is None:
return None
credits = info.get('credits')
if credits is not None:
self._credits_cache[user_email] = (credits, time.time())
return credits
except Exception:
return self._credits_cache.get(user_email, (None, 0))[0]
async def get_models(self) -> typing.List[SpaceModel]:
"""Get models from Space"""
space_config = self._get_space_config()
space_url = space_config['url']
async with aiohttp.ClientSession() as session:
async with session.get(f'{space_url}/api/v1/models') as response:
if response.status != 200:
raise ValueError(f'Failed to get models: {await response.text()}')
data = await response.json()
if data.get('code') != 0:
raise ValueError(f'Failed to get models: {data.get("msg")}')
models_data = data.get('data', {}).get('models', [])
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]

View File

@@ -4,17 +4,22 @@ import sqlalchemy
import argon2
import jwt
import datetime
import typing
import asyncio
from ....core import app
from ....entity.persistence import user
from ....utils import constants
from ....entity.errors import account as account_errors
class UserService:
ap: app.Application
_create_user_lock: asyncio.Lock
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self._create_user_lock = asyncio.Lock()
async def is_initialized(self) -> bool:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
@@ -28,7 +33,7 @@ class UserService:
hashed_password = ph.hash(password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password)
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
)
async def get_user_by_email(self, user_email: str) -> user.User | None:
@@ -39,6 +44,15 @@ class UserService:
result_list = result.all()
return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
"""Get user by Space account UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid)
)
result_list = result.all()
return result_list[0] if result_list is not None and len(result_list) > 0 else None
async def authenticate(self, user_email: str, password: str) -> str | None:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(user.User).where(user.User.user == user_email)
@@ -51,6 +65,10 @@ class UserService:
user_obj = result_list[0]
# Check if this is a Space account
if user_obj.account_type == 'space':
raise ValueError('请使用 Space 账户登录')
ph = argon2.PasswordHasher()
ph.verify(user_obj.password, password)
@@ -90,6 +108,10 @@ class UserService:
if user_obj is None:
raise ValueError('User not found')
# Space accounts cannot change password locally
if user_obj.account_type == 'space':
raise ValueError('Space account cannot change password locally')
ph.verify(user_obj.password, current_password)
hashed_password = ph.hash(new_password)
@@ -97,3 +119,180 @@ class UserService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
)
# Space user management
async def create_or_update_space_user(
self,
space_account_uuid: str,
email: str,
access_token: str,
refresh_token: str,
api_key: str,
expires_in: int = 0,
) -> user.User:
"""Create or update a Space user account (only if system not initialized or user exists)"""
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
async with self._create_user_lock:
# Check if user with this Space UUID already exists
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_user:
# Update existing user's tokens
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.space_account_uuid == space_account_uuid)
.values(
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_space_account_uuid(space_account_uuid)
# Check if user with same email exists
existing_email_user = await self.get_user_by_email(email)
if existing_email_user:
# Update existing user to link with Space account
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == email)
.values(
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_email(email)
# Check if system is already initialized
is_initialized = await self.is_initialized()
if is_initialized:
raise account_errors.AccountEmailMismatchError()
# Create new Space user (first time initialization)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(
user=email,
password='', # Space users don't have local password
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_space_account_uuid(space_account_uuid)
async def authenticate_space_user(
self, access_token: str, refresh_token: str, expires_in: int = 0
) -> typing.Tuple[str, user.User]:
"""Authenticate with Space and return JWT token"""
# Get user info from Space using raw API (token just obtained, no need to validate)
user_info = await self.ap.space_service.get_user_info_raw(access_token)
account = user_info.get('account', {})
api_key = user_info.get('api_key', '')
space_account_uuid = account.get('uuid')
email = account.get('email')
if not space_account_uuid or not email:
raise ValueError('Invalid Space user info')
# Create or update Space user in local database
user_obj = await self.create_or_update_space_user(
space_account_uuid=space_account_uuid,
email=email,
access_token=access_token,
refresh_token=refresh_token,
api_key=api_key,
expires_in=expires_in,
)
# Generate JWT token
jwt_token = await self.generate_jwt_token(email)
return jwt_token, user_obj
async def get_first_user(self) -> user.User | None:
"""Get the first user (for single-user mode)"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
result_list = result.all()
return result_list[0] if result_list else None
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
"""Set or change password for a user"""
ph = argon2.PasswordHasher()
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
raise ValueError('User not found')
# If user already has a password, verify current password
has_password = bool(user_obj.password and user_obj.password.strip())
if has_password:
if not current_password:
raise ValueError('Current password is required')
ph.verify(user_obj.password, current_password)
hashed_password = ph.hash(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
)
async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account"""
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
if not access_token:
raise ValueError('Failed to get access token from Space')
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
# Get Space user info (token just obtained, use raw API)
user_info = await self.ap.space_service.get_user_info_raw(access_token)
account = user_info.get('account', {})
api_key = user_info.get('api_key', '')
space_account_uuid = account.get('uuid')
space_email = account.get('email')
if not space_account_uuid or not space_email:
raise ValueError('Invalid Space user info')
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.user != user_email:
raise ValueError('This Space account is already bound to another user')
# Update local account to Space account
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == user_email)
.values(
user=space_email, # Update email to Space email
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
)
)
return await self.get_user_by_email(space_email)

View File

@@ -19,7 +19,9 @@ from ..utils import version as version_mgr, proxy as proxy_mgr
from ..persistence import mgr as persistencemgr
from ..api.http.controller import main as http_controller
from ..api.http.service import user as user_service
from ..api.http.service import space as space_service
from ..api.http.service import model as model_service
from ..api.http.service import provider as provider_service
from ..api.http.service import pipeline as pipeline_service
from ..api.http.service import bot as bot_service
from ..api.http.service import knowledge as knowledge_service
@@ -75,6 +77,8 @@ class Application:
instance_config: config_mgr.ConfigManager = None
instance_id: config_mgr.ConfigManager = None # used to identify the instance
# ======= Metadata config manager =======
sensitive_meta: config_mgr.ConfigManager = None
@@ -114,10 +118,14 @@ class Application:
user_service: user_service.UserService = None
space_service: space_service.SpaceService = None
llm_model_service: model_service.LLMModelsService = None
embedding_models_service: model_service.EmbeddingModelsService = None
provider_service: provider_service.ModelProviderService = None
pipeline_service: pipeline_service.PipelineService = None
bot_service: bot_service.BotService = None

View File

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

View File

@@ -16,7 +16,9 @@ from ...platform.webhook_pusher import WebhookPusher
from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller
from ...api.http.service import user as user_service
from ...api.http.service import space as space_service
from ...api.http.service import model as model_service
from ...api.http.service import provider as provider_service
from ...api.http.service import pipeline as pipeline_service
from ...api.http.service import bot as bot_service
from ...api.http.service import knowledge as knowledge_service
@@ -43,6 +45,42 @@ class BuildAppStage(stage.BootingStage):
discover.discover_blueprint('templates/components.yaml')
ap.discover = discover
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
space_service_inst = space_service.SpaceService(ap)
ap.space_service = space_service_inst
llm_model_service_inst = model_service.LLMModelsService(ap)
ap.llm_model_service = llm_model_service_inst
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
ap.embedding_models_service = embedding_models_service_inst
provider_service_inst = provider_service.ModelProviderService(ap)
ap.provider_service = provider_service_inst
pipeline_service_inst = pipeline_service.PipelineService(ap)
ap.pipeline_service = pipeline_service_inst
bot_service_inst = bot_service.BotService(ap)
ap.bot_service = bot_service_inst
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
ap.knowledge_service = knowledge_service_inst
external_kb_service_inst = external_kb_service.ExternalKBService(ap)
ap.external_kb_service = external_kb_service_inst
mcp_service_inst = mcp_service.MCPService(ap)
ap.mcp_service = mcp_service_inst
apikey_service_inst = apikey_service.ApiKeyService(ap)
ap.apikey_service = apikey_service_inst
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst
proxy_mgr = proxy.ProxyManager(ap)
await proxy_mgr.initialize()
ap.proxy_mgr = proxy_mgr
@@ -69,8 +107,8 @@ class BuildAppStage(stage.BootingStage):
ap.cmd_mgr = cmd_mgr_inst
llm_model_mgr_inst = llm_model_mgr.ModelManager(ap)
await llm_model_mgr_inst.initialize()
ap.model_mgr = llm_model_mgr_inst
await llm_model_mgr_inst.initialize()
llm_session_mgr_inst = llm_session_mgr.SessionManager(ap)
await llm_session_mgr_inst.initialize()
@@ -105,36 +143,6 @@ class BuildAppStage(stage.BootingStage):
await http_ctrl.initialize()
ap.http_ctrl = http_ctrl
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
llm_model_service_inst = model_service.LLMModelsService(ap)
ap.llm_model_service = llm_model_service_inst
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
ap.embedding_models_service = embedding_models_service_inst
pipeline_service_inst = pipeline_service.PipelineService(ap)
ap.pipeline_service = pipeline_service_inst
bot_service_inst = bot_service.BotService(ap)
ap.bot_service = bot_service_inst
knowledge_service_inst = knowledge_service.KnowledgeService(ap)
ap.knowledge_service = knowledge_service_inst
external_kb_service_inst = external_kb_service.ExternalKBService(ap)
ap.external_kb_service = external_kb_service_inst
mcp_service_inst = mcp_service.MCPService(ap)
ap.mcp_service = mcp_service_inst
apikey_service_inst = apikey_service.ApiKeyService(ap)
ap.apikey_service = apikey_service_inst
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3)
await plugin_connector_inst.initialize()

View File

@@ -2,8 +2,11 @@ from __future__ import annotations
import os
from typing import Any
from langbot.pkg.utils import constants
import yaml
import importlib.resources as resources
import uuid
import time
from .. import stage, app
from ..bootutils import config
@@ -142,6 +145,22 @@ class LoadConfigStage(stage.BootingStage):
await ap.instance_config.dump_config()
# load or generate instance id
ap.instance_id = await config.load_json_config(
'data/labels/instance_id.json',
template_data={
'instance_id': f'instance_{str(uuid.uuid4())}',
'instance_create_ts': int(time.time()),
},
completion=False,
)
constants.instance_id = ap.instance_id.data['instance_id']
print(f'LangBot instance id: {constants.instance_id}')
await ap.instance_id.dump_config()
ap.sensitive_meta = await config.load_json_config(
'data/metadata/sensitive-words.json',
'metadata/sensitive-words.json',

View File

View File

@@ -0,0 +1,49 @@
# [
# {
# "uuid": "7652ebdb-54dc-412c-a830-e9268ac88471",
# "model_id": "claude-opus-4-5-20251101",
# "display_name": {
# "en_US": "claude-opus-4-5-20251101",
# "zh_Hans": "claude-opus-4-5-20251101"
# },
# "description": {},
# "provider": "anthropic",
# "category": "chat",
# "icon_url": "Claude.Color",
# "tags": {},
# "is_featured": true,
# "featured_order": 999,
# "model_ratio": 2.5,
# "completion_ratio": 5,
# "quota_type": 0,
# "model_price": 0,
# "input_credits": 500,
# "output_credits": 2500,
# "vendor_id": 1,
# "vendor_name": "Anthropic",
# "vendor_icon": "Claude.Color",
# "supported_endpoints": [
# "anthropic",
# "openai"
# ],
# "status": "active",
# "metadata": null,
# "created_at": "2025-12-30T22:23:38.337207+08:00",
# "updated_at": "2025-12-30T22:23:38.337207+08:00"
# }
# ]
import pydantic
class SpaceModel(pydantic.BaseModel):
uuid: str
model_id: str
provider: str
category: str # chat / embedding
llm_abilities: list[str] | None = None
is_featured: bool = False
featured_order: int = 0
status: str
created_at: str | None = None
updated_at: str | None = None

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
class AccountEmailMismatchError(Exception):
def __str__(self):
return 'Account email mismatch'

View File

@@ -7,3 +7,11 @@ class RequesterNotFoundError(Exception):
def __str__(self):
return f'Requester {self.requester_name} not found'
class ProviderNotFoundError(Exception):
def __init__(self, provider_name: str):
self.provider_name = provider_name
def __str__(self):
return f'Provider {self.provider_name} not found'

View File

@@ -3,6 +3,25 @@ import sqlalchemy
from .base import Base
class ModelProvider(Base):
"""Model provider"""
__tablename__ = 'model_providers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
class LLMModel(Base):
"""LLM model"""
@@ -10,12 +29,10 @@ class LLMModel(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
@@ -26,17 +43,15 @@ class LLMModel(Base):
class EmbeddingModel(Base):
"""Embedding 模型"""
"""Embedding model"""
__tablename__ = 'embedding_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,

View File

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

View File

@@ -9,6 +9,17 @@ class User(Base):
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
# Account type: 'local' (default) or 'space'
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
# Space account fields (nullable, only used when account_type='space')
space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
space_access_token_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,

View File

@@ -9,7 +9,7 @@ import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy
from . import database, migration
from ..entity.persistence import base, pipeline, metadata
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
from ..entity import persistence
from ..core import app
from ..utils import constants, importutil
@@ -79,6 +79,7 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
await self.write_default_pipeline()
await self.write_space_model_providers()
async def create_tables(self):
# create tables
@@ -123,7 +124,42 @@ class PersistenceManager:
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
# =================================
async def write_space_model_providers(self):
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
)
# write space model providers
result = await self.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.requester == 'space-chat-completions'
)
)
exists_space_chat_completions_model_provider = result.first()
# api keys will be set/updated when the oauth callback
if exists_space_chat_completions_model_provider is None:
self.ap.logger.info('Creating space model providers...')
space_chat_completions_model_provider = {
'uuid': '00000000-0000-0000-0000-000000000000',
'name': 'LangBot Models',
'requester': 'space-chat-completions',
'base_url': space_models_gateway_api_url,
'api_keys': [],
}
await self.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(space_chat_completions_model_provider)
)
else:
if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url:
await self.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid)
.values({'base_url': space_models_gateway_api_url})
)
# =================================
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
async with self.get_db_engine().connect() as conn:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
import sqlalchemy
from .. import migration
@migration.migration_class(14)
class DBMigrateSpaceAccountSupport(migration.DBMigration):
"""Add Space account support fields to users table"""
async def upgrade(self):
"""Upgrade"""
# Get all column names from the users table
columns = []
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';")
)
all_result = result.fetchall()
columns = [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);'))
all_result = result.fetchall()
columns = [row[1] for row in all_result]
# Add account_type column
if 'account_type' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
)
# Add space_account_uuid column
if 'space_account_uuid' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
)
# Add space_access_token column
if 'space_access_token' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
)
# Add space_refresh_token column
if 'space_refresh_token' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
)
# Add space_access_token_expires_at column
if 'space_access_token_expires_at' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME')
)
# Add space_api_key column
if 'space_api_key' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,15 @@
from .. import migration
# this is a deprecated migration
@migration.migration_class(15)
class DBMigrateModelSourceTracking(migration.DBMigration):
"""Add source tracking fields to models tables for Space integration"""
async def upgrade(self):
"""Upgrade"""
pass
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,305 @@
import uuid as uuid_lib
import sqlalchemy
from .. import migration
@migration.migration_class(16)
class DBMigrateModelProviderRefactor(migration.DBMigration):
"""Refactor model structure: create providers from existing models and update references"""
async def upgrade(self):
"""Upgrade"""
# Step 1: Create model_providers table if not exists
await self._create_providers_table()
# Step 2: Migrate existing models to use providers
await self._migrate_llm_models()
await self._migrate_embedding_models()
# Step 3: Remove deprecated columns
await self._cleanup_columns()
async def _create_providers_table(self):
"""Create model_providers table"""
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS model_providers (
uuid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
requester VARCHAR(255) NOT NULL,
base_url VARCHAR(512) NOT NULL,
api_keys JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
CREATE TABLE IF NOT EXISTS model_providers (
uuid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
requester VARCHAR(255) NOT NULL,
base_url VARCHAR(512) NOT NULL,
api_keys JSON NOT NULL DEFAULT '[]',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
)
async def _migrate_llm_models(self):
"""Migrate LLM models to use providers"""
llm_columns = await self._get_columns('llm_models')
# Add provider_uuid column if not exists
if 'provider_uuid' not in llm_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)')
)
# Add prefered_ranking column if not exists
if 'prefered_ranking' not in llm_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')
)
# Only migrate if old columns exist
if 'requester' not in llm_columns:
return
# Get all LLM models with old structure
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models')
)
models = result.fetchall()
# Create providers and update models
provider_cache = {} # (requester, base_url, api_keys_str) -> provider_uuid
for model in models:
model_uuid, model_name, requester, requester_config, api_keys = model
# Extract base_url from requester_config
base_url = ''
if requester_config:
if isinstance(requester_config, str):
import json
requester_config = json.loads(requester_config)
base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')
# Parse api_keys if it's a string
if isinstance(api_keys, str):
import json
try:
api_keys = json.loads(api_keys)
except Exception:
api_keys = []
if not api_keys:
api_keys = []
# Create cache key
api_keys_str = str(sorted(api_keys)) if api_keys else '[]'
cache_key = (requester, base_url, api_keys_str)
if cache_key in provider_cache:
provider_uuid = provider_cache[cache_key]
else:
# Create new provider
provider_uuid = str(uuid_lib.uuid4())
provider_name = f'{requester}'
if base_url:
# Extract domain for name
try:
from urllib.parse import urlparse
parsed = urlparse(base_url)
provider_name = parsed.netloc or requester
except Exception:
pass
import json
api_keys_json = json.dumps(api_keys) if api_keys else '[]'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)
VALUES (:uuid, :name, :requester, :base_url, :api_keys)
"""),
{
'uuid': provider_uuid,
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys_json,
},
)
provider_cache[cache_key] = provider_uuid
# Update model with provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),
{'provider_uuid': provider_uuid, 'uuid': model_uuid},
)
async def _migrate_embedding_models(self):
"""Migrate embedding models to use providers"""
embedding_columns = await self._get_columns('embedding_models')
# Add provider_uuid column if not exists
if 'provider_uuid' not in embedding_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)')
)
# Add prefered_ranking column if not exists
if 'prefered_ranking' not in embedding_columns:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0')
)
# Only migrate if old columns exist
if 'requester' not in embedding_columns:
return
# Get all embedding models with old structure
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models')
)
models = result.fetchall()
# Get existing providers
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers')
)
existing_providers = provider_result.fetchall()
provider_cache = {}
for p in existing_providers:
p_uuid, p_requester, p_base_url, p_api_keys = p
api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]'
provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid
for model in models:
model_uuid, model_name, requester, requester_config, api_keys = model
base_url = ''
if requester_config:
if isinstance(requester_config, str):
import json
requester_config = json.loads(requester_config)
base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '')
# Parse api_keys if it's a string
if isinstance(api_keys, str):
import json
try:
api_keys = json.loads(api_keys)
except Exception:
api_keys = []
if not api_keys:
api_keys = []
api_keys_str = str(sorted(api_keys)) if api_keys else '[]'
cache_key = (requester, base_url, api_keys_str)
if cache_key in provider_cache:
provider_uuid = provider_cache[cache_key]
else:
provider_uuid = str(uuid_lib.uuid4())
provider_name = f'{requester}'
if base_url:
try:
from urllib.parse import urlparse
parsed = urlparse(base_url)
provider_name = parsed.netloc or requester
except Exception:
pass
import json
api_keys_json = json.dumps(api_keys) if api_keys else '[]'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("""
INSERT INTO model_providers (uuid, name, requester, base_url, api_keys)
VALUES (:uuid, :name, :requester, :base_url, :api_keys)
"""),
{
'uuid': provider_uuid,
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys_json,
},
)
provider_cache[cache_key] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'),
{'provider_uuid': provider_uuid, 'uuid': model_uuid},
)
async def _cleanup_columns(self):
"""Remove deprecated columns from model tables"""
llm_columns = await self._get_columns('llm_models')
deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id']
for col in deprecated_llm_cols:
if col in llm_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}')
)
embedding_columns = await self._get_columns('embedding_models')
deprecated_embedding_cols = [
'requester',
'requester_config',
'api_keys',
'description',
'source',
'space_model_id',
]
for col in deprecated_embedding_cols:
if col in embedding_columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}')
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}')
)
async def _get_columns(self, table_name: str) -> list:
"""Get column names for a table"""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';"
)
)
all_result = result.fetchall()
return [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
all_result = result.fetchall()
return [row[1] for row in all_result]
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,25 @@
from .. import migration
@migration.migration_class(17)
class MoveCloudServiceUrl(migration.DBMigration):
"""迁移云服务 URL 配置"""
async def upgrade(self):
"""升级"""
if 'space' not in self.ap.instance_config.data:
self.ap.instance_config.data['space'] = {
'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1',
'oauth_authorize_url': 'https://space.langbot.app/auth/authorize',
'disable_models_service': False,
}
if 'plugin' in self.ap.instance_config.data:
self.ap.instance_config.data['plugin'].pop('cloud_service_url', None)
await self.ap.instance_config.dump_config()
async def downgrade(self):
"""降级"""
pass

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,22 +66,27 @@ class RuntimeBot:
message_session_id=f'person_{event.sender.id}',
)
# Push to webhooks
# Push to webhooks and check if pipeline should be skipped
skip_pipeline = False
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
asyncio.create_task(
self.ap.webhook_pusher.push_person_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
event, self.bot_entity.uuid, adapter.__class__.__name__
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
else:
await self.logger.info(f'Pipeline skipped for person message due to webhook response')
async def on_group_message(
event: platform_events.GroupMessage,
@@ -97,22 +102,27 @@ class RuntimeBot:
message_session_id=f'group_{event.group.id}',
)
# Push to webhooks
# Push to webhooks and check if pipeline should be skipped
skip_pipeline = False
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
asyncio.create_task(
self.ap.webhook_pusher.push_group_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
event, self.bot_entity.uuid, adapter.__class__.__name__
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=event.group.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=event.group.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
else:
await self.logger.info(f'Pipeline skipped for group message due to webhook response')
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
@@ -245,6 +255,10 @@ class PlatformManager:
logger,
)
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
await runtime_bot.initialize()

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

@@ -0,0 +1,24 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: kook
label:
en_US: KOOK
zh_Hans: KOOK
description:
en_US: KOOK Adapter (formerly KaiHeiLa)
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
icon: kook.png
spec:
config:
- name: token
label:
en_US: Bot Token
zh_Hans: 机器人令牌
type: string
required: true
default: ""
execution:
python:
path: ./kook.py
attr: KookAdapter

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,23 +53,16 @@ spec:
type: string
required: true
default: "AI正在思考中请发送任意内容获取回复。"
- name: host
- name: api_base_url
label:
en_US: Host
zh_Hans: 监听主机
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: The host that Official Account listens on for Webhook connections.
zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 0.0.0.0
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API可根据文档修改此项
type: string
required: true
default: 0.0.0.0
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2287
required: false
default: "https://api.weixin.qq.com"
execution:
python:
path: ./officialaccount.py

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,13 +8,65 @@ import datetime
from langbot.libs.wecom_api.api import WecomClient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.libs.wecom_api.wecomevent import WecomEvent
from langbot.pkg.utils import image
from langbot.pkg.platform.logger import EventLogger
from ...utils import image
from ..logger import EventLogger
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
def split_string_by_bytes(text, limit=2048, encoding='utf-8'):
"""
Splits a string into a list of strings, where each part is at most 'limit' bytes.
Args:
text (str): The original string to split.
limit (int): The maximum byte size for each split part.
encoding (str): The encoding to use (default is 'utf-8').
Returns:
list: A list of split strings.
"""
# 1. Encode the entire string into bytes
bytes_data = text.encode(encoding)
total_len = len(bytes_data)
parts = []
start = 0
while start < total_len:
# 2. Determine the end index for the current chunk
# It shouldn't exceed the total length
end = min(start + limit, total_len)
# 3. Slice the byte array
chunk = bytes_data[start:end]
# 4. Attempt to decode the chunk
# Use errors='ignore' to drop any partial bytes at the end of the chunk
# (e.g., if a 3-byte character was cut after the 2nd byte)
part_str = chunk.decode(encoding, errors='ignore')
# 5. Calculate the actual byte length of the successfully decoded string
# This tells us exactly where the valid character boundary ended
part_bytes = part_str.encode(encoding)
part_len = len(part_bytes)
# Safety check: Prevent infinite loop if limit is too small (e.g., limit=1 for a Chinese char)
if part_len == 0 and end < total_len:
# Force advance by 1 byte to consume the un-decodable byte or raise error
# Here we just treat it as a part to avoid stuck loops, though it might be invalid
start += 1
continue
parts.append(part_str)
# 6. Move the start pointer by the actual length consumed
start += part_len
return parts
class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient):
@@ -22,12 +74,14 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append(
chunks = split_string_by_bytes(msg.text)
content_list.extend([
{
'type': 'text',
'content': msg.text,
'content': chunk,
}
)
for chunk in chunks
])
elif type(msg) is platform_message.Image:
content_list.append(
{
@@ -35,6 +89,20 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.Voice:
content_list.append(
{
'type': 'voice',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.File:
content_list.append(
{
'type': 'file',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.Forward:
for node in msg.node_list:
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
@@ -131,6 +199,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter()
config: dict
bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger):
# 校验必填项
@@ -141,11 +210,12 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'EncodingAESKey',
'contacts_secret',
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise Exception(f'Wecom 缺少配置项: {missing_keys}')
# 创建运行时 bot 对象
# 创建运行时 bot 对象,始终使用统一 webhook 模式
bot = WecomClient(
corpid=config['corpid'],
secret=config['secret'],
@@ -153,6 +223,8 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'],
logger=logger,
unified_mode=True,
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
)
super().__init__(
@@ -162,6 +234,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id='',
)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -178,11 +254,12 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
elif content['type'] == 'image':
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'voice':
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'file':
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""企业微信目前只有发送给个人的方法,
构造target_id的方式为前半部分为账户id后半部分为agent_id,中间使用“|”符号隔开。
"""
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
parts = target_id.split('|')
user_id = parts[0]
@@ -193,6 +270,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media'])
if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media'])
if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media'])
def register_listener(
self,
@@ -214,16 +295,25 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif event_type == platform_events.GroupMessage:
pass
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
async def shutdown_trigger_placeholder():
async def keep_alive():
while True:
await asyncio.sleep(1)
await self.bot.run_task(
host=self.config['host'],
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
await keep_alive()
async def kill(self) -> bool:
return False

View File

@@ -11,23 +11,6 @@ metadata:
icon: wecom.png
spec:
config:
- name: host
label:
en_US: Host
zh_Hans: 监听主机
description:
en_US: Webhook host, unless you know what you're doing, please write 0.0.0.0
zh_Hans: Webhook 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
type: string
required: true
default: "0.0.0.0"
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2290
- name: corpid
label:
en_US: Corpid
@@ -38,21 +21,21 @@ spec:
- name: secret
label:
en_US: Secret
zh_Hans: 密钥
zh_Hans: 密钥 (Secret)
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_Hans: 令牌
zh_Hans: 令牌 (Token)
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_Hans: 消息加解密密钥
zh_Hans: 消息加解密密钥 (EncodingAESKey)
type: string
required: true
default: ""
@@ -63,6 +46,16 @@ spec:
type: string
required: true
default: ""
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API可根据文档填写此项
type: string
required: false
default: "https://qyapi.weixin.qq.com/cgi-bin"
execution:
python:
path: ./wecom.py

View File

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

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