from __future__ import annotations import logging import time from typing import Any import httpx from .types import BotFrameworkActivity, StoredConversationReference logger = logging.getLogger(__name__) BOT_SERVICE_URL = "https://smba.trafficmanager.net" TOKEN_URL = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token" TOKEN_SCOPE = "https://api.botframework.com/.default" class BotFrameworkAdapter: def __init__(self, app_id: str, app_password: str, tenant_id: str = ""): self.app_id = app_id self.app_password = app_password self.tenant_id = tenant_id self._token: str | None = None self._token_expires_at: float = 0.0 async def _get_token(self) -> str: if self._token and time.monotonic() < self._token_expires_at - 60: return self._token async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post( TOKEN_URL, data={ "grant_type": "client_credentials", "client_id": self.app_id, "client_secret": self.app_password, "scope": TOKEN_SCOPE, }, ) resp.raise_for_status() data = resp.json() self._token = data["access_token"] self._token_expires_at = time.monotonic() + data.get("expires_in", 3600) return self._token async def _post_activity(self, service_url: str, conversation_id: str, activity: dict) -> dict: token = await self._get_token() url = f"{service_url}/v3/conversations/{conversation_id}/activities" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(url, json=activity, headers=headers) resp.raise_for_status() return resp.json() async def send_activity(self, ref: StoredConversationReference, activity: dict) -> dict: return await self._post_activity(ref.service_url, ref.conversation_id, activity) async def send_to_conversation( self, service_url: str, conversation_id: str, activity: dict, ) -> dict: return await self._post_activity(service_url, conversation_id, activity) async def update_activity( self, service_url: str, conversation_id: str, activity_id: str, activity: dict, ) -> dict: token = await self._get_token() url = f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.put(url, json=activity, headers=headers) resp.raise_for_status() return resp.json() async def delete_activity( self, service_url: str, conversation_id: str, activity_id: str, ) -> None: token = await self._get_token() url = f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.delete(url, headers=headers) resp.raise_for_status() async def probe(self) -> bool: try: await self._get_token() return True except Exception as e: logger.warning("Bot token probe failed: %s", e) return False async def get_conversation_members( self, service_url: str, conversation_id: str, ) -> list[dict]: token = await self._get_token() url = f"{service_url}/v3/conversations/{conversation_id}/members" headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.get(url, headers=headers) resp.raise_for_status() return resp.json() async def get_conversation_paged_members( self, service_url: str, conversation_id: str, *, page_size: int = 100, continuation_token: str | None = None, ) -> dict: token = await self._get_token() url = f"{service_url}/v3/conversations/{conversation_id}/pagedmembers?pageSize={page_size}" if continuation_token: url += f"&continuationToken={continuation_token}" headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.get(url, headers=headers) resp.raise_for_status() return resp.json() async def get_channel_messages( self, service_url: str, conversation_id: str, *, top: int = 50, ) -> list[dict]: token = await self._get_token() url = f"{service_url}/v3/conversations/{conversation_id}/activities?top={top}" headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.get(url, headers=headers) resp.raise_for_status() data = resp.json() return data.get("activities", []) async def get_conversation_replies( self, service_url: str, conversation_id: str, activity_id: str, *, top: int = 50, ) -> list[dict]: token = await self._get_token() url = f"{service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/replies?top={top}" headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.get(url, headers=headers) resp.raise_for_status() data = resp.json() return data.get("activities", []) def build_message_activity( text: str, *, reply_to_id: str | None = None, tenant_id: str | None = None, ai_generated: bool = True, importance: str | None = None, ) -> dict: activity: dict[str, Any] = { "type": "message", "text": text, "textFormat": "markdown", } if reply_to_id: activity["replyToId"] = reply_to_id if tenant_id: activity["channelData"] = {"tenant": {"id": tenant_id}} if importance: activity["importance"] = importance if ai_generated: activity.setdefault("entities", []) activity["entities"].append( { "type": "https://schema.org/Message", "@type": "Message", "@context": "https://schema.org", "additionalType": ["AIGeneratedContent"], } ) return activity def build_typing_activity() -> dict: return {"type": "typing"} def was_bot_mentioned(activity: BotFrameworkActivity, bot_app_id: str) -> bool: bot_id = f"28:{bot_app_id}" for entity in activity.entities: if isinstance(entity, dict) and entity.get("type") == "mention": mentioned = entity.get("mentioned", {}) if isinstance(mentioned, dict): mentioned_id = mentioned.get("id", "") if mentioned_id in (bot_id, bot_app_id): return True return False def extract_media_from_activity(activity: BotFrameworkActivity) -> list[dict]: result = [] for att in activity.attachments: content_type = att.get("contentType", "") content_url = att.get("contentUrl", "") name = att.get("name", "") if content_url: result.append( { "url": content_url, "content_type": content_type, "name": name, } ) return result