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

1""" 

2Microsoft Teams Channel Adapter 

3 

4Implements Microsoft Teams messaging using Bot Framework. 

5Based on HevolveBot extension patterns for Teams. 

6 

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

19 

20from __future__ import annotations 

21 

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 

35 

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 

59 

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 

73 

74logger = logging.getLogger(__name__) 

75 

76 

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 

88 

89 

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" 

96 

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 

113 

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 

128 

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 

141 

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 

154 

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 } 

164 

165 

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 

178 

179 

180class TeamsAdapter(ChannelAdapter, RoomCapableAdapter): 

181 """ 

182 Microsoft Teams messaging adapter using Bot Framework. 

183 

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

193 

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 ) 

200 

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] = {} 

207 

208 @property 

209 def name(self) -> str: 

210 return "teams" 

211 

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 

217 

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 ) 

224 

225 # Create adapter 

226 self._adapter = BotFrameworkAdapter(settings) 

227 

228 # Register error handler 

229 self._adapter.on_turn_error = self._on_error 

230 

231 self.status = ChannelStatus.CONNECTED 

232 logger.info(f"Teams adapter initialized with app ID: {self.teams_config.app_id}") 

233 return True 

234 

235 except Exception as e: 

236 logger.error(f"Failed to initialize Teams adapter: {e}") 

237 self.status = ChannelStatus.ERROR 

238 return False 

239 

240 async def disconnect(self) -> None: 

241 """Disconnect Teams adapter.""" 

242 self._adapter = None 

243 self._conversation_refs.clear() 

244 self.status = ChannelStatus.DISCONNECTED 

245 

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 

250 

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 

256 

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") 

264 

265 async def turn_callback(turn_context: TurnContext): 

266 await self._handle_turn(turn_context) 

267 

268 await self._adapter.process_activity(activity, auth_header, turn_callback) 

269 

270 async def _handle_turn(self, context: TurnContext) -> None: 

271 """Handle a turn (incoming activity).""" 

272 activity = context.activity 

273 

274 # Store conversation reference for proactive messaging 

275 if self.teams_config.enable_proactive_messaging: 

276 self._store_conversation_ref(context) 

277 

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) 

287 

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 

303 

304 async def _handle_message(self, context: TurnContext) -> None: 

305 """Handle incoming message activity.""" 

306 activity = context.activity 

307 

308 # Check for adaptive card action 

309 if activity.value: 

310 await self._handle_card_action(context, activity.value) 

311 return 

312 

313 # Convert to unified message 

314 message = self._convert_message(context) 

315 

316 # Store context for reply 

317 message.raw['turn_context'] = context 

318 

319 # Dispatch to handlers 

320 await self._dispatch_message(message) 

321 

322 async def _handle_conversation_update(self, context: TurnContext) -> None: 

323 """Handle conversation update (member added/removed).""" 

324 activity = context.activity 

325 

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}") 

332 

333 async def _handle_invoke(self, context: TurnContext) -> None: 

334 """Handle invoke activities (cards, tabs, etc.).""" 

335 activity = context.activity 

336 

337 if activity.name == "adaptiveCard/action": 

338 # Adaptive card action 

339 if activity.value: 

340 await self._handle_card_action(context, activity.value) 

341 

342 async def _handle_reaction(self, context: TurnContext) -> None: 

343 """Handle message reaction activity.""" 

344 activity = context.activity 

345 

346 if activity.reactions_added: 

347 for reaction in activity.reactions_added: 

348 logger.info(f"Reaction added: {reaction.type}") 

349 

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') 

353 

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) 

357 

358 def _convert_message(self, context: TurnContext) -> Message: 

359 """Convert Teams activity to unified Message format.""" 

360 activity = context.activity 

361 

362 # Check if bot is mentioned 

363 is_mentioned = False 

364 text = activity.text or "" 

365 

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() 

375 

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 

385 

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

392 

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 

399 

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 ) 

418 

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") 

430 

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") 

436 

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) 

443 

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

453 

454 # Send using proactive messaging 

455 result = await self._send_proactive(conv_ref, activity) 

456 return result 

457 

458 except Exception as e: 

459 logger.error(f"Failed to send Teams message: {e}") 

460 return SendResult(success=False, error=str(e)) 

461 

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") 

470 

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 ) 

484 

485 result_id = None 

486 

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 

491 

492 await self._adapter.continue_conversation( 

493 reference, 

494 send_callback, 

495 self.teams_config.app_id, 

496 ) 

497 

498 return SendResult(success=True, message_id=result_id) 

499 

500 except Exception as e: 

501 logger.error(f"Proactive message failed: {e}") 

502 return SendResult(success=False, error=str(e)) 

503 

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) 

512 

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 ) 

521 

522 attachment = Attachment( 

523 content_type="application/vnd.microsoft.card.adaptive", 

524 content=card.to_dict(), 

525 ) 

526 

527 activity = Activity(type=ActivityTypes.message) 

528 activity.attachments = [attachment] 

529 return activity 

530 

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") 

541 

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") 

546 

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) 

551 

552 activity.id = message_id 

553 

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 ) 

565 

566 async def update_callback(turn_context: TurnContext): 

567 await turn_context.update_activity(activity) 

568 

569 await self._adapter.continue_conversation( 

570 reference, 

571 update_callback, 

572 self.teams_config.app_id, 

573 ) 

574 

575 return SendResult(success=True, message_id=message_id) 

576 

577 except Exception as e: 

578 logger.error(f"Failed to edit Teams message: {e}") 

579 return SendResult(success=False, error=str(e)) 

580 

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 

585 

586 try: 

587 conv_ref = self._conversation_refs.get(chat_id) 

588 if not conv_ref: 

589 return False 

590 

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 ) 

602 

603 async def delete_callback(turn_context: TurnContext): 

604 await turn_context.delete_activity(message_id) 

605 

606 await self._adapter.continue_conversation( 

607 reference, 

608 delete_callback, 

609 self.teams_config.app_id, 

610 ) 

611 

612 return True 

613 

614 except Exception as e: 

615 logger.error(f"Failed to delete Teams message: {e}") 

616 return False 

617 

618 async def send_typing(self, chat_id: str) -> None: 

619 """Send typing indicator.""" 

620 if not self._adapter: 

621 return 

622 

623 try: 

624 conv_ref = self._conversation_refs.get(chat_id) 

625 if not conv_ref: 

626 return 

627 

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 ) 

637 

638 async def typing_callback(turn_context: TurnContext): 

639 typing_activity = Activity(type=ActivityTypes.typing) 

640 await turn_context.send_activity(typing_activity) 

641 

642 await self._adapter.continue_conversation( 

643 reference, 

644 typing_callback, 

645 self.teams_config.app_id, 

646 ) 

647 

648 except Exception: 

649 pass 

650 

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 

663 

664 # Teams-specific methods 

665 

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 

673 

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") 

682 

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") 

687 

688 attachment = Attachment( 

689 content_type="application/vnd.microsoft.card.adaptive", 

690 content=card.to_dict(), 

691 ) 

692 

693 activity = Activity(type=ActivityTypes.message) 

694 activity.attachments = [attachment] 

695 

696 return await self._send_proactive(conv_ref, activity) 

697 

698 except Exception as e: 

699 logger.error(f"Failed to send adaptive card: {e}") 

700 return SendResult(success=False, error=str(e)) 

701 

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 [] 

708 

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 

713 

714 # Meeting info requires active turn context 

715 logger.warning("get_meeting_info requires active turn context") 

716 return None 

717 

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") 

728 

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") 

733 

734 # Create mention entity 

735 mention = Mention( 

736 mentioned=ChannelAccount(id=user_id, name=user_name), 

737 text=f"<at>{user_name}</at>", 

738 ) 

739 

740 # Create activity with mention 

741 activity = MessageFactory.text(f"<at>{user_name}</at> {text}") 

742 activity.entities = [mention] 

743 

744 return await self._send_proactive(conv_ref, activity) 

745 

746 except Exception as e: 

747 logger.error(f"Failed to send mention: {e}") 

748 return SendResult(success=False, error=str(e)) 

749 

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. 

761 

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 

769 

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 

775 

776 async def list_room_members( 

777 self, room_id: str, 

778 ) -> List[Dict[str, Any]]: 

779 return [] 

780 

781 

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. 

789 

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 

794 

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") 

800 

801 if not app_id: 

802 raise ValueError("Teams app ID required") 

803 if not app_password: 

804 raise ValueError("Teams app password required") 

805 

806 config = TeamsConfig( 

807 app_id=app_id, 

808 app_password=app_password, 

809 **kwargs 

810 ) 

811 return TeamsAdapter(config)