Compare commits

...

11 Commits

Author SHA1 Message Date
RockChinQ
d3740eed9e feat: add teams adapter 2025-12-04 01:05:51 +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
57 changed files with 2152 additions and 433 deletions

View File

@@ -0,0 +1,162 @@
# Microsoft Teams Adapter Implementation
## Overview
A new Microsoft Teams platform adapter has been added to LangBot, enabling support for both personal chats and channel/group chats on Microsoft Teams.
## Files Created/Modified
### New Files
1. **`src/langbot/pkg/platform/sources/teams.py`** - Main adapter implementation
- `TeamsMessageConverter`: Converts between LangBot message format and Teams Activity format
- `TeamsEventConverter`: Converts between Teams Activity events and LangBot platform events
- `TeamsAdapter`: Main adapter class with webhook handling
2. **`src/langbot/pkg/platform/sources/teams.yaml`** - Adapter manifest
- Defines adapter metadata, configuration schema, and execution details
- Configuration fields: `app_id` and `app_password`
### Modified Files
1. **`pyproject.toml`** - Added dependencies:
- `botbuilder-core>=4.15.0`
- `botbuilder-schema>=4.15.0`
- `botframework-connector>=4.15.0`
- Added "teams" keyword
## Features
### Supported Message Types
- ✅ Plain text messages
- ✅ Image attachments (base64, URL, and file path)
-@mentions (converted to At components)
- ✅ Message replies with quote support
### Supported Chat Types
- ✅ Personal chats (1-on-1 conversations)
- ✅ Channel chats (group/team conversations)
### Message Handling
- **Incoming Messages**: Received via webhook at the unified webhook endpoint
- **Outgoing Messages**: Sent via Bot Framework SDK using conversation references
- **Event Types**: FriendMessage (personal) and GroupMessage (channel/group)
## Configuration Requirements
### Azure Setup
1. **Register an Azure AD Application**:
- Go to Azure Portal → Azure Active Directory → App registrations
- Create a new registration
- Note the **Application (client) ID** - this is your `app_id`
- Create a **Client Secret** - this is your `app_password`
2. **Create Azure Bot Resource**:
- Go to Azure Portal → Create a resource → Azure Bot
- Link it to your Azure AD application
- Enable the Microsoft Teams channel
- Set the messaging endpoint to: `https://<your-domain>/bots/<bot-uuid>`
### LangBot Configuration
When creating a Teams bot in LangBot, provide:
```yaml
adapter: teams
adapter_config:
app_id: "<your-microsoft-app-id>"
app_password: "<your-microsoft-app-password>"
```
## Architecture
### Webhook Mode
The Teams adapter operates in webhook mode, similar to the Slack adapter:
- Integrates with LangBot's unified webhook system
- Receives messages at `/bots/<bot-uuid>` endpoint
- No independent server process required
### Message Flow
1. **Incoming**:
- Teams → Azure Bot Service → LangBot Webhook
- Bot Framework validates the request
- Activity converted to LangBot event format
- Event dispatched to registered listeners
2. **Outgoing**:
- LangBot message chain → Teams Activity format
- Reply sent via Bot Framework adapter
- Uses conversation reference for proper routing
## Authentication
- Uses Bot Framework authentication with Microsoft App credentials
- JWT token validation handled by `BotFrameworkAdapter`
- Authorization header validated on each incoming request
## Limitations & Notes
1. **Direct Send**: The `send_message` method is limited - use `reply_message` for best results
2. **Conversation References**: The adapter uses conversation references from incoming activities to send replies
3. **Image Handling**: Images are converted to inline attachments using data URIs
4. **Streaming**: Streaming replies are not yet implemented
## Testing
To test the adapter:
1. Install dependencies: `uv sync`
2. Verify adapter initialization: `uv run python -c "from src.langbot.pkg.platform.sources.teams import TeamsAdapter; print('OK')"`
3. Configure a Teams bot in LangBot with your Azure credentials
4. Expose the LangBot API endpoint publicly (use ngrok or similar)
5. Set the messaging endpoint in Azure Bot Service
6. Add the bot to Teams and start chatting
## Troubleshooting
### Pydantic Validation Error on Initialization
**Fixed**: The adapter was updated to properly handle optional fields (`adapter`, `app`, `bot_uuid`) by:
- Setting `default=None` in pydantic.Field definitions
- Not passing these fields to `super().__init__()`
- Setting the adapter instance after parent class initialization
This resolves the validation error: "Input should be an instance of Quart" / "Input should be a valid string"
## Future Enhancements
Potential improvements:
- Adaptive Cards support
- Rich message formatting (markdown, cards)
- File attachments (non-image)
- Streaming message support
- Proactive messaging
- Team/channel member list retrieval
- Reaction handling
## Dependencies
The following Python packages were added:
- `botbuilder-core` - Core Bot Framework functionality
- `botbuilder-schema` - Activity and entity schemas
- `botframework-connector` - Bot Framework connector for authentication
## References
- [Microsoft Teams Bot Framework Documentation](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-features)
- [Bot Framework SDK for Python](https://github.com/microsoft/botbuilder-python)
- [Teams Conversation Bot Sample](https://learn.microsoft.com/en-us/samples/officedev/microsoft-teams-samples/officedev-microsoft-teams-samples-bot-conversation-python/)
## Sources
Research sources consulted during implementation:
- [Create an Incoming Webhook - Teams | Microsoft Learn](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook)
- [Teams Conversation Bot - Code Samples | Microsoft Learn](https://learn.microsoft.com/en-us/samples/officedev/microsoft-teams-samples/officedev-microsoft-teams-samples-bot-conversation-python/)
- [GitHub - microsoft/botbuilder-python](https://github.com/microsoft/botbuilder-python)
- [Tools and Bot Framework SDKs for Bots - Teams](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-features)

View File

@@ -30,8 +30,8 @@ services:
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

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.5.4"
version = "4.6.1"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
@@ -14,6 +14,9 @@ dependencies = [
"anthropic>=0.51.0",
"argon2-cffi>=23.1.0",
"async-lru>=2.0.5",
"botbuilder-core>=4.15.0",
"botbuilder-schema>=4.15.0",
"botframework-connector>=4.15.0",
"certifi>=2025.4.26",
"colorlog~=6.6.0",
"cryptography>=44.0.3",
@@ -63,7 +66,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.2.0b1",
"langbot-plugin==0.2.0",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
@@ -73,6 +76,7 @@ keywords = [
"bot",
"agent",
"telegram",
"teams",
"plugins",
"openai",
"instant-messaging",

View File

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

View File

@@ -23,20 +23,25 @@ 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):
self.token = token
self.aes = EncodingAESKey
self.appid = AppID
self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com'
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 +51,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 +93,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,6 +207,7 @@ class OAClientForLongerResponse:
Appsecret: str,
LoadingMessage: str,
logger: None,
unified_mode: bool = False,
):
self.token = token
self.aes = EncodingAESKey
@@ -189,13 +215,18 @@ class OAClientForLongerResponse:
self.appsecret = Appsecret
self.base_url = 'https://api.weixin.qq.com'
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 +237,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,34 @@ 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)}')
await self.logger.info(f'{req.method} {req.url} {str(req.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 +408,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 +427,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' 字段")

View File

@@ -29,12 +29,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:
@@ -70,7 +65,7 @@ class WecomBotEvent(dict):
消息id
"""
return self.get('msgid', '')
@property
def ai_bot_id(self) -> str:
"""

View File

@@ -21,6 +21,7 @@ class WecomClient:
EncodingAESKey: str,
contacts_secret: str,
logger: None,
unified_mode: bool = False,
):
self.corpid = corpid
self.secret = secret
@@ -31,13 +32,18 @@ class WecomClient:
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': [],
}
@@ -161,25 +167,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('消息解密失败')

View File

@@ -13,7 +13,7 @@ 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):
self.corpid = corpid
self.secret = secret
self.access_token_for_contacts = ''
@@ -22,10 +22,15 @@ class WecomCSClient:
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
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': [],
}
@@ -192,27 +197,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

@@ -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

@@ -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

@@ -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']:
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

@@ -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

@@ -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

@@ -245,6 +245,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

@@ -121,6 +121,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 +133,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 +150,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 +163,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 +213,73 @@ 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 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('LINE Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 LINE 后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
Appsecret=config['AppSecret'],
AppID=config['AppID'],
logger=logger,
unified_mode=True,
)
elif config['Mode'] == 'passive':
bot = OAClientForLongerResponse(
@@ -81,6 +85,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
AppID=config['AppID'],
LoadingMessage=config.get('LoadingMessage', ''),
logger=logger,
unified_mode=True,
)
else:
raise KeyError('请设置微信公众号通信模式')
@@ -129,16 +134,46 @@ 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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('微信公众号 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在微信公众号后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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,6 @@ spec:
type: string
required: true
default: "AI正在思考中请发送任意内容获取回复。"
- name: host
label:
en_US: Host
zh_Hans: 监听主机
description:
en_US: The host that Official Account listens on for Webhook connections.
zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 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: 2287
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):
@@ -134,11 +134,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 +226,46 @@ 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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('QQ 官方机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 QQ 官方机器人后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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

@@ -97,13 +97,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 +111,18 @@ 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 +174,45 @@ 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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"Slack 机器人 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在 Slack 后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
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

@@ -0,0 +1,398 @@
from __future__ import annotations
import typing
import asyncio
import traceback
import base64
import datetime
import aiohttp
import pydantic
from botbuilder.core import BotFrameworkAdapter, BotFrameworkAdapterSettings, TurnContext
from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, ConversationAccount
from botframework.connector.auth import MicrosoftAppCredentials
from quart import Quart, request, Response
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
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 TeamsMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Convert messages between LangBot format and Teams Activity format"""
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> dict:
"""Convert LangBot message chain to Teams message format"""
text_content = []
attachments = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
text_content.append(component.text)
elif isinstance(component, platform_message.Image):
# Teams supports image attachments
image_data = None
image_type = 'image/png'
if component.base64:
# Extract base64 data
if component.base64.startswith('data:'):
parts = component.base64.split(',')
header = parts[0]
if 'image/' in header:
image_type = header.split(';')[0].replace('data:', '')
image_data = parts[1] if len(parts) > 1 else component.base64
else:
image_data = component.base64
elif component.url:
# For URLs, Teams can display them inline
text_content.append(f'\n{component.url}')
continue
elif component.path:
try:
with open(component.path, 'rb') as f:
image_bytes = f.read()
image_data = base64.b64encode(image_bytes).decode('utf-8')
except Exception:
continue
if image_data:
# Create inline image attachment
attachments.append({
'contentType': image_type,
'contentUrl': f'data:{image_type};base64,{image_data}',
'name': 'image'
})
result = {
'text': ''.join(text_content),
}
if attachments:
result['attachments'] = attachments
return result
@staticmethod
async def target2yiri(activity: Activity) -> platform_message.MessageChain:
"""Convert Teams Activity to LangBot message chain"""
components = []
# Add message source
components.append(
platform_message.Source(
id=activity.id,
time=activity.timestamp if activity.timestamp else datetime.datetime.now()
)
)
# Handle mentions (convert to At components)
if activity.entities:
for entity in activity.entities:
if entity.type == 'mention':
mentioned = entity.mentioned
if mentioned:
components.append(platform_message.At(target=str(mentioned.id)))
# Add text content
if activity.text:
text = activity.text
# Remove bot mentions from text
if activity.entities:
for entity in activity.entities:
if entity.type == 'mention' and entity.text:
text = text.replace(entity.text, '').strip()
if text:
components.append(platform_message.Plain(text=text))
# Handle attachments (images, files, etc.)
if activity.attachments:
for attachment in activity.attachments:
if attachment.content_type and 'image' in attachment.content_type:
# Download and convert image to base64
if attachment.content_url:
try:
async with aiohttp.ClientSession() as session:
async with session.get(attachment.content_url) as response:
image_data = await response.read()
image_base64 = base64.b64encode(image_data).decode('utf-8')
content_type = attachment.content_type or 'image/png'
components.append(
platform_message.Image(
base64=f'data:{content_type};base64,{image_base64}'
)
)
except Exception:
pass
return platform_message.MessageChain(components)
class TeamsEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Convert events between Teams Activity and LangBot platform events"""
@staticmethod
async def yiri2target(event: platform_events.Event) -> Activity:
"""Convert LangBot event to Teams Activity"""
return event.source_platform_object
@staticmethod
async def target2yiri(activity: Activity, bot_id: str) -> platform_events.Event:
"""Convert Teams Activity to LangBot event"""
message_chain = await TeamsMessageConverter.target2yiri(activity)
# Determine if it's a personal or channel/group chat
conversation_type = activity.conversation.conversation_type if activity.conversation else None
if conversation_type == 'personal':
# Personal chat (1-on-1)
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=activity.from_property.id,
nickname=activity.from_property.name or activity.from_property.id,
remark=activity.from_property.id,
),
message_chain=message_chain,
time=activity.timestamp.timestamp() if activity.timestamp else datetime.datetime.now().timestamp(),
source_platform_object=activity,
)
else:
# Channel or group chat
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=activity.from_property.id,
member_name=activity.from_property.name or activity.from_property.id,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=activity.conversation.id,
name=activity.conversation.name or activity.conversation.id,
permission=platform_entities.Permission.Member,
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0,
),
message_chain=message_chain,
time=activity.timestamp.timestamp() if activity.timestamp else datetime.datetime.now().timestamp(),
source_platform_object=activity,
)
class TeamsAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Microsoft Teams platform adapter for LangBot"""
adapter: BotFrameworkAdapter = pydantic.Field(exclude=True, default=None)
app: Quart = pydantic.Field(exclude=True, default=None)
bot_uuid: typing.Optional[str] = None
message_converter: TeamsMessageConverter = TeamsMessageConverter()
event_converter: TeamsEventConverter = TeamsEventConverter()
config: dict
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
"""Initialize Teams adapter with app credentials"""
# Validate required config
if 'app_id' not in config or 'app_password' not in config:
raise ValueError('Teams adapter requires app_id and app_password in configuration')
# Create Bot Framework adapter settings
settings = BotFrameworkAdapterSettings(
app_id=config['app_id'],
app_password=config['app_password']
)
# Create Bot Framework adapter
adapter_instance = BotFrameworkAdapter(settings)
super().__init__(
config=config,
logger=logger,
bot_account_id=config['app_id'],
listeners={},
**kwargs
)
# Set the adapter after initialization
self.adapter = adapter_instance
async def _handle_activity(self, activity: Activity):
"""Internal method to handle incoming activities"""
try:
# Only process message activities from users (not bots)
if activity.type == ActivityTypes.message:
if activity.from_property and not (hasattr(activity.from_property, 'role') and activity.from_property.role == 'bot'):
# Convert to LangBot event
lb_event = await self.event_converter.target2yiri(activity, self.bot_account_id)
# Call appropriate listener
event_type = type(lb_event)
if event_type in self.listeners:
await self.listeners[event_type](lb_event, self)
except Exception as e:
await self.logger.error(f'Error handling Teams activity: {str(e)}\n{traceback.format_exc()}')
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""Send a message to a specific target"""
try:
message_data = await self.message_converter.yiri2target(message)
# Create activity
activity = Activity(
type=ActivityTypes.message,
text=message_data.get('text', ''),
attachments=message_data.get('attachments', []),
conversation=ConversationAccount(id=target_id),
)
# Send via Bot Framework adapter
# Note: This requires a conversation reference which we would need to store
# For now, this is a placeholder - full implementation would require conversation tracking
await self.logger.warning('Direct send_message not fully implemented - use reply_message instead')
except Exception as e:
await self.logger.error(f'Error sending Teams message: {str(e)}')
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
"""Reply to a message"""
try:
assert isinstance(message_source.source_platform_object, Activity)
source_activity: Activity = message_source.source_platform_object
message_data = await self.message_converter.yiri2target(message)
# Build conversation reference from source activity
conversation_reference = TurnContext.get_conversation_reference(source_activity)
# Create reply activity
async def send_reply(turn_context: TurnContext):
reply_text = message_data.get('text', '')
reply_attachments = message_data.get('attachments', [])
if quote_origin:
# Include reply_to_id to quote the original message
await turn_context.send_activity(
Activity(
type=ActivityTypes.message,
text=reply_text,
attachments=reply_attachments,
reply_to_id=source_activity.id
)
)
else:
await turn_context.send_activity(
Activity(
type=ActivityTypes.message,
text=reply_text,
attachments=reply_attachments
)
)
# Continue conversation using the conversation reference
await self.adapter.continue_conversation(
conversation_reference,
send_reply,
self.bot_account_id
)
except Exception as e:
await self.logger.error(f'Error replying to Teams message: {str(e)}\n{traceback.format_exc()}')
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
"""Register 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 event listener"""
if event_type in self.listeners:
del self.listeners[event_type]
def set_bot_uuid(self, bot_uuid: str):
"""Set bot UUID for webhook URL generation"""
self.bot_uuid = bot_uuid
async def handle_unified_webhook(self, _bot_uuid: str, _path: str, request_obj):
"""Handle unified webhook requests from Teams
Args:
_bot_uuid: Bot UUID (unused)
_path: Sub-path (unused)
request_obj: Quart Request object
Returns:
Response data
"""
try:
# Get request body
body = await request_obj.get_json()
# Get authorization header
auth_header = request_obj.headers.get('Authorization', '')
# Process activity
async def bot_logic(turn_context: TurnContext):
# Handle the activity
await self._handle_activity(turn_context.activity)
# Process the request through Bot Framework adapter
await self.adapter.process_activity(body, auth_header, bot_logic)
return Response(status=200)
except Exception as e:
await self.logger.error(f'Error processing Teams webhook: {str(e)}\n{traceback.format_exc()}')
return Response(status=500)
async def run_async(self):
"""Run the adapter - Teams uses webhook mode, so just keep alive and log webhook URL"""
# Print webhook callback address
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"Teams Bot Webhook URL:")
await self.logger.info(f" Local: {webhook_url}")
await self.logger.info(f" Public: {webhook_url_public}")
await self.logger.info(f"Configure this URL as the messaging endpoint in Azure Bot Service")
except Exception as e:
await self.logger.warning(f"Could not generate webhook URL: {e}")
# Keep the adapter running
async def keep_alive():
while True:
await asyncio.sleep(1)
await keep_alive()
async def kill(self) -> bool:
"""Shutdown the adapter"""
await self.logger.info('Teams adapter stopped')
return True

View File

@@ -0,0 +1,37 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: teams
label:
en_US: Microsoft Teams
zh_Hans: 微软 Teams
description:
en_US: Microsoft Teams Adapter - supports personal and channel/group chats
zh_Hans: 微软 Teams 适配器,支持个人聊天和频道/群组聊天
icon: teams.svg
spec:
config:
- name: app_id
label:
en_US: App ID
zh_Hans: 应用 ID
description:
en_US: Microsoft App ID from Azure Bot registration
zh_Hans: Azure Bot 注册的 Microsoft 应用 ID
type: string
required: true
default: ""
- name: app_password
label:
en_US: App Password
zh_Hans: 应用密码
description:
en_US: Microsoft App Password (Client Secret) from Azure Bot registration
zh_Hans: Azure Bot 注册的 Microsoft 应用密码(客户端密钥)
type: string
required: true
default: ""
execution:
python:
path: ./teams.py
attr: TeamsAdapter

View File

@@ -117,7 +117,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 +134,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 +164,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,13 +200,15 @@ 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()
@@ -344,13 +349,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

@@ -8,8 +8,8 @@ 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
@@ -131,6 +131,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 +142,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 +155,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'],
logger=logger,
unified_mode=True,
)
super().__init__(
@@ -162,6 +165,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,
@@ -180,9 +187,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_image(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]
@@ -214,16 +218,38 @@ 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():
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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

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
@@ -88,19 +88,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 +190,46 @@ 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 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
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

@@ -11,13 +11,6 @@ metadata:
icon: wecombot.png
spec:
config:
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2291
- name: Corpid
label:
en_US: Corpid

View File

@@ -121,6 +121,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: WecomCSClient = pydantic.Field(exclude=True)
message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter()
bot_uuid: str = None
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
required_keys = [
@@ -139,6 +140,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
token=config['token'],
EncodingAESKey=config['EncodingAESKey'],
logger=logger,
unified_mode=True,
)
super().__init__(
@@ -170,6 +172,10 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID用于生成 webhook URL"""
self.bot_uuid = bot_uuid
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
@@ -190,16 +196,41 @@ class WecomCSAdapter(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():
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"企业微信客服 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在企业微信后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
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

@@ -11,13 +11,6 @@ metadata:
icon: wecom.png
spec:
config:
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: int
required: true
default: 2289
- name: corpid
label:
en_US: Corpid

View File

@@ -385,6 +385,12 @@ class PluginRuntimeConnector:
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
if not self.is_enable_plugin:
return {}
return await self.handler.get_debug_info()
async def emit_event(
self,
event: events.BaseEventModel,

View File

@@ -758,3 +758,12 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=30,
)
return result
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
)
return result

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 12
required_database_version = 13
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

View File

@@ -1,6 +1,7 @@
admins: []
api:
port: 5300
webhook_prefix: 'http://127.0.0.1:5300'
command:
enable: true
prefix:
@@ -48,3 +49,4 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
cloud_service_url: 'https://space.langbot.app'
display_plugin_debug_url: 'http://localhost:5401'

View File

@@ -0,0 +1,143 @@
"""
Tests for webhook_prefix configuration
"""
import os
import pytest
from typing import Any
def _apply_env_overrides_to_config(cfg: dict) -> dict:
"""Apply environment variable overrides to data/config.yaml
Environment variables should be uppercase and use __ (double underscore)
to represent nested keys. For example:
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
Arrays and dict types are ignored.
Args:
cfg: Configuration dictionary
Returns:
Updated configuration dictionary
"""
def convert_value(value: str, original_value: Any) -> Any:
"""Convert string value to appropriate type based on original value
Args:
value: String value from environment variable
original_value: Original value to infer type from
Returns:
Converted value (falls back to string if conversion fails)
"""
if isinstance(original_value, bool):
return value.lower() in ('true', '1', 'yes', 'on')
elif isinstance(original_value, int):
try:
return int(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
elif isinstance(original_value, float):
try:
return float(value)
except ValueError:
# If conversion fails, keep as string (user error, but non-breaking)
return value
else:
return value
# Process environment variables
for env_key, env_value in os.environ.items():
# Check if the environment variable is uppercase and contains __
if not env_key.isupper():
continue
if '__' not in env_key:
continue
# Convert environment variable name to config path
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
keys = [key.lower() for key in env_key.split('__')]
# Navigate to the target value and validate the path
current = cfg
for i, key in enumerate(keys):
if not isinstance(current, dict) or key not in current:
break
if i == len(keys) - 1:
# At the final key - check if it's a scalar value
if isinstance(current[key], (dict, list)):
# Skip dict and list types
pass
else:
# Valid scalar value - convert and set it
converted_value = convert_value(env_value, current[key])
current[key] = converted_value
else:
# Navigate deeper
current = current[key]
return cfg
class TestWebhookDisplayPrefix:
"""Test webhook_prefix configuration functionality"""
def test_default_webhook_prefix(self):
"""Test that the default webhook display prefix is correctly set"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Should have the default value
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
def test_webhook_prefix_env_override(self):
"""Test overriding webhook_prefix via environment variable"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Set environment variable
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com:8080'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
def test_webhook_prefix_with_custom_domain(self):
"""Test webhook_prefix with custom domain"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Set to a custom domain
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://bot.mycompany.com'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
def test_webhook_prefix_with_subdirectory(self):
"""Test webhook_prefix with subdirectory path"""
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
# Set to a URL with subdirectory
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['webhook_prefix'] == 'https://example.com/langbot'
# Cleanup
del os.environ['API__WEBHOOK_PREFIX']
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -74,11 +74,18 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/debug": "^4.1.12",
"@types/estree": "^1.0.8",
"@types/estree-jsx": "^1.0.5",
"@types/hast": "^3.0.4",
"@types/lodash": "^4.17.16",
"@types/mdast": "^4.0.4",
"@types/ms": "^2.1.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/unist": "^3.0.3",
"eslint": "^9",
"eslint-config-next": "15.2.4",
"eslint-config-prettier": "^10.1.2",

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
IChooseAdapterEntity,
IPipelineEntity,
@@ -112,11 +112,86 @@ export default function BotForm({
IDynamicFormItemSchema[]
>([]);
const [, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const webhookInputRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
setBotFormValues();
}, []);
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
const copyToClipboard = () => {
console.log('[Copy] Attempting to copy from input element');
const inputElement = webhookInputRef.current;
if (!inputElement) {
console.error('[Copy] Input element not found');
toast.error(t('common.copyFailed'));
return;
}
try {
// 确保input元素可见且未被禁用
inputElement.disabled = false;
inputElement.readOnly = false;
// 聚焦并选中所有文本
inputElement.focus();
inputElement.select();
// 尝试使用现代API
if (navigator.clipboard && navigator.clipboard.writeText) {
console.log(
'[Copy] Using Clipboard API with input value:',
inputElement.value,
);
navigator.clipboard
.writeText(inputElement.value)
.then(() => {
console.log('[Copy] Clipboard API success');
inputElement.blur(); // 取消选中
inputElement.readOnly = true;
toast.success(t('bots.webhookUrlCopied'));
})
.catch((err) => {
console.error(
'[Copy] Clipboard API failed, trying execCommand:',
err,
);
// 降级到execCommand
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
toast.success(t('bots.webhookUrlCopied'));
} else {
toast.error(t('common.copyFailed'));
}
});
} else {
// 直接使用execCommand
console.log(
'[Copy] Using execCommand with input value:',
inputElement.value,
);
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
toast.success(t('bots.webhookUrlCopied'));
} else {
toast.error(t('common.copyFailed'));
}
}
} catch (err) {
console.error('[Copy] Copy failed:', err);
inputElement.readOnly = true;
toast.error(t('common.copyFailed'));
}
};
function setBotFormValues() {
initBotFormComponent().then(() => {
// 拉取初始化表单信息
@@ -131,12 +206,20 @@ export default function BotForm({
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
handleAdapterSelect(val.adapter);
// dynamicForm.setFieldsValue(val.adapter_config);
// 设置 webhook 地址(如果有)
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
})
.catch((err) => {
toast.error(t('bots.getBotConfigError') + err.message);
});
} else {
form.reset();
setWebhookUrl('');
}
});
}
@@ -209,7 +292,7 @@ export default function BotForm({
async function getBotConfig(
botId: string,
): Promise<z.infer<typeof formSchema>> {
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
return new Promise((resolve, reject) => {
httpClient
.getBot(botId)
@@ -222,6 +305,10 @@ export default function BotForm({
adapter_config: bot.adapter_config,
enable: bot.enable ?? true,
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
webhook_full_url: bot.adapter_runtime_values
? ((bot.adapter_runtime_values as Record<string, unknown>)
.webhook_full_url as string)
: undefined,
});
})
.catch((err) => {
@@ -360,51 +447,86 @@ export default function BotForm({
<div className="space-y-4">
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
{initBotId && (
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<>
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem
key={item.value}
value={item.value}
>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Webhook 地址显示(统一 Webhook 模式) */}
{webhookUrl && (
<FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2">
<Input
ref={webhookInputRef}
value={webhookUrl}
readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900"
onClick={(e) => {
// 点击输入框时自动全选
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={copyToClipboard}
>
{t('common.copy')}
</Button>
</div>
<p className="text-sm text-gray-500 mt-1">
{t('bots.webhookUrlHint')}
</p>
</FormItem>
)}
</>
)}
<FormField

View File

@@ -509,6 +509,17 @@ export default function ExternalKBForm({
</Select>
</FormControl>
<FormMessage />
<p className="text-sm text-muted-foreground">
{t('knowledge.retrieverInstallInfo')}{' '}
<a
href="https://space.langbot.app/market?category=KnowledgeRetriever"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:no-underline"
>
{t('knowledge.retrieverMarketLink')}
</a>
</p>
</FormItem>
)}
/>

View File

@@ -154,7 +154,7 @@ export default function PipelineDialog({
// 编辑流水线时的对话框
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] h-[75vh] flex">
<DialogContent className="overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex">
<SidebarProvider className="items-start w-full flex h-full min-h-0">
<Sidebar
collapsible="none"

View File

@@ -20,6 +20,13 @@ import { toast } from 'sonner';
import AtBadge from './AtBadge';
import { WebSocketClient } from '@/app/infra/websocket/WebSocketClient';
import ImagePreviewDialog from './ImagePreviewDialog';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import '@/styles/github-markdown.css';
interface DebugDialogProps {
open: boolean;
@@ -50,7 +57,9 @@ export default function DebugDialog({
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
const [showImagePreview, setShowImagePreview] = useState(false);
const [quotedMessage, setQuotedMessage] = useState<Message | null>(null);
const [hoveredMessageId, setHoveredMessageId] = useState<number | null>(null);
const [rawModeMessages, setRawModeMessages] = useState<Set<string>>(
new Set(),
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
@@ -195,6 +204,8 @@ export default function DebugDialog({
// 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接
useEffect(() => {
if (open) {
// 清空当前消息,避免显示旧的消息
setMessages([]);
loadMessages(selectedPipelineId);
initWebSocket(selectedPipelineId);
}
@@ -554,7 +565,111 @@ export default function DebugDialog({
return t('bots.earlier');
};
// Generate a unique key for a message
const getMessageKey = (message: Message): string => {
return `${message.id}-${message.timestamp}`;
};
// Toggle raw mode for a message (by default, messages are in markdown mode)
const toggleRawMode = (message: Message) => {
const key = getMessageKey(message);
setRawModeMessages((prev) => {
const newSet = new Set(prev);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
};
// Check if message has any Plain text content
const hasPlainText = (message: Message): boolean => {
return message.message_chain.some((c) => c.type === 'Plain');
};
// Extract plain text from message chain
const getPlainText = (message: Message): string => {
return message.message_chain
.filter((c) => c.type === 'Plain')
.map((c) => (c as Plain).text)
.join('');
};
const renderMessageContent = (message: Message) => {
const key = getMessageKey(message);
const isRawMode = rawModeMessages.has(key);
// By default, render with markdown if there's plain text (unless raw mode is enabled)
if (!isRawMode && hasPlainText(message)) {
const plainText = getPlainText(message);
const nonPlainComponents = message.message_chain.filter(
(c) => c.type !== 'Plain' && c.type !== 'Source',
);
return (
<div className="text-base leading-relaxed align-middle">
{/* Render non-Plain components first */}
{nonPlainComponents.map((component, index) =>
renderMessageComponent(component, index),
)}
{/* Render Plain text as markdown */}
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
rehypeHighlight,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: {
className: ['anchor'],
},
},
],
]}
components={{
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
ol: ({ children }) => (
<ol className="list-decimal">{children}</ol>
),
li: ({ children }) => <li className="ml-4">{children}</li>,
img: ({ src, alt, ...props }) => {
const imageSrc = src || '';
if (typeof imageSrc !== 'string') {
return (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg my-4"
{...props}
/>
);
}
return (
<img
src={imageSrc}
alt={alt || ''}
className="max-w-lg h-auto my-4"
{...props}
/>
);
},
}}
>
{plainText}
</ReactMarkdown>
</div>
</div>
);
}
return (
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
{message.message_chain.map((component, index) =>
@@ -619,68 +734,109 @@ export default function DebugDialog({
<div
key={message.id + message.timestamp}
className={cn(
'flex group',
'flex',
message.role === 'user' ? 'justify-end' : 'justify-start',
)}
onMouseEnter={() => setHoveredMessageId(message.id)}
onMouseLeave={() => setHoveredMessageId(null)}
>
<div
className={cn(
'relative flex items-end gap-2',
message.role === 'user' ? 'flex-row-reverse' : 'flex-row',
'max-w-3xl px-5 py-3 rounded-2xl',
message.role === 'user'
? 'user-message-bubble bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'max-w-md px-5 py-3 rounded-2xl',
'text-xs mt-2 flex items-center justify-between gap-2',
message.role === 'user'
? 'bg-[#2288ee] text-white rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400',
)}
>
{renderMessageContent(message)}
<div
className={cn(
'text-xs mt-2 flex items-center justify-between gap-2',
message.role === 'user'
? 'text-white/70'
: 'text-gray-500 dark:text-gray-400',
)}
>
<div className="flex items-center gap-2">
<span>
{message.role === 'user'
? t('pipelines.debugDialog.userMessage')
: t('pipelines.debugDialog.botMessage')}
</span>
<span className="text-[10px]">
{formatTimestamp(getMessageTimestamp(message))}
</span>
</div>
</div>
{hoveredMessageId === message.id && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => setQuotedMessage(message)}
>
<svg
className="w-3 h-3 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{hasPlainText(message) && (
<button
onClick={() => toggleRawMode(message)}
className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors',
message.role === 'user'
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
)}
title={
rawModeMessages.has(getMessageKey(message))
? t('pipelines.debugDialog.showMarkdown')
: t('pipelines.debugDialog.showRaw')
}
>
{rawModeMessages.has(getMessageKey(message)) ? (
<span className="flex items-center gap-0.5">
<svg
className="w-3 h-3"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z" />
</svg>
MD
</span>
) : (
<span className="flex items-center gap-0.5">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
{t('pipelines.debugDialog.showRaw')}
</span>
)}
</button>
)}
<button
onClick={() => setQuotedMessage(message)}
className={cn(
'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5',
message.role === 'user'
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
)}
title={t('pipelines.debugDialog.reply')}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{t('pipelines.debugDialog.reply')}
</Button>
)}
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{t('pipelines.debugDialog.reply')}
</button>
</div>
<span className="text-[10px]">
{formatTimestamp(getMessageTimestamp(message))}
</span>
</div>
</div>
</div>
))

View File

@@ -8,7 +8,7 @@ import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { getAPILanguageCode } from '@/i18n/I18nProvider';
import './github-markdown.css';
import '@/styles/github-markdown.css';
export default function PluginReadme({
pluginAuthor,

View File

@@ -24,6 +24,9 @@ import {
Power,
Github,
ChevronLeft,
Code,
Copy,
Bug,
} from 'lucide-react';
import {
DropdownMenu,
@@ -38,6 +41,11 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -105,6 +113,11 @@ export default function PluginConfigPage() {
);
const [isEditMode, setIsEditMode] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [debugInfo, setDebugInfo] = useState<{
debug_url: string;
plugin_debug_key: string;
} | null>(null);
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
useEffect(() => {
const fetchPluginSystemStatus = async () => {
@@ -374,6 +387,22 @@ export default function PluginConfigPage() {
[uploadPluginFile, isPluginSystemReady, t],
);
const handleShowDebugInfo = async () => {
try {
const info = await httpClient.getPluginDebugInfo();
setDebugInfo(info);
setDebugPopoverOpen(true);
} catch (error) {
console.error('Failed to fetch debug info:', error);
toast.error(t('plugins.failedToGetDebugInfo'));
}
};
const handleCopyDebugInfo = (text: string) => {
navigator.clipboard.writeText(text);
toast.success(t('plugins.copiedToClipboard'));
};
const renderPluginDisabledState = () => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center pt-[10vh]">
<Power className="w-16 h-16 text-gray-400 mb-4" />
@@ -466,7 +495,92 @@ export default function PluginConfigPage() {
</TabsTrigger>
</TabsList>
<div className="flex flex-row justify-end items-center">
<div className="flex flex-row justify-end items-center gap-2">
{activeTab === 'installed' && (
<Popover
open={debugPopoverOpen}
onOpenChange={setDebugPopoverOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="px-4 py-5 cursor-pointer"
onClick={handleShowDebugInfo}
>
<Code className="w-4 h-4 mr-2" />
{t('plugins.debugInfo')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[380px]" align="end">
<div className="space-y-3">
{/* Header with icon and title */}
<div className="flex items-center gap-2 pb-2 border-b">
<Bug className="w-4 h-4" />
<h4 className="font-semibold text-sm">
{t('plugins.debugInfoTitle')}
</h4>
</div>
{/* Debug URL row */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugUrl')}:
</label>
<Input
value={debugInfo?.debug_url || ''}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
handleCopyDebugInfo(debugInfo?.debug_url || '')
}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</div>
{/* Debug Key row */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
{t('plugins.debugKey')}:
</label>
<Input
value={
debugInfo?.plugin_debug_key ||
t('plugins.noDebugKey')
}
readOnly
className="w-[220px] font-mono text-xs h-8"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() =>
handleCopyDebugInfo(
debugInfo?.plugin_debug_key || '',
)
}
disabled={!debugInfo?.plugin_debug_key}
>
<Copy className="w-3.5 h-3.5" />
</Button>
</div>
{!debugInfo?.plugin_debug_key && (
<p className="text-xs text-muted-foreground ml-[58px]">
{t('plugins.debugKeyDisabled')}
</p>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="px-6 py-4 cursor-pointer">

View File

@@ -63,6 +63,7 @@ export interface KnowledgeBase {
description: string;
embedding_model_uuid: string;
created_at?: string;
updated_at?: string;
top_k: number;
}
@@ -142,6 +143,7 @@ export interface Bot {
use_pipeline_uuid?: string;
created_at?: string;
updated_at?: string;
adapter_runtime_values?: object;
}
export interface ApiRespKnowledgeBases {

View File

@@ -639,6 +639,13 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/system/status/plugin-system');
}
public getPluginDebugInfo(): Promise<{
debug_url: string;
plugin_debug_key: string;
}> {
return this.get('/api/v1/plugins/debug-info');
}
// ============ User API ============
public checkIfInited(): Promise<{ initialized: boolean }> {
return this.get('/api/v1/user/init');

View File

@@ -79,8 +79,10 @@ export class WebSocketClient {
// 构建WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// extract host from process.env.NEXT_PUBLIC_API_BASE_URL
// 如果环境变量未定义,使用当前页面的 host (适配生产环境)
const host =
process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] || '';
process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] ||
window.location.host;
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
this.ws = new WebSocket(url);
@@ -149,12 +151,28 @@ export class WebSocketClient {
break;
case 'response':
// 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session都忽略
if (!data.session_type || data.session_type !== this.sessionType) {
// 忽略不匹配的 session_type 消息
console.debug(
`忽略不匹配的消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`,
);
break;
}
if (data.data) {
this.onMessageCallback?.(data.data);
}
break;
case 'user_message':
// 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session都忽略
if (!data.session_type || data.session_type !== this.sessionType) {
// 忽略不匹配的 session_type 消息
console.debug(
`忽略不匹配的用户消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`,
);
break;
}
// 用户消息广播(包括自己发送的消息)
if (data.data) {
this.onMessageCallback?.(data.data);

View File

@@ -41,6 +41,7 @@ const enUS = {
addRound: 'Add Round',
copy: 'Copy',
copySuccess: 'Copy Successfully',
copyFailed: 'Copy Failed',
test: 'Test',
forgotPassword: 'Forgot Password?',
loading: 'Loading...',
@@ -187,6 +188,10 @@ const enUS = {
log: 'Log',
configuration: 'Configuration',
logs: 'Logs',
webhookUrl: 'Webhook Callback URL',
webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint:
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
},
plugins: {
title: 'Extensions',
@@ -231,6 +236,15 @@ const enUS = {
failedToGetStatus: 'Failed to get plugin system status',
pluginSystemNotReady:
'Plugin system is not ready, cannot perform this operation',
debugInfo: 'Debug Info',
debugInfoTitle: 'Plugin Debug Information',
debugUrl: 'Debug URL',
debugKey: 'Debug Key',
noDebugKey: '(Not Set)',
debugKeyDisabled:
'Debug key is not set, plugin debugging does not require authentication',
failedToGetDebugInfo: 'Failed to get debug information',
copiedToClipboard: 'Copied to clipboard',
deleting: 'Deleting...',
deletePlugin: 'Delete Plugin',
cancel: 'Cancel',
@@ -525,6 +539,8 @@ const enUS = {
imageUploadFailed: 'Image upload failed',
reply: 'Reply',
replyTo: 'Reply to',
showMarkdown: 'Show Markdown',
showRaw: 'Show Raw',
},
},
knowledge: {
@@ -603,6 +619,8 @@ const enUS = {
retriever: 'Retriever',
selectRetriever: 'Select a retriever...',
retrieverConfiguration: 'Retriever Configuration',
retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',
retrieverMarketLink: 'here',
},
register: {
title: 'Initialize LangBot 👋',

View File

@@ -42,6 +42,7 @@ const jaJP = {
addRound: 'ラウンドを追加',
copy: 'コピー',
copySuccess: 'コピーに成功しました',
copyFailed: 'コピーに失敗しました',
test: 'テスト',
forgotPassword: 'パスワードを忘れた?',
loading: '読み込み中...',
@@ -189,6 +190,10 @@ const jaJP = {
log: 'ログ',
configuration: '設定',
logs: 'ログ',
webhookUrl: 'Webhook コールバック URL',
webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint:
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
},
plugins: {
title: '拡張機能',
@@ -233,6 +238,15 @@ const jaJP = {
failedToGetStatus: 'プラグインシステム状態の取得に失敗しました',
pluginSystemNotReady:
'プラグインシステムが準備されていません。この操作を実行できません',
debugInfo: 'デバッグ情報',
debugInfoTitle: 'プラグインデバッグ情報',
debugUrl: 'デバッグURL',
debugKey: 'デバッグキー',
noDebugKey: '(未設定)',
debugKeyDisabled:
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
failedToGetDebugInfo: 'デバッグ情報の取得に失敗しました',
copiedToClipboard: 'クリップボードにコピーしました',
deleting: '削除中...',
deletePlugin: 'プラグインを削除',
cancel: 'キャンセル',
@@ -529,6 +543,8 @@ const jaJP = {
imageUploadFailed: '画像のアップロードに失敗しました',
reply: '返信',
replyTo: '返信先',
showMarkdown: 'Markdownで表示',
showRaw: '原文で表示',
},
},
knowledge: {
@@ -608,6 +624,8 @@ const jaJP = {
retriever: '検索器',
selectRetriever: '検索器を選択...',
retrieverConfiguration: '検索器設定',
retrieverInstallInfo: 'ナレッジ検索器プラグインは',
retrieverMarketLink: 'こちらからインストールできます',
},
register: {
title: 'LangBot を初期化 👋',

View File

@@ -41,6 +41,7 @@ const zhHans = {
addRound: '添加回合',
copy: '复制',
copySuccess: '复制成功',
copyFailed: '复制失败',
test: '测试',
forgotPassword: '忘记密码?',
loading: '加载中...',
@@ -182,6 +183,10 @@ const zhHans = {
log: '日志',
configuration: '配置',
logs: '日志',
webhookUrl: 'Webhook 回调地址',
webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint:
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
},
plugins: {
title: '插件扩展',
@@ -222,6 +227,14 @@ const zhHans = {
loadingStatus: '正在检查插件系统状态...',
failedToGetStatus: '获取插件系统状态失败',
pluginSystemNotReady: '插件系统未就绪,无法执行此操作',
debugInfo: '调试信息',
debugInfoTitle: '插件调试信息',
debugUrl: '调试地址',
debugKey: '调试密钥',
noDebugKey: '(未设置)',
debugKeyDisabled: '未设置调试密钥,插件调试无需认证',
failedToGetDebugInfo: '获取调试信息失败',
copiedToClipboard: '已复制到剪贴板',
deleting: '删除中...',
deletePlugin: '删除插件',
cancel: '取消',
@@ -508,6 +521,8 @@ const zhHans = {
imageUploadFailed: '图片上传失败',
reply: '回复',
replyTo: '回复给',
showMarkdown: '渲染',
showRaw: '原文',
},
},
knowledge: {
@@ -581,6 +596,8 @@ const zhHans = {
retriever: '检索器',
selectRetriever: '选择一个检索器...',
retrieverConfiguration: '检索器配置',
retrieverInstallInfo: '您可以从',
retrieverMarketLink: '此处安装知识检索器插件',
},
register: {
title: '初始化 LangBot 👋',

View File

@@ -41,6 +41,7 @@ const zhHant = {
addRound: '新增回合',
copy: '複製',
copySuccess: '複製成功',
copyFailed: '複製失敗',
test: '測試',
forgotPassword: '忘記密碼?',
loading: '載入中...',
@@ -182,6 +183,10 @@ const zhHant = {
log: '日誌',
configuration: '設定',
logs: '日誌',
webhookUrl: 'Webhook 回調位址',
webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint:
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
},
plugins: {
title: '外掛擴展',
@@ -222,6 +227,14 @@ const zhHant = {
loadingStatus: '正在檢查外掛系統狀態...',
failedToGetStatus: '取得外掛系統狀態失敗',
pluginSystemNotReady: '外掛系統未就緒,無法執行此操作',
debugInfo: '偵錯資訊',
debugInfoTitle: '外掛偵錯資訊',
debugUrl: '偵錯位址',
debugKey: '偵錯金鑰',
noDebugKey: '(未設定)',
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證',
failedToGetDebugInfo: '取得偵錯資訊失敗',
copiedToClipboard: '已複製到剪貼簿',
deleting: '刪除中...',
deletePlugin: '刪除外掛',
cancel: '取消',
@@ -506,6 +519,8 @@ const zhHant = {
imageUploadFailed: '圖片上傳失敗',
reply: '回覆',
replyTo: '回覆給',
showMarkdown: '渲染',
showRaw: '原文',
},
},
knowledge: {
@@ -579,6 +594,8 @@ const zhHant = {
retriever: '檢索器',
selectRetriever: '選擇一個檢索器...',
retrieverConfiguration: '檢索器配置',
retrieverInstallInfo: '您可以從',
retrieverMarketLink: '此處安裝知識檢索器插件',
},
register: {
title: '初始化 LangBot 👋',