Coverage for integrations / channels / extensions / teams_adapter.py: 30.8%
289 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Microsoft Teams Channel Adapter
4Implements Microsoft Teams messaging using Bot Framework.
5Based on HevolveBot extension patterns for Teams.
7Features:
8- Bot Framework SDK integration
9- Adaptive Cards support
10- Tabs integration
11- Meeting integration
12- Channel/Group chat support
13- Direct messages (1:1)
14- File sharing
15- @mentions
16- Message reactions
17- Conversation reference for proactive messaging
18"""
20from __future__ import annotations
22import asyncio
23import logging
24import os
25import json
26from typing import Optional, List, Dict, Any, Callable
27from datetime import datetime
28from dataclasses import dataclass, field
29try:
30 import aiohttp
31 HAS_AIOHTTP = True
32except ImportError:
33 HAS_AIOHTTP = False
34from urllib.parse import urljoin
36try:
37 from botbuilder.core import (
38 BotFrameworkAdapter,
39 BotFrameworkAdapterSettings,
40 TurnContext,
41 MessageFactory,
42 CardFactory,
43 )
44 from botbuilder.schema import (
45 Activity,
46 ActivityTypes,
47 ChannelAccount,
48 ConversationReference,
49 Attachment,
50 HeroCard,
51 CardAction,
52 ActionTypes,
53 Mention,
54 )
55 from botbuilder.core.teams import TeamsInfo, TeamsActivityHandler
56 HAS_TEAMS = True
57except ImportError:
58 HAS_TEAMS = False
60from ..base import (
61 ChannelAdapter,
62 ChannelConfig,
63 ChannelStatus,
64 Message,
65 MessageType,
66 MediaAttachment,
67 SendResult,
68 ChannelConnectionError,
69 ChannelSendError,
70 ChannelRateLimitError,
71)
72from ..room_capable import RoomCapableAdapter
74logger = logging.getLogger(__name__)
77@dataclass
78class TeamsConfig(ChannelConfig):
79 """Microsoft Teams-specific configuration."""
80 app_id: str = ""
81 app_password: str = ""
82 tenant_id: Optional[str] = None # For single-tenant apps
83 service_url: str = "https://smba.trafficmanager.net/teams/"
84 enable_proactive_messaging: bool = True
85 enable_adaptive_cards: bool = True
86 enable_tabs: bool = False
87 enable_meetings: bool = False
90@dataclass
91class AdaptiveCard:
92 """Adaptive Card builder helper."""
93 body: List[Dict[str, Any]] = field(default_factory=list)
94 actions: List[Dict[str, Any]] = field(default_factory=list)
95 version: str = "1.4"
97 def add_text_block(
98 self,
99 text: str,
100 size: str = "medium",
101 weight: str = "default",
102 wrap: bool = True,
103 ) -> 'AdaptiveCard':
104 """Add a TextBlock to the card."""
105 self.body.append({
106 "type": "TextBlock",
107 "text": text,
108 "size": size,
109 "weight": weight,
110 "wrap": wrap,
111 })
112 return self
114 def add_image(
115 self,
116 url: str,
117 alt_text: str = "",
118 size: str = "auto",
119 ) -> 'AdaptiveCard':
120 """Add an Image to the card."""
121 self.body.append({
122 "type": "Image",
123 "url": url,
124 "altText": alt_text,
125 "size": size,
126 })
127 return self
129 def add_action_submit(
130 self,
131 title: str,
132 data: Dict[str, Any],
133 ) -> 'AdaptiveCard':
134 """Add a submit action button."""
135 self.actions.append({
136 "type": "Action.Submit",
137 "title": title,
138 "data": data,
139 })
140 return self
142 def add_action_open_url(
143 self,
144 title: str,
145 url: str,
146 ) -> 'AdaptiveCard':
147 """Add an open URL action button."""
148 self.actions.append({
149 "type": "Action.OpenUrl",
150 "title": title,
151 "url": url,
152 })
153 return self
155 def to_dict(self) -> Dict[str, Any]:
156 """Convert to Adaptive Card JSON."""
157 return {
158 "type": "AdaptiveCard",
159 "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
160 "version": self.version,
161 "body": self.body,
162 "actions": self.actions,
163 }
166@dataclass
167class ConversationRef:
168 """Stored conversation reference for proactive messaging."""
169 conversation_id: str
170 service_url: str
171 channel_id: str
172 bot_id: str
173 bot_name: str
174 user_id: Optional[str] = None
175 user_name: Optional[str] = None
176 tenant_id: Optional[str] = None
177 is_group: bool = False
180class TeamsAdapter(ChannelAdapter, RoomCapableAdapter):
181 """
182 Microsoft Teams messaging adapter using Bot Framework.
184 Usage:
185 config = TeamsConfig(
186 app_id="your-app-id",
187 app_password="your-app-password",
188 )
189 adapter = TeamsAdapter(config)
190 adapter.on_message(my_handler)
191 await adapter.start()
192 """
194 def __init__(self, config: TeamsConfig):
195 if not HAS_TEAMS:
196 raise ImportError(
197 "botbuilder not installed. "
198 "Install with: pip install botbuilder-core botbuilder-schema"
199 )
201 super().__init__(config)
202 self.teams_config: TeamsConfig = config
203 self._adapter: Optional[BotFrameworkAdapter] = None
204 self._conversation_refs: Dict[str, ConversationRef] = {}
205 self._activity_handlers: List[Callable] = []
206 self._card_action_handlers: Dict[str, Callable] = {}
208 @property
209 def name(self) -> str:
210 return "teams"
212 async def connect(self) -> bool:
213 """Initialize Bot Framework adapter."""
214 if not self.teams_config.app_id or not self.teams_config.app_password:
215 logger.error("Teams app ID and password required")
216 return False
218 try:
219 # Create adapter settings
220 settings = BotFrameworkAdapterSettings(
221 app_id=self.teams_config.app_id,
222 app_password=self.teams_config.app_password,
223 )
225 # Create adapter
226 self._adapter = BotFrameworkAdapter(settings)
228 # Register error handler
229 self._adapter.on_turn_error = self._on_error
231 self.status = ChannelStatus.CONNECTED
232 logger.info(f"Teams adapter initialized with app ID: {self.teams_config.app_id}")
233 return True
235 except Exception as e:
236 logger.error(f"Failed to initialize Teams adapter: {e}")
237 self.status = ChannelStatus.ERROR
238 return False
240 async def disconnect(self) -> None:
241 """Disconnect Teams adapter."""
242 self._adapter = None
243 self._conversation_refs.clear()
244 self.status = ChannelStatus.DISCONNECTED
246 async def _on_error(self, context: TurnContext, error: Exception) -> None:
247 """Handle errors in turn processing."""
248 logger.error(f"Teams adapter error: {error}")
249 self.status = ChannelStatus.ERROR
251 # Send error message to user
252 try:
253 await context.send_activity("Sorry, an error occurred processing your request.")
254 except Exception:
255 pass
257 async def process_activity(self, activity: Activity, auth_header: str = "") -> None:
258 """
259 Process an incoming activity from Teams.
260 This should be called from your webhook endpoint.
261 """
262 if not self._adapter:
263 raise ChannelConnectionError("Adapter not initialized")
265 async def turn_callback(turn_context: TurnContext):
266 await self._handle_turn(turn_context)
268 await self._adapter.process_activity(activity, auth_header, turn_callback)
270 async def _handle_turn(self, context: TurnContext) -> None:
271 """Handle a turn (incoming activity)."""
272 activity = context.activity
274 # Store conversation reference for proactive messaging
275 if self.teams_config.enable_proactive_messaging:
276 self._store_conversation_ref(context)
278 # Handle different activity types
279 if activity.type == ActivityTypes.message:
280 await self._handle_message(context)
281 elif activity.type == ActivityTypes.conversation_update:
282 await self._handle_conversation_update(context)
283 elif activity.type == ActivityTypes.invoke:
284 await self._handle_invoke(context)
285 elif activity.type == ActivityTypes.message_reaction:
286 await self._handle_reaction(context)
288 def _store_conversation_ref(self, context: TurnContext) -> None:
289 """Store conversation reference for proactive messaging."""
290 activity = context.activity
291 ref = ConversationRef(
292 conversation_id=activity.conversation.id,
293 service_url=activity.service_url,
294 channel_id=activity.channel_id,
295 bot_id=activity.recipient.id,
296 bot_name=activity.recipient.name,
297 user_id=activity.from_property.id if activity.from_property else None,
298 user_name=activity.from_property.name if activity.from_property else None,
299 tenant_id=activity.conversation.tenant_id if hasattr(activity.conversation, 'tenant_id') else None,
300 is_group=activity.conversation.is_group if hasattr(activity.conversation, 'is_group') else False,
301 )
302 self._conversation_refs[activity.conversation.id] = ref
304 async def _handle_message(self, context: TurnContext) -> None:
305 """Handle incoming message activity."""
306 activity = context.activity
308 # Check for adaptive card action
309 if activity.value:
310 await self._handle_card_action(context, activity.value)
311 return
313 # Convert to unified message
314 message = self._convert_message(context)
316 # Store context for reply
317 message.raw['turn_context'] = context
319 # Dispatch to handlers
320 await self._dispatch_message(message)
322 async def _handle_conversation_update(self, context: TurnContext) -> None:
323 """Handle conversation update (member added/removed)."""
324 activity = context.activity
326 # Handle member added
327 if activity.members_added:
328 for member in activity.members_added:
329 if member.id != activity.recipient.id:
330 # New member joined
331 logger.info(f"Member added: {member.name}")
333 async def _handle_invoke(self, context: TurnContext) -> None:
334 """Handle invoke activities (cards, tabs, etc.)."""
335 activity = context.activity
337 if activity.name == "adaptiveCard/action":
338 # Adaptive card action
339 if activity.value:
340 await self._handle_card_action(context, activity.value)
342 async def _handle_reaction(self, context: TurnContext) -> None:
343 """Handle message reaction activity."""
344 activity = context.activity
346 if activity.reactions_added:
347 for reaction in activity.reactions_added:
348 logger.info(f"Reaction added: {reaction.type}")
350 async def _handle_card_action(self, context: TurnContext, value: Dict[str, Any]) -> None:
351 """Handle adaptive card action submission."""
352 action_id = value.get('action_id') or value.get('actionId')
354 if action_id and action_id in self._card_action_handlers:
355 handler = self._card_action_handlers[action_id]
356 await handler(context, value)
358 def _convert_message(self, context: TurnContext) -> Message:
359 """Convert Teams activity to unified Message format."""
360 activity = context.activity
362 # Check if bot is mentioned
363 is_mentioned = False
364 text = activity.text or ""
366 if activity.entities:
367 for entity in activity.entities:
368 if entity.type == "mention":
369 mention_data = entity.additional_properties
370 if mention_data.get('mentioned', {}).get('id') == activity.recipient.id:
371 is_mentioned = True
372 # Remove mention from text
373 mention_text = mention_data.get('text', '')
374 text = text.replace(mention_text, '').strip()
376 # Process attachments
377 media = []
378 if activity.attachments:
379 for attachment in activity.attachments:
380 media_type = MessageType.DOCUMENT
381 if attachment.content_type and attachment.content_type.startswith('image/'):
382 media_type = MessageType.IMAGE
383 elif attachment.content_type and attachment.content_type.startswith('video/'):
384 media_type = MessageType.VIDEO
386 media.append(MediaAttachment(
387 type=media_type,
388 url=attachment.content_url,
389 file_name=attachment.name,
390 mime_type=attachment.content_type,
391 ))
393 # Determine if group
394 is_group = False
395 if hasattr(activity.conversation, 'is_group'):
396 is_group = activity.conversation.is_group
397 elif activity.conversation.conversation_type == "channel":
398 is_group = True
400 return Message(
401 id=activity.id,
402 channel=self.name,
403 sender_id=activity.from_property.id if activity.from_property else "",
404 sender_name=activity.from_property.name if activity.from_property else "",
405 chat_id=activity.conversation.id,
406 text=text,
407 media=media,
408 reply_to_id=activity.reply_to_id,
409 timestamp=activity.timestamp or datetime.now(),
410 is_group=is_group,
411 is_bot_mentioned=is_mentioned,
412 raw={
413 'service_url': activity.service_url,
414 'channel_id': activity.channel_id,
415 'tenant_id': activity.conversation.tenant_id if hasattr(activity.conversation, 'tenant_id') else None,
416 },
417 )
419 async def send_message(
420 self,
421 chat_id: str,
422 text: str,
423 reply_to: Optional[str] = None,
424 media: Optional[List[MediaAttachment]] = None,
425 buttons: Optional[List[Dict]] = None,
426 ) -> SendResult:
427 """Send a message to a Teams conversation."""
428 if not self._adapter:
429 return SendResult(success=False, error="Not connected")
431 try:
432 # Get conversation reference
433 conv_ref = self._conversation_refs.get(chat_id)
434 if not conv_ref:
435 return SendResult(success=False, error="Conversation not found")
437 # Build activity
438 if buttons and self.teams_config.enable_adaptive_cards:
439 # Use adaptive card for buttons
440 activity = self._build_adaptive_card_activity(text, buttons)
441 else:
442 activity = MessageFactory.text(text)
444 # Add attachments for media
445 if media:
446 activity.attachments = activity.attachments or []
447 for m in media:
448 activity.attachments.append(Attachment(
449 content_type=m.mime_type or "application/octet-stream",
450 content_url=m.url,
451 name=m.file_name,
452 ))
454 # Send using proactive messaging
455 result = await self._send_proactive(conv_ref, activity)
456 return result
458 except Exception as e:
459 logger.error(f"Failed to send Teams message: {e}")
460 return SendResult(success=False, error=str(e))
462 async def _send_proactive(
463 self,
464 conv_ref: ConversationRef,
465 activity: Activity,
466 ) -> SendResult:
467 """Send a proactive message using stored conversation reference."""
468 if not self._adapter:
469 return SendResult(success=False, error="Not connected")
471 try:
472 # Build conversation reference
473 reference = ConversationReference(
474 activity_id=None,
475 bot=ChannelAccount(id=conv_ref.bot_id, name=conv_ref.bot_name),
476 channel_id=conv_ref.channel_id,
477 conversation=type('Conversation', (), {
478 'id': conv_ref.conversation_id,
479 'is_group': conv_ref.is_group,
480 'tenant_id': conv_ref.tenant_id,
481 })(),
482 service_url=conv_ref.service_url,
483 )
485 result_id = None
487 async def send_callback(turn_context: TurnContext):
488 nonlocal result_id
489 response = await turn_context.send_activity(activity)
490 result_id = response.id if response else None
492 await self._adapter.continue_conversation(
493 reference,
494 send_callback,
495 self.teams_config.app_id,
496 )
498 return SendResult(success=True, message_id=result_id)
500 except Exception as e:
501 logger.error(f"Proactive message failed: {e}")
502 return SendResult(success=False, error=str(e))
504 def _build_adaptive_card_activity(
505 self,
506 text: str,
507 buttons: List[Dict],
508 ) -> Activity:
509 """Build activity with adaptive card."""
510 card = AdaptiveCard()
511 card.add_text_block(text)
513 for btn in buttons:
514 if btn.get('url'):
515 card.add_action_open_url(btn['text'], btn['url'])
516 else:
517 card.add_action_submit(
518 btn['text'],
519 {'action_id': btn.get('callback_data', btn['text'])}
520 )
522 attachment = Attachment(
523 content_type="application/vnd.microsoft.card.adaptive",
524 content=card.to_dict(),
525 )
527 activity = Activity(type=ActivityTypes.message)
528 activity.attachments = [attachment]
529 return activity
531 async def edit_message(
532 self,
533 chat_id: str,
534 message_id: str,
535 text: str,
536 buttons: Optional[List[Dict]] = None,
537 ) -> SendResult:
538 """Edit an existing Teams message."""
539 if not self._adapter:
540 return SendResult(success=False, error="Not connected")
542 try:
543 conv_ref = self._conversation_refs.get(chat_id)
544 if not conv_ref:
545 return SendResult(success=False, error="Conversation not found")
547 if buttons and self.teams_config.enable_adaptive_cards:
548 activity = self._build_adaptive_card_activity(text, buttons)
549 else:
550 activity = MessageFactory.text(text)
552 activity.id = message_id
554 reference = ConversationReference(
555 activity_id=message_id,
556 bot=ChannelAccount(id=conv_ref.bot_id, name=conv_ref.bot_name),
557 channel_id=conv_ref.channel_id,
558 conversation=type('Conversation', (), {
559 'id': conv_ref.conversation_id,
560 'is_group': conv_ref.is_group,
561 'tenant_id': conv_ref.tenant_id,
562 })(),
563 service_url=conv_ref.service_url,
564 )
566 async def update_callback(turn_context: TurnContext):
567 await turn_context.update_activity(activity)
569 await self._adapter.continue_conversation(
570 reference,
571 update_callback,
572 self.teams_config.app_id,
573 )
575 return SendResult(success=True, message_id=message_id)
577 except Exception as e:
578 logger.error(f"Failed to edit Teams message: {e}")
579 return SendResult(success=False, error=str(e))
581 async def delete_message(self, chat_id: str, message_id: str) -> bool:
582 """Delete a Teams message."""
583 if not self._adapter:
584 return False
586 try:
587 conv_ref = self._conversation_refs.get(chat_id)
588 if not conv_ref:
589 return False
591 reference = ConversationReference(
592 activity_id=message_id,
593 bot=ChannelAccount(id=conv_ref.bot_id, name=conv_ref.bot_name),
594 channel_id=conv_ref.channel_id,
595 conversation=type('Conversation', (), {
596 'id': conv_ref.conversation_id,
597 'is_group': conv_ref.is_group,
598 'tenant_id': conv_ref.tenant_id,
599 })(),
600 service_url=conv_ref.service_url,
601 )
603 async def delete_callback(turn_context: TurnContext):
604 await turn_context.delete_activity(message_id)
606 await self._adapter.continue_conversation(
607 reference,
608 delete_callback,
609 self.teams_config.app_id,
610 )
612 return True
614 except Exception as e:
615 logger.error(f"Failed to delete Teams message: {e}")
616 return False
618 async def send_typing(self, chat_id: str) -> None:
619 """Send typing indicator."""
620 if not self._adapter:
621 return
623 try:
624 conv_ref = self._conversation_refs.get(chat_id)
625 if not conv_ref:
626 return
628 reference = ConversationReference(
629 bot=ChannelAccount(id=conv_ref.bot_id, name=conv_ref.bot_name),
630 channel_id=conv_ref.channel_id,
631 conversation=type('Conversation', (), {
632 'id': conv_ref.conversation_id,
633 'is_group': conv_ref.is_group,
634 })(),
635 service_url=conv_ref.service_url,
636 )
638 async def typing_callback(turn_context: TurnContext):
639 typing_activity = Activity(type=ActivityTypes.typing)
640 await turn_context.send_activity(typing_activity)
642 await self._adapter.continue_conversation(
643 reference,
644 typing_callback,
645 self.teams_config.app_id,
646 )
648 except Exception:
649 pass
651 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
652 """Get information about a Teams conversation."""
653 conv_ref = self._conversation_refs.get(chat_id)
654 if conv_ref:
655 return {
656 'conversation_id': conv_ref.conversation_id,
657 'channel_id': conv_ref.channel_id,
658 'is_group': conv_ref.is_group,
659 'tenant_id': conv_ref.tenant_id,
660 'service_url': conv_ref.service_url,
661 }
662 return None
664 # Teams-specific methods
666 def register_card_action(
667 self,
668 action_id: str,
669 handler: Callable[[TurnContext, Dict[str, Any]], Any],
670 ) -> None:
671 """Register a handler for adaptive card actions."""
672 self._card_action_handlers[action_id] = handler
674 async def send_adaptive_card(
675 self,
676 chat_id: str,
677 card: AdaptiveCard,
678 ) -> SendResult:
679 """Send an adaptive card."""
680 if not self._adapter:
681 return SendResult(success=False, error="Not connected")
683 try:
684 conv_ref = self._conversation_refs.get(chat_id)
685 if not conv_ref:
686 return SendResult(success=False, error="Conversation not found")
688 attachment = Attachment(
689 content_type="application/vnd.microsoft.card.adaptive",
690 content=card.to_dict(),
691 )
693 activity = Activity(type=ActivityTypes.message)
694 activity.attachments = [attachment]
696 return await self._send_proactive(conv_ref, activity)
698 except Exception as e:
699 logger.error(f"Failed to send adaptive card: {e}")
700 return SendResult(success=False, error=str(e))
702 async def get_team_members(self, chat_id: str) -> List[Dict[str, Any]]:
703 """Get members of a Teams team/channel."""
704 # This requires TeamsInfo.get_team_members which needs turn context
705 # For now, return empty list
706 logger.warning("get_team_members requires active turn context")
707 return []
709 async def get_meeting_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
710 """Get meeting information if in a meeting context."""
711 if not self.teams_config.enable_meetings:
712 return None
714 # Meeting info requires active turn context
715 logger.warning("get_meeting_info requires active turn context")
716 return None
718 async def mention_user(
719 self,
720 chat_id: str,
721 user_id: str,
722 user_name: str,
723 text: str,
724 ) -> SendResult:
725 """Send a message with a user mention."""
726 if not self._adapter:
727 return SendResult(success=False, error="Not connected")
729 try:
730 conv_ref = self._conversation_refs.get(chat_id)
731 if not conv_ref:
732 return SendResult(success=False, error="Conversation not found")
734 # Create mention entity
735 mention = Mention(
736 mentioned=ChannelAccount(id=user_id, name=user_name),
737 text=f"<at>{user_name}</at>",
738 )
740 # Create activity with mention
741 activity = MessageFactory.text(f"<at>{user_name}</at> {text}")
742 activity.entities = [mention]
744 return await self._send_proactive(conv_ref, activity)
746 except Exception as e:
747 logger.error(f"Failed to send mention: {e}")
748 return SendResult(success=False, error=str(e))
750 # ─── UNIF-G2: RoomCapableAdapter — pending OAuth wiring ──────────
751 #
752 # Teams join semantics require Bot Framework + Graph API
753 # ``conversations`` and ``meetings`` flows that aren't fully wired
754 # in this adapter. We mark the adapter ``RoomCapableAdapter`` so
755 # ``isinstance(adapter, RoomCapableAdapter)`` is honest, but the
756 # methods return False / [] (NOT ``NotImplementedError`` —
757 # ``Join_External_Room`` distinguishes refusal from unsupported via
758 # the ``is_room_capable`` flag and the boolean return). Real
759 # join_room implementation lands in a follow-up alongside Bot
760 # Framework conversation auth + Graph meeting attendee APIs.
762 async def join_room(self, room_id: str,
763 role: str = 'participant') -> bool:
764 logger.info(
765 "Teams.join_room: platform support pending — bot_framework "
766 "+ graph meeting attendee APIs not yet wired (room_id=%s, "
767 "role=%s)", room_id, role)
768 return False
770 async def leave_room(self, room_id: str) -> bool:
771 logger.info(
772 "Teams.leave_room: platform support pending (room_id=%s)",
773 room_id)
774 return False
776 async def list_room_members(
777 self, room_id: str,
778 ) -> List[Dict[str, Any]]:
779 return []
782def create_teams_adapter(
783 app_id: str = None,
784 app_password: str = None,
785 **kwargs
786) -> TeamsAdapter:
787 """
788 Factory function to create Teams adapter.
790 Args:
791 app_id: Bot app ID (or set TEAMS_APP_ID env var)
792 app_password: Bot app password (or set TEAMS_APP_PASSWORD env var)
793 **kwargs: Additional config options
795 Returns:
796 Configured TeamsAdapter
797 """
798 app_id = app_id or os.getenv("TEAMS_APP_ID")
799 app_password = app_password or os.getenv("TEAMS_APP_PASSWORD")
801 if not app_id:
802 raise ValueError("Teams app ID required")
803 if not app_password:
804 raise ValueError("Teams app password required")
806 config = TeamsConfig(
807 app_id=app_id,
808 app_password=app_password,
809 **kwargs
810 )
811 return TeamsAdapter(config)