From d3740eed9e26093767e2191f43bad3a99ca0378e Mon Sep 17 00:00:00 2001 From: RockChinQ <1010553892@qq.com> Date: Thu, 4 Dec 2025 01:05:51 +0800 Subject: [PATCH] feat: add teams adapter --- TEAMS_ADAPTER_IMPLEMENTATION.md | 162 ++++++++ pyproject.toml | 4 + src/langbot/pkg/platform/sources/teams.py | 398 ++++++++++++++++++++ src/langbot/pkg/platform/sources/teams.yaml | 37 ++ 4 files changed, 601 insertions(+) create mode 100644 TEAMS_ADAPTER_IMPLEMENTATION.md create mode 100644 src/langbot/pkg/platform/sources/teams.py create mode 100644 src/langbot/pkg/platform/sources/teams.yaml diff --git a/TEAMS_ADAPTER_IMPLEMENTATION.md b/TEAMS_ADAPTER_IMPLEMENTATION.md new file mode 100644 index 00000000..79e4ed33 --- /dev/null +++ b/TEAMS_ADAPTER_IMPLEMENTATION.md @@ -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:///bots/` + +### LangBot Configuration + +When creating a Teams bot in LangBot, provide: + +```yaml +adapter: teams +adapter_config: + app_id: "" + 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/` 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) diff --git a/pyproject.toml b/pyproject.toml index 3c861965..abb9fbf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/langbot/pkg/platform/sources/teams.py b/src/langbot/pkg/platform/sources/teams.py new file mode 100644 index 00000000..3b831a66 --- /dev/null +++ b/src/langbot/pkg/platform/sources/teams.py @@ -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://:{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 diff --git a/src/langbot/pkg/platform/sources/teams.yaml b/src/langbot/pkg/platform/sources/teams.yaml new file mode 100644 index 00000000..a0caecf2 --- /dev/null +++ b/src/langbot/pkg/platform/sources/teams.yaml @@ -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