""" Microsoft Teams platform adapter for Hermes Agent. Uses the microsoft-teams-apps SDK for authentication and activity processing. Runs an aiohttp webhook server to receive messages from Teams. Proactive messaging (send, typing) uses the SDK's App.send() method. Requires: pip install microsoft-teams-apps aiohttp TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID env vars Configuration in config.yaml: platforms: teams: enabled: true extra: client_id: "your-client-id" # or TEAMS_CLIENT_ID env var client_secret: "your-secret" # or TEAMS_CLIENT_SECRET env var tenant_id: "your-tenant-id" # or TEAMS_TENANT_ID env var port: 3978 # or TEAMS_PORT env var """ from __future__ import annotations import asyncio import html import json import logging import os from typing import Any, Dict, Optional from urllib.parse import quote import httpx try: from aiohttp import web AIOHTTP_AVAILABLE = True except ImportError: AIOHTTP_AVAILABLE = False web = None # type: ignore[assignment] try: from microsoft_teams.apps import App, ActivityContext from microsoft_teams.common.http.client import ClientOptions from microsoft_teams.api import MessageActivity, ConversationReference from microsoft_teams.api.activities.typing import TypingActivityInput from microsoft_teams.api.activities.invoke.adaptive_card import AdaptiveCardInvokeActivity from microsoft_teams.api.models.adaptive_card import ( AdaptiveCardActionCardResponse, AdaptiveCardActionMessageResponse, ) from microsoft_teams.api.models.invoke_response import InvokeResponse, AdaptiveCardInvokeResponse from microsoft_teams.apps.http.adapter import ( HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler, ) from microsoft_teams.cards import AdaptiveCard, ExecuteAction, TextBlock TEAMS_SDK_AVAILABLE = True except ImportError: TEAMS_SDK_AVAILABLE = False ClientOptions = None # type: ignore[assignment,misc] App = None # type: ignore[assignment,misc] ActivityContext = None # type: ignore[assignment,misc] MessageActivity = None # type: ignore[assignment,misc] ConversationReference = None # type: ignore[assignment,misc] TypingActivityInput = None # type: ignore[assignment,misc] AdaptiveCardInvokeActivity = None # type: ignore[assignment,misc] AdaptiveCardActionCardResponse = None # type: ignore[assignment,misc] AdaptiveCardActionMessageResponse = None # type: ignore[assignment,misc] AdaptiveCardInvokeResponse = None # type: ignore[assignment,misc,union-attr] InvokeResponse = None # type: ignore[assignment,misc] HttpMethod = str # type: ignore[assignment,misc] HttpRequest = None # type: ignore[assignment,misc] HttpResponse = None # type: ignore[assignment,misc] HttpRouteHandler = None # type: ignore[assignment,misc] AdaptiveCard = None # type: ignore[assignment,misc] ExecuteAction = None # type: ignore[assignment,misc] TextBlock = None # type: ignore[assignment,misc] from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, cache_image_from_url, ) logger = logging.getLogger(__name__) _DEFAULT_PORT = 3978 _WEBHOOK_PATH = "/api/messages" def _parse_bool(value: Any, *, default: bool = False) -> bool: if isinstance(value, bool): return value if isinstance(value, str): normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False return default class _StaticAccessTokenProvider: """Minimal token-provider shim so outbound Graph delivery can reuse the shared client.""" def __init__(self, access_token: str): self._access_token = str(access_token or "").strip() async def get_access_token(self, *, force_refresh: bool = False) -> str: del force_refresh if not self._access_token: raise ValueError("TEAMS_GRAPH_ACCESS_TOKEN is required for graph delivery mode.") return self._access_token def clear_cache(self) -> None: return None class TeamsSummaryWriter: """Pipeline-facing Teams outbound delivery surface. This stays inside the existing Teams platform plugin so the meeting-pipeline PR can reuse one Teams integration surface instead of introducing a second adapter elsewhere in the gateway core. """ def __init__( self, platform_config: PlatformConfig | None = None, *, graph_client: Any | None = None, transport: httpx.AsyncBaseTransport | None = None, ) -> None: self._platform_config = platform_config self._graph_client = graph_client self._transport = transport async def write_summary( self, payload: Any, config: dict[str, Any] | None, existing_record: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: merged = self._resolve_delivery_config(config) if existing_record and not _parse_bool(merged.get("force_resend"), default=False): return dict(existing_record) mode = str(merged.get("delivery_mode") or merged.get("mode") or "").strip().lower() if not mode: if merged.get("incoming_webhook_url"): mode = "incoming_webhook" elif merged.get("chat_id") or ( merged.get("team_id") and merged.get("channel_id") ): mode = "graph" if mode == "incoming_webhook": return await self._write_summary_via_incoming_webhook(payload, merged) if mode == "graph": return await self._write_summary_via_graph(payload, merged) raise ValueError( "Teams delivery_mode must be 'incoming_webhook' or 'graph'." ) def _resolve_delivery_config(self, config: dict[str, Any] | None) -> dict[str, Any]: merged: dict[str, Any] = {} platform_cfg = self._platform_config if platform_cfg is not None: merged.update(dict(platform_cfg.extra or {})) if platform_cfg.token and "access_token" not in merged: merged["access_token"] = platform_cfg.token if platform_cfg.home_channel: merged.setdefault("channel_id", platform_cfg.home_channel.chat_id) merged.update(dict(config or {})) env_defaults = { "delivery_mode": os.getenv("TEAMS_DELIVERY_MODE", ""), "incoming_webhook_url": os.getenv("TEAMS_INCOMING_WEBHOOK_URL", ""), "access_token": os.getenv("TEAMS_GRAPH_ACCESS_TOKEN", ""), "team_id": os.getenv("TEAMS_TEAM_ID", ""), "channel_id": os.getenv("TEAMS_CHANNEL_ID", ""), "chat_id": os.getenv("TEAMS_CHAT_ID", ""), } for key, value in env_defaults.items(): if value and not merged.get(key): merged[key] = value return merged async def _write_summary_via_incoming_webhook( self, payload: Any, config: dict[str, Any], ) -> dict[str, Any]: webhook_url = str(config.get("incoming_webhook_url") or "").strip() if not webhook_url: raise ValueError("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.") body = {"text": self._render_summary_markdown(payload)} async with httpx.AsyncClient(timeout=20.0, transport=self._transport) as client: response = await client.post(webhook_url, json=body) response.raise_for_status() return { "delivery_mode": "incoming_webhook", "webhook_url": webhook_url, "status_code": response.status_code, "delivered": True, } async def _write_summary_via_graph( self, payload: Any, config: dict[str, Any], ) -> dict[str, Any]: graph_client = self._build_graph_client(config) chat_id = str(config.get("chat_id") or "").strip() if chat_id: path = f"/chats/{quote(chat_id, safe='')}/messages" response = await graph_client.post_json( path, json_body={"body": {"contentType": "html", "content": self._render_summary_html(payload)}}, ) return { "delivery_mode": "graph", "target_type": "chat", "chat_id": chat_id, "message_id": (response or {}).get("id"), "web_url": (response or {}).get("webUrl"), } team_id = str(config.get("team_id") or "").strip() channel_id = str(config.get("channel_id") or "").strip() if not team_id or not channel_id: raise ValueError( "Graph delivery mode requires chat_id, or both team_id and channel_id." ) path = ( f"/teams/{quote(team_id, safe='')}/channels/" f"{quote(channel_id, safe='')}/messages" ) response = await graph_client.post_json( path, json_body={"body": {"contentType": "html", "content": self._render_summary_html(payload)}}, ) return { "delivery_mode": "graph", "target_type": "channel", "team_id": team_id, "channel_id": channel_id, "message_id": (response or {}).get("id"), "web_url": (response or {}).get("webUrl"), } def _build_graph_client(self, config: dict[str, Any]) -> Any: if self._graph_client is not None: return self._graph_client from tools.microsoft_graph_auth import MicrosoftGraphTokenProvider from tools.microsoft_graph_client import MicrosoftGraphClient access_token = str(config.get("access_token") or "").strip() if access_token: return MicrosoftGraphClient( _StaticAccessTokenProvider(access_token), transport=self._transport, ) return MicrosoftGraphClient( MicrosoftGraphTokenProvider.from_env(), transport=self._transport, ) def _render_summary_markdown(self, payload: Any) -> str: lines = [ f"**{self._title(payload)}**", "", f"Summary: {self._text(getattr(payload, 'summary', None), 'No summary available.')}", "", "Key decisions:", *self._bullet_lines(getattr(payload, "key_decisions", None)), "", "Action items:", *self._bullet_lines(getattr(payload, "action_items", None)), "", "Risks:", *self._bullet_lines(getattr(payload, "risks", None)), ] return "\n".join(lines) def _render_summary_html(self, payload: Any) -> str: sections = [ ("Summary", [self._text(getattr(payload, "summary", None), "No summary available.")]), ("Key decisions", list(getattr(payload, "key_decisions", None) or [])), ("Action items", list(getattr(payload, "action_items", None) or [])), ("Risks", list(getattr(payload, "risks", None) or [])), ] blocks = [f"
{html.escape(str(items[0]))}
") continue if items: rendered = "".join(f"None
") else: blocks.append("None
") return "".join(blocks) @staticmethod def _title(payload: Any) -> str: title = getattr(payload, "title", None) if title: return str(title) meeting_ref = getattr(payload, "meeting_ref", None) meeting_id = getattr(meeting_ref, "meeting_id", None) if meeting_ref else None return f"Meeting {meeting_id or 'summary'}" @staticmethod def _text(value: Any, default: str) -> str: text = str(value or "").strip() return text or default @classmethod def _bullet_lines(cls, values: Any) -> list[str]: items = [str(item).strip() for item in (values or []) if str(item).strip()] return [f"- {item}" for item in items] or ["- None"] class _AiohttpBridgeAdapter: """HttpServerAdapter that bridges the Teams SDK into an aiohttp server. Without a custom adapter, ``App()`` unconditionally imports fastapi/uvicorn and allocates a ``FastAPI()`` instance. This bridge captures the SDK's route registrations and wires them into our own aiohttp ``Application``. """ def __init__(self, aiohttp_app: "web.Application"): self._aiohttp_app = aiohttp_app def register_route(self, method: "HttpMethod", path: str, handler: "HttpRouteHandler") -> None: """Register an SDK route handler as an aiohttp route.""" async def _aiohttp_handler(request: "web.Request") -> "web.Response": body = await request.json() headers = dict(request.headers) result: "HttpResponse" = await handler(HttpRequest(body=body, headers=headers)) status = result.get("status", 200) resp_body = result.get("body") if resp_body is not None: return web.Response( status=status, body=json.dumps(resp_body), content_type="application/json", ) return web.Response(status=status) self._aiohttp_app.router.add_route(method, path, _aiohttp_handler) def serve_static(self, path: str, directory: str) -> None: pass async def start(self, port: int) -> None: raise NotImplementedError("aiohttp server is managed by the adapter") async def stop(self) -> None: pass def check_requirements() -> bool: """Return True when all Teams dependencies and credentials are present.""" return TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE def validate_config(config) -> bool: """Return True when the config has the minimum required credentials.""" extra = getattr(config, "extra", {}) or {} client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "") client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "") tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "") return bool(client_id and client_secret and tenant_id) def is_connected(config) -> bool: """Check whether Teams is configured (env or config.yaml).""" return validate_config(config) def _env_enablement() -> dict | None: """Seed ``PlatformConfig.extra`` from env vars during gateway config load. Called by the platform registry's env-enablement hook BEFORE adapter construction, so ``gateway status`` and ``get_connected_platforms()`` reflect env-only configuration without instantiating the Teams SDK. Returns ``None`` when Teams isn't minimally configured. The special ``home_channel`` key in the returned dict becomes a proper ``HomeChannel`` dataclass on the ``PlatformConfig`` via the core hook. """ client_id = os.getenv("TEAMS_CLIENT_ID", "").strip() client_secret = os.getenv("TEAMS_CLIENT_SECRET", "").strip() tenant_id = os.getenv("TEAMS_TENANT_ID", "").strip() if not (client_id and client_secret and tenant_id): return None seed: dict = { "client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id, } port = os.getenv("TEAMS_PORT", "").strip() if port: try: seed["port"] = int(port) except ValueError: pass home = os.getenv("TEAMS_HOME_CHANNEL", "").strip() if home: seed["home_channel"] = { "chat_id": home, "name": os.getenv("TEAMS_HOME_CHANNEL_NAME", "Home"), } return seed # Keep the old name as an alias so existing test imports don't break. check_teams_requirements = check_requirements class TeamsAdapter(BasePlatformAdapter): """Microsoft Teams adapter using the microsoft-teams-apps SDK.""" MAX_MESSAGE_LENGTH = 28000 # Teams text message limit (~28 KB) def __init__(self, config: PlatformConfig): super().__init__(config, Platform("teams")) extra = config.extra or {} self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "") self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "") self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "") self._port = int(extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT))) self._app: Optional["App"] = None self._runner: Optional["web.AppRunner"] = None self._dedup = MessageDeduplicator(max_size=1000) # Maps chat_id → ConversationReference captured from incoming messages. # Used to send cards with the correct conversation type (personal/group/channel). self._conv_refs: Dict[str, Any] = {} async def connect(self) -> bool: if not TEAMS_SDK_AVAILABLE: self._set_fatal_error( "MISSING_SDK", "microsoft-teams-apps not installed. Run: pip install microsoft-teams-apps", retryable=False, ) return False if not AIOHTTP_AVAILABLE: self._set_fatal_error( "MISSING_SDK", "aiohttp not installed. Run: pip install aiohttp", retryable=False, ) return False if not self._client_id or not self._client_secret or not self._tenant_id: self._set_fatal_error( "MISSING_CREDENTIALS", "TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required", retryable=False, ) return False try: # Set up aiohttp app first — the bridge adapter wires SDK routes into it aiohttp_app = web.Application() aiohttp_app.router.add_get("/health", lambda _: web.Response(text="ok")) self._app = App( client_id=self._client_id, client_secret=self._client_secret, tenant_id=self._tenant_id, http_server_adapter=_AiohttpBridgeAdapter(aiohttp_app), client=ClientOptions(headers={"User-Agent": "Hermes"}), ) # Register message handler before initialize() @self._app.on_message async def _handle_message(ctx: ActivityContext[MessageActivity]): await self._on_message(ctx) @self._app.on_card_action async def _handle_card_action( ctx: ActivityContext[AdaptiveCardInvokeActivity], ) -> InvokeResponse[AdaptiveCardActionMessageResponse]: return await self._on_card_action(ctx) # initialize() calls register_route() on the bridge, which adds # POST /api/messages to aiohttp_app automatically await self._app.initialize() self._runner = web.AppRunner(aiohttp_app) await self._runner.setup() site = web.TCPSite(self._runner, "0.0.0.0", self._port) await site.start() self._running = True self._mark_connected() logger.info( "[teams] Webhook server listening on 0.0.0.0:%d%s", self._port, _WEBHOOK_PATH, ) return True except Exception as e: self._set_fatal_error( "CONNECT_FAILED", f"Teams connection failed: {e}", retryable=True, ) logger.error("[teams] Failed to connect: %s", e) return False async def disconnect(self) -> None: self._running = False if self._runner: await self._runner.cleanup() self._runner = None self._app = None self._mark_disconnected() logger.info("[teams] Disconnected") async def _on_message(self, ctx: ActivityContext[MessageActivity]) -> None: """Process an incoming Teams message and dispatch to the gateway.""" activity = ctx.activity # Self-message filter bot_id = self._app.id if self._app else None if bot_id and getattr(activity.from_, "id", None) == bot_id: return # Deduplication msg_id = getattr(activity, "id", None) if msg_id and self._dedup.is_duplicate(msg_id): return # Cache the conversation reference for proactive sends (approval cards, etc.) conv_id = getattr(activity.conversation, "id", None) if conv_id: self._conv_refs[conv_id] = ctx.conversation_ref # Extract text — strip bot @mentions text = "" if hasattr(activity, "text") and activity.text: text = activity.text # Strip