Compare commits

..

3 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
6 changed files with 606 additions and 3 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

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.6.0"
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",
@@ -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.6.0'
__version__ = '4.6.1'

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

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