Compare commits

...

29 Commits

Author SHA1 Message Date
Dong_master
16ec4b4f7c fix: del port 2025-12-01 21:52:43 +08:00
Dong_master
87de625dc9 feat:add lark unified_webhook but bug webhook url 404 2025-12-01 21:52:13 +08:00
wangcham
767ae34ed3 feat: finish the fxxking line adapter 2025-11-30 17:05:31 +08:00
Copilot
13ed6c8d46 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>
2025-11-23 14:04:40 +08:00
Junyan Qin
2412e080b4 fix: import errors 2025-11-21 23:39:19 +08:00
wangcham
442c93193c merge: merge master into feat/unified_webhook
Resolved conflicts by keeping current branch changes for webhook feature files:
- src/langbot/libs/wecom_ai_bot_api/api.py
- src/langbot/libs/wecom_ai_bot_api/wecombotevent.py
- src/langbot/pkg/api/http/controller/groups/webhooks.py
- src/langbot/pkg/platform/sources/officialaccount.py
- src/langbot/pkg/platform/sources/qqofficial.py
- src/langbot/pkg/platform/sources/wecom.py
- src/langbot/pkg/platform/sources/wecombot.py

Merged master branch changes including:
- Project restructure: moved files from pkg/ and libs/ to src/langbot/
- New features: API key auth, MCP resources, pipeline extensions
- Documentation updates: AGENTS.md, CLAUDE.md, API docs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 16:50:52 +08:00
Junyan Qin
dbc09f46f4 perf: provider icon rounded in hovercard 2025-11-20 10:25:29 +08:00
Junyan Qin
cf43f09aff perf: auto refresh logic in market 2025-11-20 10:18:28 +08:00
Copilot
c3c51b0fbf perf: Add "Select All" checkbox to Plugin and MCP Server selection dialogs (#1790)
* Initial plan

* Add "Select All" checkbox to Plugin and MCP Server selection dialogs

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

* Make "Select All" text clickable by adding onClick handler to container

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-11-18 17:00:05 +08:00
wangcham
2a87419fb2 fix: modify wecomcs 2025-11-18 16:09:34 +08:00
Duke
8a42daa63f Fix wecom image message send fail issue (#1789)
* Fix wecom image upload issue

* Fix log
2025-11-18 16:02:13 +08:00
Junyan Qin
d91d98c9d4 chore: bump version 4.5.3 2025-11-18 11:31:28 +08:00
Copilot
2e82f2b2d1 fix: plugin pages scroll entire viewport instead of content area only (#1788)
* Initial plan

* Fix scroll behavior in plugin pages - only content areas scroll now

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-11-18 11:16:41 +08:00
wangcham
9855b6d5bc feat: add wecomcs 2025-11-17 16:55:56 +08:00
wangcham
403a721b94 fix: qqo webhook 2025-11-17 16:21:01 +08:00
Junyan Qin
f459c7017a chore: update pr template 2025-11-17 16:02:39 +08:00
Copilot
c27ccb8475 feat(web): Add centered empty state messages to pipeline extension dialogs (#1784)
* Initial plan

* feat: add empty state messages in pipeline extension dialogs

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

* fix: center empty state messages in dialog content area

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-11-16 23:37:40 +08:00
Copilot
abb2f7ae05 feat(web): Move Get Help button to account menu (#1782)
* Initial plan

* feat: Move Get Help button to account options menu

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-11-16 22:44:46 +08:00
Junyan Qin
80606ed32c docs: update README_JP 2025-11-16 20:44:33 +08:00
Junyan Qin
bc7c5fa864 chore: push first pypi package 2025-11-16 20:25:48 +08:00
Junyan Qin
ed0ea68037 doc: add uv link to READMEs 2025-11-16 20:04:34 +08:00
Junyan Qin
6ac4dbc011 doc: update README 2025-11-16 20:00:43 +08:00
Copilot
e642ffa5b3 chore: Add PyPI package support for uvx/pip installation (#1764)
* Initial plan

* Add package structure and resource path utilities

- Created langbot/ package with __init__.py and __main__.py entry point
- Added paths utility to find frontend and resource files from package installation
- Updated config loading to use resource paths
- Updated frontend serving to use resource paths
- Added MANIFEST.in for package data inclusion
- Updated pyproject.toml with build system and entry points

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

* Add PyPI publishing workflow and update license

- Created GitHub Actions workflow to build frontend and publish to PyPI
- Added license field to pyproject.toml to fix deprecation warning
- Updated .gitignore to exclude build artifacts
- Tested package building successfully

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

* Add PyPI installation documentation

- Created PYPI_INSTALLATION.md with detailed installation and usage instructions
- Updated README.md to feature uvx/pip installation as recommended method
- Updated README_EN.md with same changes for English documentation

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

* Address code review feedback

- Made package-data configuration more specific to langbot package only
- Improved path detection with caching to avoid repeated file I/O
- Removed sys.path searching which was incorrect for package data
- Removed interactive input() call for non-interactive environment compatibility
- Simplified error messages for version check

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

* Fix code review issues

- Use specific exception types instead of bare except
- Fix misleading comments about directory levels
- Remove redundant existence check before makedirs with exist_ok=True
- Use context manager for file opening to ensure proper cleanup

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

* Simplify package configuration and document behavioral differences

- Removed redundant package-data configuration, relying on MANIFEST.in
- Added documentation about behavioral differences between package and source installation
- Clarified that include-package-data=true uses MANIFEST.in for data files

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

* chore: update pyproject.toml

* chore: try pack templates in langbot/

* chore: update

* chore: update

* chore: update

* chore: update

* chore: update

* chore: adjust dir structure

* chore: fix imports

* fix: read default-pipeline-config.json

* fix: read default-pipeline-config.json

* fix: tests

* ci: publish pypi

* chore: bump version 4.6.0-beta.1 for testing

* chore: add templates/**

* fix: send adapters and requesters icons

* chore: bump version 4.6.0b2 for testing

* chore: add platform field for docker-compose.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>
2025-11-16 19:53:01 +08:00
wangcham
2ef47ebfb1 fix: errors when npm lint 2025-11-13 14:30:26 +08:00
wangcham
81e411c558 feat: qqo 2025-11-07 21:41:10 +08:00
wangcham
ad64e89a86 fix: slack adapter 2025-11-06 16:50:14 +00:00
wangcham
913b9a24c4 feat: add support for wecombot,wxoa,slack and qqo 2025-11-06 09:21:17 +00:00
wangcham
ceb38d91b4 feat: add unified webhook for wecom 2025-11-05 14:23:10 +00:00
wangcham
a0dec39905 fix: wecombot id 2025-11-05 03:54:33 +00:00
496 changed files with 2492 additions and 1661 deletions

View File

@@ -2,6 +2,17 @@
> 请在此部分填写你实现/解决/优化的内容: > 请在此部分填写你实现/解决/优化的内容:
> Summary of what you implemented/solved/optimized: > Summary of what you implemented/solved/optimized:
>
### 更改前后对比截图 / Screenshots
> 请在此部分粘贴更改前后对比截图(可以是界面截图、控制台输出、对话截图等):
> Please paste the screenshots of changes before and after here (can be interface screenshots, console output, conversation screenshots, etc.):
>
> 修改前 / Before:
>
> 修改后 / After:
>
## 检查清单 / Checklist ## 检查清单 / Checklist

46
.github/workflows/publish-to-pypi.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Build and Publish to PyPI
on:
workflow_dispatch:
release:
types: [published]
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for trusted publishing to PyPI
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build frontend
run: |
cd web
npm install -g pnpm
pnpm install
pnpm build
mkdir -p ../src/langbot/web/out
cp -r out ../src/langbot/web/
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6
with:
version: "latest"
- name: Build package
run: |
uv build
- name: Publish to PyPI
run: |
uv publish --token ${{ secrets.PYPI_TOKEN }}

6
.gitignore vendored
View File

@@ -47,3 +47,9 @@ uv.lock
plugins.bak plugins.bak
coverage.xml coverage.xml
.coverage .coverage
src/langbot/web/
# Build artifacts
/dist
/build
*.egg-info

View File

@@ -31,6 +31,16 @@ LangBot 是一个开源的大语言模型原生即时通信机器人开发平台
## 📦 开始使用 ## 📦 开始使用
#### 快速部署
使用 `uvx` 一键启动(需要先安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)
```bash
uvx langbot
```
访问 http://localhost:5300 即可开始使用。
#### Docker Compose 部署 #### Docker Compose 部署
```bash ```bash

View File

@@ -25,6 +25,16 @@ LangBot is an open-source LLM native instant messaging robot development platfor
## 📦 Getting Started ## 📦 Getting Started
#### Quick Start
Use `uvx` to start with one command (need to install [uv](https://docs.astral.sh/uv/getting-started/installation/)):
```bash
uvx langbot
```
Visit http://localhost:5300 to start using it.
#### Docker Compose Deployment #### Docker Compose Deployment
```bash ```bash

View File

@@ -25,6 +25,16 @@ LangBot は、エージェント、RAG、MCP などの LLM アプリケーショ
## 📦 始め方 ## 📦 始め方
#### クイックスタート
`uvx` を使用した迅速なデプロイ([uv](https://docs.astral.sh/uv/getting-started/installation/) が必要です):
```bash
uvx langbot
```
http://localhost:5300 にアクセスして使用を開始します。
#### Docker Compose デプロイ #### Docker Compose デプロイ
```bash ```bash

View File

@@ -27,6 +27,16 @@ LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台
## 📦 開始使用 ## 📦 開始使用
#### 快速部署
使用 `uvx` 一鍵啟動(需要先安裝 [uv](https://docs.astral.sh/uv/getting-started/installation/)
```bash
uvx langbot
```
訪問 http://localhost:5300 即可開始使用。
#### Docker Compose 部署 #### Docker Compose 部署
```bash ```bash

View File

@@ -7,6 +7,7 @@ services:
langbot_plugin_runtime: langbot_plugin_runtime:
image: rockchin/langbot:latest image: rockchin/langbot:latest
container_name: langbot_plugin_runtime container_name: langbot_plugin_runtime
platform: linux/amd64 # For Apple Silicon compatibility
volumes: volumes:
- ./data/plugins:/app/data/plugins - ./data/plugins:/app/data/plugins
ports: ports:
@@ -21,6 +22,7 @@ services:
langbot: langbot:
image: rockchin/langbot:latest image: rockchin/langbot:latest
container_name: langbot container_name: langbot
platform: linux/amd64 # For Apple Silicon compatibility
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./plugins:/app/plugins - ./plugins:/app/plugins

117
docs/PYPI_INSTALLATION.md Normal file
View File

@@ -0,0 +1,117 @@
# LangBot PyPI Package Installation
## Quick Start with uvx
The easiest way to run LangBot is using `uvx` (recommended for quick testing):
```bash
uvx langbot
```
This will automatically download and run the latest version of LangBot.
## Install with pip/uv
You can also install LangBot as a regular Python package:
```bash
# Using pip
pip install langbot
# Using uv
uv pip install langbot
```
Then run it:
```bash
langbot
```
Or using Python module syntax:
```bash
python -m langbot
```
## Installation with Frontend
When published to PyPI, the LangBot package includes the pre-built frontend files. You don't need to build the frontend separately.
## Data Directory
When running LangBot as a package, it will create a `data/` directory in your current working directory to store configuration, logs, and other runtime data. You can run LangBot from any directory, and it will set up its data directory there.
## Command Line Options
LangBot supports the following command line options:
- `--standalone-runtime`: Use standalone plugin runtime
- `--debug`: Enable debug mode
Example:
```bash
langbot --debug
```
## Comparison with Other Installation Methods
### PyPI Package (uvx/pip)
- **Pros**: Easy to install and update, no need to clone repository or build frontend
- **Cons**: Less flexible for development/customization
### Docker
- **Pros**: Isolated environment, easy deployment
- **Cons**: Requires Docker
### Manual Source Installation
- **Pros**: Full control, easy to customize and develop
- **Cons**: Requires building frontend, managing dependencies manually
## Development
If you want to contribute or customize LangBot, you should still use the manual installation method by cloning the repository:
```bash
git clone https://github.com/langbot-app/LangBot
cd LangBot
uv sync
cd web
npm install
npm run build
cd ..
uv run main.py
```
## Updating
To update to the latest version:
```bash
# With pip
pip install --upgrade langbot
# With uv
uv pip install --upgrade langbot
# With uvx (automatically uses latest)
uvx langbot
```
## System Requirements
- Python 3.10.1 or higher
- Operating System: Linux, macOS, or Windows
## Differences from Source Installation
When running LangBot from the PyPI package (via uvx or pip), there are a few behavioral differences compared to running from source:
1. **Version Check**: The package version does not prompt for user input when the Python version is incompatible. It simply prints an error message and exits. This makes it compatible with non-interactive environments like containers and CI/CD.
2. **Working Directory**: The package version does not require being run from the LangBot project root. You can run `langbot` from any directory, and it will create a `data/` directory in your current working directory.
3. **Frontend Files**: The frontend is pre-built and included in the package, so you don't need to run `npm build` separately.
These differences are intentional to make the package more user-friendly and suitable for various deployment scenarios.

View File

@@ -1,45 +0,0 @@
from v1 import client # type: ignore
import asyncio
import os
import json
class TestDifyClient:
async def test_chat_messages(self):
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
async for chunk in cln.chat_messages(inputs={}, query='调用工具查看现在几点?', user='test'):
print(json.dumps(chunk, ensure_ascii=False, indent=4))
async def test_upload_file(self):
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
file_bytes = open('img.png', 'rb').read()
print(type(file_bytes))
file = ('img2.png', file_bytes, 'image/png')
resp = await cln.upload_file(file=file, user='test')
print(json.dumps(resp, ensure_ascii=False, indent=4))
async def test_workflow_run(self):
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
# resp = await cln.workflow_run(inputs={}, user="test")
# # print(json.dumps(resp, ensure_ascii=False, indent=4))
# print(resp)
chunks = []
ignored_events = ['text_chunk']
async for chunk in cln.workflow_run(inputs={}, user='test'):
if chunk['event'] in ignored_events:
continue
chunks.append(chunk)
print(json.dumps(chunks, ensure_ascii=False, indent=4))
if __name__ == '__main__':
asyncio.run(TestDifyClient().test_chat_messages())

118
main.py
View File

@@ -1,117 +1,3 @@
import asyncio import langbot.__main__
import argparse
# LangBot 终端启动入口
# 在此层级解决依赖项检查。
# LangBot/main.py
asciiart = r""" langbot.__main__.main()
_ ___ _
| | __ _ _ _ __ _| _ ) ___| |_
| |__/ _` | ' \/ _` | _ \/ _ \ _|
|____\__,_|_||_\__, |___/\___/\__|
|___/
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://docs.langbot.app
"""
async def main_entry(loop: asyncio.AbstractEventLoop):
parser = argparse.ArgumentParser(description='LangBot')
parser.add_argument(
'--standalone-runtime',
action='store_true',
help='Use standalone plugin runtime / 使用独立插件运行时',
default=False,
)
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
args = parser.parse_args()
if args.standalone_runtime:
from pkg.utils import platform
platform.standalone_runtime = True
if args.debug:
from pkg.utils import constants
constants.debug_mode = True
print(asciiart)
import sys
# 检查依赖
from pkg.core.bootutils import deps
missing_deps = await deps.check_deps()
if missing_deps:
print('以下依赖包未安装,将自动安装,请完成后重启程序:')
print(
'These dependencies are missing, they will be installed automatically, please restart the program after completion:'
)
for dep in missing_deps:
print('-', dep)
await deps.install_deps(missing_deps)
print('已自动安装缺失的依赖包,请重启程序。')
print('The missing dependencies have been installed automatically, please restart the program.')
sys.exit(0)
# # 检查pydantic版本如果没有 pydantic.v1则把 pydantic 映射为 v1
# import pydantic.version
# if pydantic.version.VERSION < '2.0':
# import pydantic
# sys.modules['pydantic.v1'] = pydantic
# 检查配置文件
from pkg.core.bootutils import files
generated_files = await files.generate_files()
if generated_files:
print('以下文件不存在,已自动生成:')
print('Following files do not exist and have been automatically generated:')
for file in generated_files:
print('-', file)
from pkg.core import boot
await boot.main(loop)
if __name__ == '__main__':
import os
import sys
# 必须大于 3.10.1
if sys.version_info < (3, 10, 1):
print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version)
input('按任意键退出...')
print('Your Python version is not supported. Please exit the program by pressing any key.')
exit(1)
# Check if the current directory is the LangBot project root directory
invalid_pwd = False
if not os.path.exists('main.py'):
invalid_pwd = True
else:
with open('main.py', 'r', encoding='utf-8') as f:
content = f.read()
if 'LangBot/main.py' not in content:
invalid_pwd = True
if invalid_pwd:
print('请在 LangBot 项目根目录下以命令形式运行此程序。')
input('按任意键退出...')
print('Please run this program in the LangBot project root directory in command form.')
print('Press any key to exit...')
exit(1)
loop = asyncio.new_event_loop()
loop.run_until_complete(main_entry(loop))

View File

@@ -1,49 +0,0 @@
import quart
from .. import group
@group.group_class('webhooks', '/api/v1/webhooks')
class WebhooksRouterGroup(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,115 +0,0 @@
from __future__ import annotations
import json
import typing
import os
import base64
import logging
import pydantic
import requests
from ..core import app
class Announcement(pydantic.BaseModel):
"""公告"""
id: int
time: str
timestamp: int
content: str
enabled: typing.Optional[bool] = True
def to_dict(self) -> dict:
return {
'id': self.id,
'time': self.time,
'timestamp': self.timestamp,
'content': self.content,
'enabled': self.enabled,
}
class AnnouncementManager:
"""公告管理器"""
ap: app.Application = None
def __init__(self, ap: app.Application):
self.ap = ap
async def fetch_all(self) -> list[Announcement]:
"""获取所有公告"""
try:
resp = requests.get(
url='https://api.github.com/repos/langbot-app/LangBot/contents/res/announcement.json',
proxies=self.ap.proxy_mgr.get_forward_proxies(),
timeout=5,
)
resp.raise_for_status() # 检查请求是否成功
obj_json = resp.json()
b64_content = obj_json['content']
# 解码
content = base64.b64decode(b64_content).decode('utf-8')
return [Announcement(**item) for item in json.loads(content)]
except (requests.RequestException, json.JSONDecodeError, KeyError) as e:
self.ap.logger.warning(f'获取公告失败: {e}')
pass
return [] # 请求失败时返回空列表
async def fetch_saved(self) -> list[Announcement]:
if not os.path.exists('data/labels/announcement_saved.json'):
with open('data/labels/announcement_saved.json', 'w', encoding='utf-8') as f:
f.write('[]')
with open('data/labels/announcement_saved.json', 'r', encoding='utf-8') as f:
content = f.read()
if not content:
content = '[]'
return [Announcement(**item) for item in json.loads(content)]
async def write_saved(self, content: list[Announcement]):
with open('data/labels/announcement_saved.json', 'w', encoding='utf-8') as f:
f.write(json.dumps([item.to_dict() for item in content], indent=4, ensure_ascii=False))
async def fetch_new(self) -> list[Announcement]:
"""获取新公告"""
all = await self.fetch_all()
saved = await self.fetch_saved()
to_show: list[Announcement] = []
for item in all:
# 遍历saved检查是否有相同id的公告
for saved_item in saved:
if saved_item.id == item.id:
break
else:
if item.enabled:
# 没有相同id的公告
to_show.append(item)
await self.write_saved(all)
return to_show
async def show_announcements(self) -> typing.Tuple[str, int]:
"""显示公告"""
try:
announcements = await self.fetch_new()
ann_text = ''
for ann in announcements:
ann_text += f'[公告] {ann.time}: {ann.content}\n'
# TODO statistics
return ann_text, logging.INFO
except Exception as e:
return f'获取公告时出错: {e}', logging.WARNING

View File

@@ -1,8 +1,9 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.5.0" version = "4.5.3"
description = "Easy-to-use global IM bot platform designed for LLM era" description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"]
requires-python = ">=3.10.1,<4.0" requires-python = ">=3.10.1,<4.0"
dependencies = [ dependencies = [
"aiocqhttp>=1.4.4", "aiocqhttp>=1.4.4",
@@ -45,7 +46,6 @@ dependencies = [
"urllib3>=2.4.0", "urllib3>=2.4.0",
"websockets>=15.0.1", "websockets>=15.0.1",
"python-socks>=2.7.1", # dingtalk missing dependency "python-socks>=2.7.1", # dingtalk missing dependency
"taskgroup==0.0.0a4", # graingert/taskgroup#20
"pip>=25.1.1", "pip>=25.1.1",
"ruff>=0.11.9", "ruff>=0.11.9",
"pre-commit>=4.2.0", "pre-commit>=4.2.0",
@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1", "langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24", "chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.11b1", "langbot-plugin==0.1.11",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10", "tboxsdk>=0.0.10",
@@ -85,11 +85,10 @@ keywords = [
"onebot", "onebot",
] ]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 5 - Production/Stable",
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Framework :: Robot Framework", "Framework :: Robot Framework",
"Framework :: Robot Framework :: Library", "Framework :: Robot Framework :: Library",
"License :: OSI Approved :: AGPL-3 License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Topic :: Communications :: Chat", "Topic :: Communications :: Chat",
@@ -100,6 +99,16 @@ Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app" Documentation = "https://docs.langbot.app"
Repository = "https://github.com/langbot-app/LangBot" Repository = "https://github.com/langbot-app/LangBot"
[project.scripts]
langbot = "langbot.__main__:main"
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pre-commit>=4.2.0", "pre-commit>=4.2.0",

View File

@@ -26,7 +26,7 @@ markers =
# Coverage options (when using pytest-cov) # Coverage options (when using pytest-cov)
[coverage:run] [coverage:run]
source = pkg source = langbot.pkg
omit = omit =
*/tests/* */tests/*
*/test_*.py */test_*.py

3
src/langbot/__init__.py Normal file
View File

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

104
src/langbot/__main__.py Normal file
View File

@@ -0,0 +1,104 @@
"""LangBot entry point for package execution"""
import asyncio
import argparse
import sys
import os
# ASCII art banner
asciiart = r"""
_ ___ _
| | __ _ _ _ __ _| _ ) ___| |_
| |__/ _` | ' \/ _` | _ \/ _ \ _|
|____\__,_|_||_\__, |___/\___/\__|
|___/
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://docs.langbot.app
"""
async def main_entry(loop: asyncio.AbstractEventLoop):
"""Main entry point for LangBot"""
parser = argparse.ArgumentParser(description='LangBot')
parser.add_argument(
'--standalone-runtime',
action='store_true',
help='Use standalone plugin runtime / 使用独立插件运行时',
default=False,
)
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
args = parser.parse_args()
if args.standalone_runtime:
from langbot.pkg.utils import platform
platform.standalone_runtime = True
if args.debug:
from langbot.pkg.utils import constants
constants.debug_mode = True
print(asciiart)
# Check dependencies
from langbot.pkg.core.bootutils import deps
missing_deps = await deps.check_deps()
if missing_deps:
print('以下依赖包未安装,将自动安装,请完成后重启程序:')
print(
'These dependencies are missing, they will be installed automatically, please restart the program after completion:'
)
for dep in missing_deps:
print('-', dep)
await deps.install_deps(missing_deps)
print('已自动安装缺失的依赖包,请重启程序。')
print('The missing dependencies have been installed automatically, please restart the program.')
sys.exit(0)
# Check configuration files
from langbot.pkg.core.bootutils import files
generated_files = await files.generate_files()
if generated_files:
print('以下文件不存在,已自动生成:')
print('Following files do not exist and have been automatically generated:')
for file in generated_files:
print('-', file)
from langbot.pkg.core import boot
await boot.main(loop)
def main():
"""Main function to be called by console script entry point"""
# Check Python version
if sys.version_info < (3, 10, 1):
print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version)
print('Your Python version is not supported.')
print('Python 3.10.1 or higher is required. Current version:', sys.version)
sys.exit(1)
# Set up the working directory
# When installed as a package, we need to handle the working directory differently
# We'll create data directory in current working directory if not exists
os.makedirs('data', exist_ok=True)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(main_entry(loop))
except KeyboardInterrupt:
print('\n正在退出...')
print('Exiting...')
finally:
loop.close()
if __name__ == '__main__':
main()

View File

@@ -7,10 +7,8 @@ import os
from pathlib import Path from pathlib import Path
class AsyncCozeAPIClient: class AsyncCozeAPIClient:
def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"): def __init__(self, api_key: str, api_base: str = 'https://api.coze.cn'):
self.api_key = api_key self.api_key = api_key
self.api_base = api_base self.api_base = api_base
self.session = None self.session = None
@@ -24,13 +22,11 @@ class AsyncCozeAPIClient:
"""退出时自动关闭会话""" """退出时自动关闭会话"""
await self.close() await self.close()
async def coze_session(self): async def coze_session(self):
"""确保HTTP session存在""" """确保HTTP session存在"""
if self.session is None: if self.session is None:
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
ssl=False if self.api_base.startswith("http://") else True, ssl=False if self.api_base.startswith('http://') else True,
limit=100, limit=100,
limit_per_host=30, limit_per_host=30,
keepalive_timeout=30, keepalive_timeout=30,
@@ -42,12 +38,10 @@ class AsyncCozeAPIClient:
sock_read=120, sock_read=120,
) )
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", 'Authorization': f'Bearer {self.api_key}',
"Accept": "text/event-stream", 'Accept': 'text/event-stream',
} }
self.session = aiohttp.ClientSession( self.session = aiohttp.ClientSession(headers=headers, timeout=timeout, connector=connector)
headers=headers, timeout=timeout, connector=connector
)
return self.session return self.session
async def close(self): async def close(self):
@@ -63,15 +57,15 @@ class AsyncCozeAPIClient:
# 处理 Path 对象 # 处理 Path 对象
if isinstance(file, Path): if isinstance(file, Path):
if not file.exists(): if not file.exists():
raise ValueError(f"File not found: {file}") raise ValueError(f'File not found: {file}')
with open(file, "rb") as f: with open(file, 'rb') as f:
file = f.read() file = f.read()
# 处理文件路径字符串 # 处理文件路径字符串
elif isinstance(file, str): elif isinstance(file, str):
if not os.path.isfile(file): if not os.path.isfile(file):
raise ValueError(f"File not found: {file}") raise ValueError(f'File not found: {file}')
with open(file, "rb") as f: with open(file, 'rb') as f:
file = f.read() file = f.read()
# 处理文件对象 # 处理文件对象
@@ -79,43 +73,39 @@ class AsyncCozeAPIClient:
file = file.read() file = file.read()
session = await self.coze_session() session = await self.coze_session()
url = f"{self.api_base}/v1/files/upload" url = f'{self.api_base}/v1/files/upload'
try: try:
file_io = io.BytesIO(file) file_io = io.BytesIO(file)
async with session.post( async with session.post(
url, url,
data={ data={
"file": file_io, 'file': file_io,
}, },
timeout=aiohttp.ClientTimeout(total=60), timeout=aiohttp.ClientTimeout(total=60),
) as response: ) as response:
if response.status == 401: if response.status == 401:
raise Exception("Coze API 认证失败,请检查 API Key 是否正确") raise Exception('Coze API 认证失败,请检查 API Key 是否正确')
response_text = await response.text() response_text = await response.text()
if response.status != 200: if response.status != 200:
raise Exception( raise Exception(f'文件上传失败,状态码: {response.status}, 响应: {response_text}')
f"文件上传失败,状态码: {response.status}, 响应: {response_text}"
)
try: try:
result = await response.json() result = await response.json()
except json.JSONDecodeError: except json.JSONDecodeError:
raise Exception(f"文件上传响应解析失败: {response_text}") raise Exception(f'文件上传响应解析失败: {response_text}')
if result.get("code") != 0: if result.get('code') != 0:
raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}") raise Exception(f'文件上传失败: {result.get("msg", "未知错误")}')
file_id = result["data"]["id"] file_id = result['data']['id']
return file_id return file_id
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise Exception("文件上传超时") raise Exception('文件上传超时')
except Exception as e: except Exception as e:
raise Exception(f"文件上传失败: {str(e)}") raise Exception(f'文件上传失败: {str(e)}')
async def chat_messages( async def chat_messages(
self, self,
@@ -139,22 +129,21 @@ class AsyncCozeAPIClient:
timeout: 超时时间 timeout: 超时时间
""" """
session = await self.coze_session() session = await self.coze_session()
url = f"{self.api_base}/v3/chat" url = f'{self.api_base}/v3/chat'
payload = { payload = {
"bot_id": bot_id, 'bot_id': bot_id,
"user_id": user_id, 'user_id': user_id,
"stream": stream, 'stream': stream,
"auto_save_history": auto_save_history, 'auto_save_history': auto_save_history,
} }
if additional_messages: if additional_messages:
payload["additional_messages"] = additional_messages payload['additional_messages'] = additional_messages
params = {} params = {}
if conversation_id: if conversation_id:
params["conversation_id"] = conversation_id params['conversation_id'] = conversation_id
try: try:
async with session.post( async with session.post(
@@ -164,29 +153,25 @@ class AsyncCozeAPIClient:
timeout=aiohttp.ClientTimeout(total=timeout), timeout=aiohttp.ClientTimeout(total=timeout),
) as response: ) as response:
if response.status == 401: if response.status == 401:
raise Exception("Coze API 认证失败,请检查 API Key 是否正确") raise Exception('Coze API 认证失败,请检查 API Key 是否正确')
if response.status != 200: if response.status != 200:
raise Exception(f"Coze API 流式请求失败,状态码: {response.status}") raise Exception(f'Coze API 流式请求失败,状态码: {response.status}')
async for chunk in response.content: async for chunk in response.content:
chunk = chunk.decode("utf-8") chunk = chunk.decode('utf-8')
if chunk != '\n': if chunk != '\n':
if chunk.startswith("event:"): if chunk.startswith('event:'):
chunk_type = chunk.replace("event:", "", 1).strip() chunk_type = chunk.replace('event:', '', 1).strip()
elif chunk.startswith("data:"): elif chunk.startswith('data:'):
chunk_data = chunk.replace("data:", "", 1).strip() chunk_data = chunk.replace('data:', '', 1).strip()
else: else:
yield {"event": chunk_type, "data": json.loads(chunk_data) if chunk_data else {}} # 处理本地部署时接口返回的data为空值 yield {
'event': chunk_type,
'data': json.loads(chunk_data) if chunk_data else {},
} # 处理本地部署时接口返回的data为空值
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)") raise Exception(f'Coze API 流式请求超时 ({timeout}秒)')
except Exception as e: except Exception as e:
raise Exception(f"Coze API 流式请求失败: {str(e)}") raise Exception(f'Coze API 流式请求失败: {str(e)}')

View File

@@ -194,28 +194,23 @@ class DingTalkClient:
'Type': 'richText', 'Type': 'richText',
'Elements': [], # 按顺序存储所有元素 'Elements': [], # 按顺序存储所有元素
'SimpleContent': '', # 兼容字段:纯文本内容 'SimpleContent': '', # 兼容字段:纯文本内容
'SimplePicture': '' # 兼容字段:第一张图片 'SimplePicture': '', # 兼容字段:第一张图片
} }
# 先收集所有文本和图片占位符 # 先收集所有文本和图片占位符
text_elements = [] text_elements = []
image_placeholders = []
# 解析富文本内容,保持原始顺序 # 解析富文本内容,保持原始顺序
for item in data['richText']: for item in data['richText']:
# 处理文本内容 # 处理文本内容
if 'text' in item and item['text'] != "\n": if 'text' in item and item['text'] != '\n':
element = { element = {'Type': 'text', 'Content': item['text']}
'Type': 'text',
'Content': item['text']
}
rich_content['Elements'].append(element) rich_content['Elements'].append(element)
text_elements.append(item['text']) text_elements.append(item['text'])
# 检查是否是图片元素 - 根据钉钉API的实际结构调整 # 检查是否是图片元素 - 根据钉钉API的实际结构调整
# 钉钉富文本中的图片通常有特定标识,可能需要根据实际返回调整 # 钉钉富文本中的图片通常有特定标识,可能需要根据实际返回调整
elif item.get("type") == "picture": elif item.get('type') == 'picture':
# 创建图片占位符 # 创建图片占位符
element = { element = {
'Type': 'image_placeholder', 'Type': 'image_placeholder',
@@ -232,10 +227,7 @@ class DingTalkClient:
if element['Type'] == 'image_placeholder': if element['Type'] == 'image_placeholder':
if image_index < len(image_list) and image_list[image_index]: if image_index < len(image_list) and image_list[image_index]:
image_url = await self.download_image(image_list[image_index]) image_url = await self.download_image(image_list[image_index])
new_elements.append({ new_elements.append({'Type': 'image', 'Picture': image_url})
'Type': 'image',
'Picture': image_url
})
image_index += 1 image_index += 1
else: else:
# 如果没有对应的图片,保留占位符或跳过 # 如果没有对应的图片,保留占位符或跳过
@@ -245,7 +237,6 @@ class DingTalkClient:
rich_content['Elements'] = new_elements rich_content['Elements'] = new_elements
# 设置兼容字段 # 设置兼容字段
all_texts = [elem['Content'] for elem in rich_content['Elements'] if elem.get('Type') == 'text'] all_texts = [elem['Content'] for elem in rich_content['Elements'] if elem.get('Type') == 'text']
rich_content['SimpleContent'] = '\n'.join(all_texts) if all_texts else '' rich_content['SimpleContent'] = '\n'.join(all_texts) if all_texts else ''
@@ -261,8 +252,6 @@ class DingTalkClient:
if all_images: if all_images:
message_data['Picture'] = all_images[0] message_data['Picture'] = all_images[0]
elif incoming_message.message_type == 'text': elif incoming_message.message_type == 'text':
message_data['Content'] = incoming_message.get_text_list()[0] message_data['Content'] = incoming_message.get_text_list()[0]

View File

@@ -43,7 +43,6 @@ class DingTalkEvent(dict):
def name(self): def name(self):
return self.get('Name', '') return self.get('Name', '')
@property @property
def conversation(self): def conversation(self):
return self.get('conversation_type', '') return self.get('conversation_type', '')

View File

@@ -1,12 +1,12 @@
# 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件 # 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件
import time import time
import traceback import traceback
from libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt from langbot.libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from quart import Quart, request from quart import Quart, request
import hashlib import hashlib
from typing import Callable from typing import Callable
from .oaevent import OAEvent from langbot.libs.official_account_api.oaevent import OAEvent
import asyncio import asyncio
@@ -23,20 +23,25 @@ xml_template = """
class OAClient: 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):
self.token = token self.token = token
self.aes = EncodingAESKey self.aes = EncodingAESKey
self.appid = AppID self.appid = AppID
self.appsecret = Appsecret self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com' self.base_url = 'https://api.weixin.qq.com'
self.access_token = '' self.access_token = ''
self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command', # 只有在非统一模式下才注册独立路由
'handle_callback', if not self.unified_mode:
self.handle_callback_request, self.app.add_url_rule(
methods=['GET', 'POST'], '/callback/command',
) 'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self._message_handlers = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -46,19 +51,39 @@ class OAClient:
self.logger = logger self.logger = logger
async def handle_callback_request(self): 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: try:
# 每隔100毫秒查询是否生成ai回答 # 每隔100毫秒查询是否生成ai回答
start_time = time.time() start_time = time.time()
signature = request.args.get('signature', '') signature = req.args.get('signature', '')
timestamp = request.args.get('timestamp', '') timestamp = req.args.get('timestamp', '')
nonce = request.args.get('nonce', '') nonce = req.args.get('nonce', '')
echostr = request.args.get('echostr', '') echostr = req.args.get('echostr', '')
msg_signature = request.args.get('msg_signature', '') msg_signature = req.args.get('msg_signature', '')
if msg_signature is None: if msg_signature is None:
await self.logger.error('msg_signature不在请求体中') await self.logger.error('msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中') raise Exception('msg_signature不在请求体中')
if request.method == 'GET': if req.method == 'GET':
# 校验签名 # 校验签名
check_str = ''.join(sorted([self.token, timestamp, nonce])) check_str = ''.join(sorted([self.token, timestamp, nonce]))
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
@@ -68,8 +93,8 @@ class OAClient:
else: else:
await self.logger.error('拒绝请求') await self.logger.error('拒绝请求')
raise Exception('拒绝请求') raise Exception('拒绝请求')
elif request.method == 'POST': elif req.method == 'POST':
encryt_msg = await request.data encryt_msg = await req.data
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
xml_msg = xml_msg.decode('utf-8') xml_msg = xml_msg.decode('utf-8')
@@ -182,6 +207,7 @@ class OAClientForLongerResponse:
Appsecret: str, Appsecret: str,
LoadingMessage: str, LoadingMessage: str,
logger: None, logger: None,
unified_mode: bool = False,
): ):
self.token = token self.token = token
self.aes = EncodingAESKey self.aes = EncodingAESKey
@@ -189,13 +215,18 @@ class OAClientForLongerResponse:
self.appsecret = Appsecret self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com' self.base_url = 'https://api.weixin.qq.com'
self.access_token = '' self.access_token = ''
self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command', # 只有在非统一模式下才注册独立路由
'handle_callback', if not self.unified_mode:
self.handle_callback_request, self.app.add_url_rule(
methods=['GET', 'POST'], '/callback/command',
) 'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self._message_handlers = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -206,24 +237,44 @@ class OAClientForLongerResponse:
self.logger = logger self.logger = logger
async def handle_callback_request(self): 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: try:
signature = request.args.get('signature', '') signature = req.args.get('signature', '')
timestamp = request.args.get('timestamp', '') timestamp = req.args.get('timestamp', '')
nonce = request.args.get('nonce', '') nonce = req.args.get('nonce', '')
echostr = request.args.get('echostr', '') echostr = req.args.get('echostr', '')
msg_signature = request.args.get('msg_signature', '') msg_signature = req.args.get('msg_signature', '')
if msg_signature is None: if msg_signature is None:
await self.logger.error('msg_signature不在请求体中') await self.logger.error('msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中') raise Exception('msg_signature不在请求体中')
if request.method == 'GET': if req.method == 'GET':
check_str = ''.join(sorted([self.token, timestamp, nonce])) check_str = ''.join(sorted([self.token, timestamp, nonce]))
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
return echostr if check_signature == signature else '拒绝请求' return echostr if check_signature == signature else '拒绝请求'
elif request.method == 'POST': elif req.method == 'POST':
encryt_msg = await request.data encryt_msg = await req.data
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
xml_msg = xml_msg.decode('utf-8') xml_msg = xml_msg.decode('utf-8')

View File

@@ -10,38 +10,20 @@ import traceback
from cryptography.hazmat.primitives.asymmetric import ed25519 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: 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 = Quart(__name__)
self.app.add_url_rule(
'/callback/command', # 只有在非统一模式下才注册独立路由
'handle_callback', if not self.unified_mode:
self.handle_callback_request, self.app.add_url_rule(
methods=['GET', 'POST'], '/callback/command',
) 'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self.secret = secret self.secret = secret
self.token = token self.token = token
self.app_id = app_id self.app_id = app_id
@@ -82,18 +64,45 @@ class QQOfficialClient:
raise Exception(f'获取access_token失败: {e}') raise Exception(f'获取access_token失败: {e}')
async def handle_callback_request(self): 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: 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) payload = json.loads(body)
# 验证是否为回调验证请求
if payload.get('op') == 13: if payload.get('op') == 13:
# 生成签名 validation_data = payload.get('d')
response = handle_validation(payload, self.secret) if not validation_data:
return {'error': "missing 'd' field"}, 400
return response response = await self.verify(validation_data)
return response, 200
if payload.get('op') == 0: if payload.get('op') == 0:
message_data = await self.get_message(payload) message_data = await self.get_message(payload)
@@ -104,6 +113,7 @@ class QQOfficialClient:
return {'code': 0, 'message': 'success'} return {'code': 0, 'message': 'success'}
except Exception as e: 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()}') await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
return {'error': str(e)}, 400 return {'error': str(e)}, 400
@@ -261,3 +271,26 @@ class QQOfficialClient:
if self.access_token_expiry_time is None: if self.access_token_expiry_time is None:
return True return True
return time.time() > self.access_token_expiry_time 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: 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.bot_token = bot_token
self.signing_secret = signing_secret self.signing_secret = signing_secret
self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
self.client = AsyncWebClient(self.bot_token) 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 = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -23,8 +28,28 @@ class SlackClient:
self.logger = logger self.logger = logger
async def handle_callback_request(self): 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: try:
body = await request.get_data() body = await req.get_data()
data = json.loads(body) data = json.loads(body)
if 'type' in data: if 'type' in data:
if data['type'] == 'url_verification': if data['type'] == 'url_verification':

View File

@@ -1,4 +1,4 @@
from libs.wechatpad_api.util.http_util import post_json from langbot.libs.wechatpad_api.util.http_util import post_json
class ChatRoomApi: class ChatRoomApi:

View File

@@ -1,4 +1,4 @@
from libs.wechatpad_api.util.http_util import post_json from langbot.libs.wechatpad_api.util.http_util import post_json
import httpx import httpx
import base64 import base64

View File

@@ -1,4 +1,4 @@
from libs.wechatpad_api.util.http_util import post_json, get_json from langbot.libs.wechatpad_api.util.http_util import post_json, get_json
class LoginApi: class LoginApi:

View File

@@ -1,4 +1,4 @@
from libs.wechatpad_api.util.http_util import post_json from langbot.libs.wechatpad_api.util.http_util import post_json
class MessageApi: class MessageApi:

View File

@@ -1,4 +1,4 @@
from libs.wechatpad_api.util.http_util import post_json, async_request, get_json from langbot.libs.wechatpad_api.util.http_util import post_json, async_request, get_json
class UserApi: class UserApi:

View File

@@ -1,9 +1,9 @@
from libs.wechatpad_api.api.login import LoginApi from langbot.libs.wechatpad_api.api.login import LoginApi
from libs.wechatpad_api.api.friend import FriendApi from langbot.libs.wechatpad_api.api.friend import FriendApi
from libs.wechatpad_api.api.message import MessageApi from langbot.libs.wechatpad_api.api.message import MessageApi
from libs.wechatpad_api.api.user import UserApi from langbot.libs.wechatpad_api.api.user import UserApi
from libs.wechatpad_api.api.downloadpai import DownloadApi from langbot.libs.wechatpad_api.api.downloadpai import DownloadApi
from libs.wechatpad_api.api.chatroom import ChatRoomApi from langbot.libs.wechatpad_api.api.chatroom import ChatRoomApi
class WeChatPadClient: class WeChatPadClient:

View File

@@ -16,7 +16,7 @@ import struct
from Crypto.Cipher import AES from Crypto.Cipher import AES
import xml.etree.cElementTree as ET import xml.etree.cElementTree as ET
import socket import socket
from libs.wecom_ai_bot_api import ierror from langbot.libs.wecom_ai_bot_api import ierror
""" """

View File

@@ -13,9 +13,9 @@ import httpx
from Crypto.Cipher import AES from Crypto.Cipher import AES
from quart import Quart, request, Response, jsonify from quart import Quart, request, Response, jsonify
from libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api import wecombotevent
from libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
from pkg.platform.logger import EventLogger from langbot.pkg.platform.logger import EventLogger
@dataclass @dataclass
@@ -200,7 +200,7 @@ class StreamSessionManager:
class WecomBotClient: 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: Args:
@@ -208,6 +208,7 @@ class WecomBotClient:
EnCodingAESKey: 企业微信消息加解密密钥 EnCodingAESKey: 企业微信消息加解密密钥
Corpid: 企业 ID Corpid: 企业 ID
logger: 日志记录器 logger: 日志记录器
unified_mode: 是否使用统一 webhook 模式默认 False
Example: Example:
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger) >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
@@ -217,13 +218,15 @@ class WecomBotClient:
self.EnCodingAESKey = EnCodingAESKey self.EnCodingAESKey = EnCodingAESKey
self.Corpid = Corpid self.Corpid = Corpid
self.ReceiveId = '' self.ReceiveId = ''
self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command', # 只有在非统一模式下才注册独立路由
'handle_callback', if not self.unified_mode:
self.handle_callback_request, self.app.add_url_rule(
methods=['POST', 'GET'] '/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET']
) )
self._message_handlers = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -362,7 +365,7 @@ class WecomBotClient:
return await self._encrypt_and_reply(payload, nonce) return await self._encrypt_and_reply(payload, nonce)
async def handle_callback_request(self): async def handle_callback_request(self):
"""企业微信回调入口。 """企业微信回调入口(独立端口模式,使用全局 request
Returns: Returns:
Quart Response: 根据请求类型返回验证首包或刷新结果 Quart Response: 根据请求类型返回验证首包或刷新结果
@@ -370,15 +373,34 @@ class WecomBotClient:
Example: Example:
作为 Quart 路由处理函数直接注册并使用 作为 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: try:
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
await self.logger.info(f'{request.method} {request.url} {str(request.args)}') await self.logger.info(f'{req.method} {req.url} {str(req.args)}')
if request.method == 'GET': if req.method == 'GET':
return await self._handle_get_callback() return await self._handle_get_callback(req)
if request.method == 'POST': if req.method == 'POST':
return await self._handle_post_callback() return await self._handle_post_callback(req)
return Response('', status=405) return Response('', status=405)
@@ -386,13 +408,13 @@ class WecomBotClient:
await self.logger.error(traceback.format_exc()) await self.logger.error(traceback.format_exc())
return Response('Internal Server Error', status=500) 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 验证请求。""" """处理企业微信的 GET 验证请求。"""
msg_signature = unquote(request.args.get('msg_signature', '')) msg_signature = unquote(req.args.get('msg_signature', ''))
timestamp = unquote(request.args.get('timestamp', '')) timestamp = unquote(req.args.get('timestamp', ''))
nonce = unquote(request.args.get('nonce', '')) nonce = unquote(req.args.get('nonce', ''))
echostr = unquote(request.args.get('echostr', '')) echostr = unquote(req.args.get('echostr', ''))
if not all([msg_signature, timestamp, nonce, echostr]): if not all([msg_signature, timestamp, nonce, echostr]):
await self.logger.error('请求参数缺失') await self.logger.error('请求参数缺失')
@@ -405,22 +427,22 @@ class WecomBotClient:
return Response(decrypted_str, mimetype='text/plain') 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 回调请求。""" """处理企业微信的 POST 回调请求。"""
self.stream_sessions.cleanup() self.stream_sessions.cleanup()
msg_signature = unquote(request.args.get('msg_signature', '')) msg_signature = unquote(req.args.get('msg_signature', ''))
timestamp = unquote(request.args.get('timestamp', '')) timestamp = unquote(req.args.get('timestamp', ''))
nonce = unquote(request.args.get('nonce', '')) 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', '') encrypted_msg = (encrypted_json or {}).get('encrypt', '')
if not encrypted_msg: if not encrypted_msg:
await self.logger.error("请求体中缺少 'encrypt' 字段") await self.logger.error("请求体中缺少 'encrypt' 字段")
return Response('Bad Request', status=400) return Response('Bad Request', status=400)
xml_post_data = f"<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>" xml_post_data = f'<xml><Encrypt><![CDATA[{encrypted_msg}]]></Encrypt></xml>'
ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce) ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)
if ret != 0: if ret != 0:
await self.logger.error('解密失败') await self.logger.error('解密失败')
@@ -458,7 +480,7 @@ class WecomBotClient:
picurl = item.get('image', {}).get('url') picurl = item.get('image', {}).get('url')
if texts: if texts:
message_data['content'] = "".join(texts) # 拼接所有 text message_data['content'] = ''.join(texts) # 拼接所有 text
if picurl: if picurl:
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey) base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
message_data['picurl'] = base64 # 只保留第一个 image message_data['picurl'] = base64 # 只保留第一个 image
@@ -466,7 +488,9 @@ class WecomBotClient:
# Extract user information # Extract user information
from_info = msg_json.get('from', {}) from_info = msg_json.get('from', {})
message_data['userid'] = from_info.get('userid', '') message_data['userid'] = from_info.get('userid', '')
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '') message_data['username'] = (
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
)
# Extract chat/group information # Extract chat/group information
if msg_json.get('chattype', '') == 'group': if msg_json.get('chattype', '') == 'group':
@@ -555,7 +579,7 @@ class WecomBotClient:
encrypted_bytes = response.content encrypted_bytes = response.content
aes_key = base64.b64decode(encoding_aes_key + "=") # base64 补齐 aes_key = base64.b64decode(encoding_aes_key + '=') # base64 补齐
iv = aes_key[:16] iv = aes_key[:16]
cipher = AES.new(aes_key, AES.MODE_CBC, iv) cipher = AES.new(aes_key, AES.MODE_CBC, iv)
@@ -564,22 +588,22 @@ class WecomBotClient:
pad_len = decrypted[-1] pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len] decrypted = decrypted[:-pad_len]
if decrypted.startswith(b"\xff\xd8"): # JPEG if decrypted.startswith(b'\xff\xd8'): # JPEG
mime_type = "image/jpeg" mime_type = 'image/jpeg'
elif decrypted.startswith(b"\x89PNG"): # PNG elif decrypted.startswith(b'\x89PNG'): # PNG
mime_type = "image/png" mime_type = 'image/png'
elif decrypted.startswith((b"GIF87a", b"GIF89a")): # GIF elif decrypted.startswith((b'GIF87a', b'GIF89a')): # GIF
mime_type = "image/gif" mime_type = 'image/gif'
elif decrypted.startswith(b"BM"): # BMP elif decrypted.startswith(b'BM'): # BMP
mime_type = "image/bmp" mime_type = 'image/bmp'
elif decrypted.startswith(b"II*\x00") or decrypted.startswith(b"MM\x00*"): # TIFF elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): # TIFF
mime_type = "image/tiff" mime_type = 'image/tiff'
else: else:
mime_type = "application/octet-stream" mime_type = 'application/octet-stream'
# 转 base64 # 转 base64
base64_str = base64.b64encode(decrypted).decode("utf-8") base64_str = base64.b64encode(decrypted).decode('utf-8')
return f"data:{mime_type};base64,{base64_str}" return f'data:{mime_type};base64,{base64_str}'
async def run_task(self, host: str, port: int, *args, **kwargs): async def run_task(self, host: str, port: int, *args, **kwargs):
""" """

View File

@@ -21,6 +21,7 @@ class WecomClient:
EncodingAESKey: str, EncodingAESKey: str,
contacts_secret: str, contacts_secret: str,
logger: None, logger: None,
unified_mode: bool = False,
): ):
self.corpid = corpid self.corpid = corpid
self.secret = secret self.secret = secret
@@ -31,13 +32,18 @@ class WecomClient:
self.access_token = '' self.access_token = ''
self.secret_for_contacts = contacts_secret self.secret_for_contacts = contacts_secret
self.logger = logger self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command', # 只有在非统一模式下才注册独立路由
'handle_callback', if not self.unified_mode:
self.handle_callback_request, self.app.add_url_rule(
methods=['GET', 'POST'], '/callback/command',
) 'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self._message_handlers = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -109,14 +115,13 @@ class WecomClient:
async def send_image(self, user_id: str, agent_id: int, media_id: str): async def send_image(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token(): if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret) self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
params = { params = {
'touser': user_id, 'touser': user_id,
'toparty': '',
'totag': '',
'agentid': agent_id,
'msgtype': 'image', 'msgtype': 'image',
'agentid': agent_id,
'image': { 'image': {
'media_id': media_id, 'media_id': media_id,
}, },
@@ -125,19 +130,13 @@ class WecomClient:
'enable_duplicate_check': 0, 'enable_duplicate_check': 0,
'duplicate_check_interval': 1800, 'duplicate_check_interval': 1800,
} }
try: response = await client.post(url, json=params)
response = await client.post(url, json=params) data = response.json()
data = response.json()
except Exception as e:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(e))
# 企业微信错误码40014和42001代表accesstoken问题
if data['errcode'] == 40014 or data['errcode'] == 42001: if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret) self.access_token = await self.get_access_token(self.secret)
return await self.send_image(user_id, agent_id, media_id) return await self.send_image(user_id, agent_id, media_id)
if data['errcode'] != 0: if data['errcode'] != 0:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(data)) raise Exception('Failed to send image: ' + str(data))
async def send_private_msg(self, user_id: str, agent_id: int, content: str): async def send_private_msg(self, user_id: str, agent_id: int, content: str):
@@ -168,25 +167,43 @@ class WecomClient:
raise Exception('Failed to send message: ' + str(data)) raise Exception('Failed to send message: ' + str(data))
async def handle_callback_request(self): 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: try:
msg_signature = request.args.get('msg_signature') msg_signature = req.args.get('msg_signature')
timestamp = request.args.get('timestamp') timestamp = req.args.get('timestamp')
nonce = request.args.get('nonce') nonce = req.args.get('nonce')
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
if request.method == 'GET': if req.method == 'GET':
echostr = request.args.get('echostr') echostr = req.args.get('echostr')
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0: if ret != 0:
await self.logger.error('验证失败') await self.logger.error('验证失败')
raise Exception(f'验证失败,错误码: {ret}') raise Exception(f'验证失败,错误码: {ret}')
return reply_echo_str return reply_echo_str
elif request.method == 'POST': elif req.method == 'POST':
encrypt_msg = await request.data encrypt_msg = await req.data
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0: if ret != 0:
await self.logger.error('消息解密失败') await self.logger.error('消息解密失败')
@@ -340,4 +357,3 @@ class WecomClient:
async def get_media_id(self, image: platform_message.Image): async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image) media_id = await self.upload_to_work(image=image)
return media_id return media_id

View File

@@ -13,7 +13,7 @@ import aiofiles
class WecomCSClient: 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):
self.corpid = corpid self.corpid = corpid
self.secret = secret self.secret = secret
self.access_token_for_contacts = '' self.access_token_for_contacts = ''
@@ -22,10 +22,15 @@ class WecomCSClient:
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin' self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
self.access_token = '' self.access_token = ''
self.logger = logger self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__) 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 = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -192,27 +197,45 @@ class WecomCSClient:
return data return data
async def handle_callback_request(self): 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: try:
msg_signature = request.args.get('msg_signature') msg_signature = req.args.get('msg_signature')
timestamp = request.args.get('timestamp') timestamp = req.args.get('timestamp')
nonce = request.args.get('nonce') nonce = req.args.get('nonce')
try: try:
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
except Exception as e: except Exception as e:
raise Exception(f'初始化失败,错误码: {e}') raise Exception(f'初始化失败,错误码: {e}')
if request.method == 'GET': if req.method == 'GET':
echostr = request.args.get('echostr') echostr = req.args.get('echostr')
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0: if ret != 0:
raise Exception(f'验证失败,错误码: {ret}') raise Exception(f'验证失败,错误码: {ret}')
return reply_echo_str return reply_echo_str
elif request.method == 'POST': elif req.method == 'POST':
encrypt_msg = await request.data encrypt_msg = await req.data
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0: if ret != 0:
raise Exception(f'消息解密失败,错误码: {ret}') raise Exception(f'消息解密失败,错误码: {ret}')

View File

@@ -110,7 +110,7 @@ class RouterGroup(abc.ABC):
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
# Try API key first (check X-API-Key header) # Try API key first (check X-API-Key header)
api_key = quart.request.headers.get('X-API-Key', '') api_key = quart.request.headers.get('X-API-Key', '')
if api_key: if api_key:
# API key authentication # API key authentication
try: try:
@@ -124,7 +124,9 @@ class RouterGroup(abc.ABC):
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
if not token: if not token:
return self.http_status(401, -1, 'No valid authentication provided (user token or API key required)') return self.http_status(
401, -1, 'No valid authentication provided (user token or API key required)'
)
try: try:
user_email = await self.ap.user_service.verify_jwt_token(token) user_email = await self.ap.user_service.verify_jwt_token(token)

View File

@@ -27,7 +27,9 @@ class PipelinesRouterGroup(group.RouterGroup):
async def _() -> str: async def _() -> str:
return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()}) return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
@self.route('/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route(
'/<pipeline_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(pipeline_uuid: str) -> str: async def _(pipeline_uuid: str) -> str:
if quart.request.method == 'GET': if quart.request.method == 'GET':
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
@@ -47,7 +49,9 @@ class PipelinesRouterGroup(group.RouterGroup):
return self.success() return self.success()
@self.route('/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route(
'/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(pipeline_uuid: str) -> str: async def _(pipeline_uuid: str) -> str:
if quart.request.method == 'GET': if quart.request.method == 'GET':
# Get current extensions and available plugins # Get current extensions and available plugins

View File

@@ -1,6 +1,7 @@
import quart import quart
import mimetypes
from ... import group from ... import group
from langbot.pkg.utils import importutil
@group.group_class('adapters', '/api/v1/platform/adapters') @group.group_class('adapters', '/api/v1/platform/adapters')
@@ -31,4 +32,6 @@ class AdaptersRouterGroup(group.RouterGroup):
if icon_path is None: if icon_path is None:
return self.http_status(404, -1, 'icon not found') return self.http_status(404, -1, 'icon not found')
return await quart.send_file(icon_path) return quart.Response(
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
)

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) @self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str: async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET': 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: if bot is None:
return self.http_status(404, -1, 'bot not found') return self.http_status(404, -1, 'bot not found')
return self.success(data={'bot': bot}) return self.success(data={'bot': bot})

View File

@@ -1,6 +1,8 @@
import quart import quart
import mimetypes
from ... import group from ... import group
from langbot.pkg.utils import importutil
@group.group_class('provider/requesters', '/api/v1/provider/requesters') @group.group_class('provider/requesters', '/api/v1/provider/requesters')
@@ -32,4 +34,6 @@ class RequestersRouterGroup(group.RouterGroup):
if icon_path is None: if icon_path is None:
return self.http_status(404, -1, 'icon not found') return self.http_status(404, -1, 'icon not found')
return await quart.send_file(icon_path) return quart.Response(
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
)

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import quart
import traceback
from .. import group
@group.group_class('webhooks', '/bots')
class WebhookRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def handle_webhook(bot_uuid: str):
"""处理 bot webhook 回调(无子路径)"""
return await self._dispatch_webhook(bot_uuid, '')
@self.route('/<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)
async def _dispatch_webhook(self, bot_uuid: str, path: str):
"""分发 webhook 请求到对应的 bot adapter
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
Returns:
适配器返回的响应
"""
try:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if not runtime_bot:
return quart.jsonify({'error': 'Bot not found'}), 404
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

@@ -86,7 +86,9 @@ class HTTPController:
ginst = g(self.ap, self.quart_app) ginst = g(self.ap, self.quart_app)
await ginst.initialize() await ginst.initialize()
frontend_path = 'web/out' from ....utils import paths
frontend_path = paths.get_frontend_path()
@self.quart_app.route('/') @self.quart_app.route('/')
async def index(): async def index():

View File

@@ -61,9 +61,7 @@ class ApiKeyService:
async def delete_api_key(self, key_id: int) -> None: async def delete_api_key(self, key_id: int) -> None:
"""Delete an API key""" """Delete an API key"""
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id))
sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
)
async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None: async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:
"""Update an API key's metadata (name, description)""" """Update an API key's metadata (name, description)"""

View File

@@ -58,6 +58,16 @@ class BotService:
if runtime_bot is not None: if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id 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 persistence_bot['adapter_runtime_values'] = adapter_runtime_values
return persistence_bot return persistence_bot

View File

@@ -84,13 +84,11 @@ class MCPService:
new_enable = server_data.get('enable', False) new_enable = server_data.get('enable', False)
need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions
need_start = new_enable
if old_enable and not new_enable: if old_enable and not new_enable:
if need_remove: if need_remove:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
elif not old_enable and new_enable: elif not old_enable and new_enable:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
@@ -100,7 +98,7 @@ class MCPService:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
elif old_enable and new_enable: elif old_enable and new_enable:
if need_remove: if need_remove:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
@@ -112,7 +110,6 @@ class MCPService:
server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
async def delete_mcp_server(self, server_uuid: str) -> None: async def delete_mcp_server(self, server_uuid: str) -> None:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(

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