diff --git a/.env.example b/.env.example index 066e93f7c9..589978e6b5 100644 --- a/.env.example +++ b/.env.example @@ -398,3 +398,19 @@ IMAGE_TOOLS_DEBUG=false # Override STT provider endpoints (for proxies or self-hosted instances) # GROQ_BASE_URL=https://api.groq.com/openai/v1 # STT_OPENAI_BASE_URL=https://api.openai.com/v1 + +# ============================================================================= +# MICROSOFT TEAMS INTEGRATION +# ============================================================================= +# Register a Bot in Azure: https://dev.botframework.com/ → "Register a bot" +# Or use Azure Portal: Azure Active Directory → App registrations → New registration +# Then add the bot to Teams via the Bot Framework or App Studio. +# +# TEAMS_CLIENT_ID= # Azure AD App (client) ID +# TEAMS_CLIENT_SECRET= # Azure AD client secret value +# TEAMS_TENANT_ID= # Azure AD tenant ID (or "common" for multi-tenant) +# TEAMS_ALLOWED_USERS= # Comma-separated AAD object IDs or UPNs +# TEAMS_ALLOW_ALL_USERS=false # Set true to skip the allowlist +# TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery +# TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel +# TEAMS_PORT=3978 # Webhook listen port (Bot Framework default) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index ac0f1588ab..e292498b0c 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -570,7 +570,7 @@ agent: # - A preset like "hermes-cli" or "hermes-telegram" (curated tool set) # - A list of individual toolsets to compose your own (see list below) # -# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot +# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams # # Examples: # @@ -600,6 +600,7 @@ agent: # signal: hermes-signal (same as telegram) # homeassistant: hermes-homeassistant (same as telegram) # qqbot: hermes-qqbot (same as telegram) +# teams: hermes-teams (same as telegram) # platform_toolsets: cli: [hermes-cli] @@ -611,6 +612,7 @@ platform_toolsets: homeassistant: [hermes-homeassistant] qqbot: [hermes-qqbot] yuanbao: [hermes-yuanbao] + teams: [hermes-teams] # ============================================================================= # Gateway Platform Settings diff --git a/docker-compose.yml b/docker-compose.yml index a0fe1a100a..ecf59d40c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,13 @@ services: # uncomment BOTH lines (API_SERVER_KEY is mandatory for auth): # - API_SERVER_HOST=0.0.0.0 # - API_SERVER_KEY=${API_SERVER_KEY} + # Microsoft Teams — uncomment and fill in to enable Teams gateway. + # Register your bot at https://dev.botframework.com/ to get these values. + # - TEAMS_CLIENT_ID=${TEAMS_CLIENT_ID} + # - TEAMS_CLIENT_SECRET=${TEAMS_CLIENT_SECRET} + # - TEAMS_TENANT_ID=${TEAMS_TENANT_ID} + # - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS} + # - TEAMS_PORT=3978 command: ["gateway", "run"] dashboard: diff --git a/plugins/platforms/teams/__init__.py b/plugins/platforms/teams/__init__.py new file mode 100644 index 0000000000..d4f1d7bf0e --- /dev/null +++ b/plugins/platforms/teams/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py new file mode 100644 index 0000000000..ca636501c3 --- /dev/null +++ b/plugins/platforms/teams/adapter.py @@ -0,0 +1,637 @@ +""" +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 json +import logging +import os +from typing import Any, Dict, Optional + +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.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 + 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" + + +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) + + +# 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), + ) + + # 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 BotName HTML tags that Teams prepends for @mentions + if "" in text: + import re + text = re.sub(r"[^<]*\s*", "", text).strip() + + # Determine chat type from conversation + conv = activity.conversation + conv_type = getattr(conv, "conversation_type", None) or "" + if conv_type == "personal": + chat_type = "dm" + elif conv_type == "groupChat": + chat_type = "group" + elif conv_type == "channel": + chat_type = "channel" + else: + chat_type = "dm" + + # Build source + from_account = activity.from_ + user_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "") + user_name = getattr(from_account, "name", None) or "" + + source = self.build_source( + chat_id=conv.id, + chat_name=getattr(conv, "name", None) or "", + chat_type=chat_type, + user_id=str(user_id), + user_name=user_name, + guild_id=getattr(conv, "tenant_id", None) or self._tenant_id, + ) + + # Handle image attachments + media_urls = [] + media_types = [] + for att in getattr(activity, "attachments", None) or []: + content_url = getattr(att, "content_url", None) + content_type = getattr(att, "content_type", None) or "" + if content_url and content_type.startswith("image/"): + try: + cached = await cache_image_from_url(content_url) + if cached: + media_urls.append(cached) + media_types.append(content_type) + except Exception as e: + logger.warning("[teams] Failed to cache image attachment: %s", e) + + msg_type = MessageType.PHOTO if media_urls else MessageType.TEXT + + event = MessageEvent( + text=text, + source=source, + message_type=msg_type, + media_urls=media_urls, + media_types=media_types, + message_id=msg_id, + ) + await self.handle_message(event) + + async def _send_card(self, chat_id: str, card: "AdaptiveCard") -> "Any": + """Send an AdaptiveCard, using a stored ConversationReference when available.""" + from microsoft_teams.api import MessageActivityInput + + conv_ref = self._conv_refs.get(chat_id) + if conv_ref and self._app: + activity = MessageActivityInput().add_card(card) + return await self._app.activity_sender.send(activity, conv_ref) + elif self._app: + return await self._app.send(chat_id, card) + return None + + async def _on_card_action( + self, ctx: "ActivityContext[AdaptiveCardInvokeActivity]" + ) -> "InvokeResponse[AdaptiveCardActionMessageResponse]": + """Handle an Adaptive Card Action.Execute button click.""" + from tools.approval import resolve_gateway_approval, has_blocking_approval + + action = ctx.activity.value.action + data = action.data or {} + hermes_action = data.get("hermes_action", "") + session_key = data.get("session_key", "") + + if not hermes_action or not session_key: + return InvokeResponse( + status=200, + body=AdaptiveCardActionMessageResponse(value="Unknown action."), + ) + + # Only authorized users may click approval buttons. + allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip() + if allowed_csv: + from_account = ctx.activity.from_ + clicker_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "") + allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} + if "*" not in allowed_ids and clicker_id not in allowed_ids: + logger.warning("[teams] Unauthorized card action by %s — ignoring", clicker_id) + return InvokeResponse( + status=200, + body=AdaptiveCardActionMessageResponse(value="⛔ Not authorized."), + ) + + choice_map = { + "approve_once": "once", + "approve_session": "session", + "approve_always": "always", + "deny": "deny", + } + choice = choice_map.get(hermes_action) + if not choice: + return InvokeResponse( + status=200, + body=AdaptiveCardActionMessageResponse(value="Unknown action."), + ) + + if not has_blocking_approval(session_key): + return InvokeResponse( + status=200, + body=AdaptiveCardActionCardResponse( + value=AdaptiveCard() + .with_version("1.4") + .with_body([TextBlock(text="⚠️ Approval already resolved or expired.", wrap=True)]) + ), + ) + + resolve_gateway_approval(session_key, choice) + + label_map = { + "once": "✅ Allowed (once)", + "session": "✅ Allowed (session)", + "always": "✅ Always allowed", + "deny": "❌ Denied", + } + return InvokeResponse( + status=200, + body=AdaptiveCardActionCardResponse( + value=AdaptiveCard() + .with_version("1.4") + .with_body([TextBlock(text=label_map[choice], wrap=True, weight="Bolder")]) + ), + ) + + async def send_exec_approval( + self, + chat_id: str, + command: str, + session_key: str, + description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an Adaptive Card approval prompt with Allow/Deny buttons.""" + if not self._app: + return SendResult(success=False, error="Teams app not initialized") + + cmd_preview = command[:2000] + "..." if len(command) > 2000 else command + + card = ( + AdaptiveCard() + .with_version("1.4") + .with_body([ + TextBlock(text="⚠️ Command Approval Required", wrap=True, weight="Bolder"), + TextBlock(text=f"```\n{cmd_preview}\n```", wrap=True), + TextBlock(text=f"Reason: {description}", wrap=True, isSubtle=True), + ]) + .with_actions([ + ExecuteAction( + title="Allow Once", + verb="hermes_approve", + data={"hermes_action": "approve_once", "session_key": session_key}, + style="positive", + ), + ExecuteAction( + title="Allow Session", + verb="hermes_approve", + data={"hermes_action": "approve_session", "session_key": session_key}, + ), + ExecuteAction( + title="Always Allow", + verb="hermes_approve", + data={"hermes_action": "approve_always", "session_key": session_key}, + ), + ExecuteAction( + title="Deny", + verb="hermes_approve", + data={"hermes_action": "deny", "session_key": session_key}, + style="destructive", + ), + ]) + ) + + try: + result = await self._send_card(chat_id, card) + message_id = getattr(result, "id", None) if result else None + return SendResult(success=True, message_id=message_id) + except Exception as e: + logger.error("[teams] send_exec_approval failed: %s", e, exc_info=True) + return SendResult(success=False, error=str(e), retryable=True) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not self._app: + return SendResult(success=False, error="Teams app not initialized") + + formatted = self.format_message(content) + chunks = self.truncate_message(formatted) + last_message_id = None + + for chunk in chunks: + try: + result = await self._app.send(chat_id, chunk) + last_message_id = getattr(result, "id", None) + except Exception as e: + return SendResult(success=False, error=str(e), retryable=True) + + return SendResult(success=True, message_id=last_message_id) + + async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: + if not self._app: + return + try: + await self._app.send(chat_id, TypingActivityInput()) + except Exception: + pass + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + # Teams: embed image as markdown + text = f"![image]({image_url})" + if caption: + text = f"{caption}\n\n{text}" + return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) + + async def get_chat_info(self, chat_id: str) -> dict: + return {"name": chat_id, "type": "unknown", "chat_id": chat_id} + + +# ── Interactive setup ───────────────────────────────────────────────────────── + +def interactive_setup() -> None: + """Prompt the user for Teams credentials and save them to ~/.hermes/.env.""" + from hermes_cli.config import ( + get_env_value, + save_env_value, + prompt, + prompt_yes_no, + print_info, + print_success, + print_warning, + ) + + existing_id = get_env_value("TEAMS_CLIENT_ID") + if existing_id: + print_info(f"Teams: already configured (app ID: {existing_id})") + if not prompt_yes_no("Reconfigure Teams?", False): + return + + print_info("Connect Hermes to Microsoft Teams via the Bot Framework.") + print_info("You'll need an Azure Bot registration with a client secret.") + print_info("See: https://learn.microsoft.com/azure/bot-service/") + print() + + client_id = prompt( + "Azure App (client) ID", + default=existing_id or get_env_value("TEAMS_CLIENT_ID") or "", + ) + if not client_id: + print_warning("Client ID is required — skipping Teams setup") + return + save_env_value("TEAMS_CLIENT_ID", client_id.strip()) + + client_secret = prompt( + "Client secret", + default=get_env_value("TEAMS_CLIENT_SECRET") or "", + password=True, + ) + if not client_secret: + print_warning("Client secret is required — skipping Teams setup") + return + save_env_value("TEAMS_CLIENT_SECRET", client_secret.strip()) + + tenant_id = prompt( + "Tenant ID (or 'common' for multi-tenant)", + default=get_env_value("TEAMS_TENANT_ID") or "", + ) + if not tenant_id: + print_warning("Tenant ID is required — skipping Teams setup") + return + save_env_value("TEAMS_TENANT_ID", tenant_id.strip()) + + port = prompt( + "Webhook listen port", + default=get_env_value("TEAMS_PORT") or "3978", + ) + save_env_value("TEAMS_PORT", port.strip() or "3978") + + print() + if prompt_yes_no("Restrict access to specific users? (recommended)", True): + allowed = prompt( + "Allowed Azure AD object IDs (comma-separated)", + default=get_env_value("TEAMS_ALLOWED_USERS") or "", + ) + if allowed: + save_env_value("TEAMS_ALLOWED_USERS", allowed.replace(" ", "")) + print_success("Allowlist configured") + else: + save_env_value("TEAMS_ALLOWED_USERS", "") + else: + save_env_value("TEAMS_ALLOW_ALL_USERS", "true") + print_warning("⚠️ Open access — anyone who can message the bot can command it.") + + print() + print_success("Teams configuration saved to ~/.hermes/.env") + print_info("Set your bot's messaging endpoint to: https:///api/messages") + print_info("Restart the gateway for changes to take effect: hermes gateway restart") + + +# ── Plugin entry point ──────────────────────────────────────────────────────── + +def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system.""" + ctx.register_platform( + name="teams", + label="Microsoft Teams", + adapter_factory=lambda cfg: TeamsAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"], + install_hint="pip install microsoft-teams-apps aiohttp", + setup_fn=interactive_setup, + # Auth env vars for _is_user_authorized() integration + allowed_users_env="TEAMS_ALLOWED_USERS", + allow_all_env="TEAMS_ALLOW_ALL_USERS", + # Teams supports up to ~28 KB per message + max_message_length=28000, + # Display + emoji="💼", + allow_update_command=True, + # LLM guidance + platform_hint=( + "You are chatting via Microsoft Teams. Teams renders a subset of " + "markdown — bold (**text**), italic (*text*), and inline code " + "(`code`) work, but complex tables or raw HTML do not. Keep " + "responses clear and professional." + ), + ) diff --git a/plugins/platforms/teams/plugin.yaml b/plugins/platforms/teams/plugin.yaml new file mode 100644 index 0000000000..57f18adaa1 --- /dev/null +++ b/plugins/platforms/teams/plugin.yaml @@ -0,0 +1,13 @@ +name: teams-platform +kind: platform +version: 1.0.0 +description: > + Microsoft Teams gateway adapter for Hermes Agent. + Connects to Microsoft Teams via the Bot Framework and relays messages + between Teams chats (personal DMs, group chats, channel posts) and + the Hermes agent. Supports Adaptive Card approval prompts. +author: Aamir Jawaid +requires_env: + - TEAMS_CLIENT_ID + - TEAMS_CLIENT_SECRET + - TEAMS_TENANT_ID diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py new file mode 100644 index 0000000000..3dec68f820 --- /dev/null +++ b/tests/gateway/test_teams.py @@ -0,0 +1,566 @@ +"""Tests for the Microsoft Teams platform adapter plugin.""" + +import asyncio +import os +import sys +import types +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig, HomeChannel + +# Ensure the plugin directory is on sys.path for direct import (mirrors IRC pattern) +_REPO_ROOT = Path(__file__).resolve().parents[2] +_TEAMS_PLUGIN_DIR = _REPO_ROOT / "plugins" / "platforms" / "teams" +if str(_TEAMS_PLUGIN_DIR) not in sys.path: + sys.path.insert(0, str(_TEAMS_PLUGIN_DIR)) + + +# --------------------------------------------------------------------------- +# SDK Mock — install in sys.modules before importing the adapter +# --------------------------------------------------------------------------- + +def _ensure_teams_mock(): + """Install a teams SDK mock in sys.modules if the real package isn't present.""" + if "microsoft_teams" in sys.modules and hasattr(sys.modules["microsoft_teams"], "__file__"): + return + + # Build the module hierarchy + microsoft_teams = types.ModuleType("microsoft_teams") + microsoft_teams_apps = types.ModuleType("microsoft_teams.apps") + microsoft_teams_api = types.ModuleType("microsoft_teams.api") + microsoft_teams_api_activities = types.ModuleType("microsoft_teams.api.activities") + microsoft_teams_api_activities_typing = types.ModuleType("microsoft_teams.api.activities.typing") + microsoft_teams_api_activities_invoke = types.ModuleType("microsoft_teams.api.activities.invoke") + microsoft_teams_api_activities_invoke_adaptive_card = types.ModuleType( + "microsoft_teams.api.activities.invoke.adaptive_card" + ) + microsoft_teams_api_models = types.ModuleType("microsoft_teams.api.models") + microsoft_teams_api_models_adaptive_card = types.ModuleType("microsoft_teams.api.models.adaptive_card") + microsoft_teams_api_models_invoke_response = types.ModuleType("microsoft_teams.api.models.invoke_response") + microsoft_teams_cards = types.ModuleType("microsoft_teams.cards") + microsoft_teams_apps_http = types.ModuleType("microsoft_teams.apps.http") + microsoft_teams_apps_http_adapter = types.ModuleType("microsoft_teams.apps.http.adapter") + + # App class mock + class MockApp: + def __init__(self, **kwargs): + self._client_id = kwargs.get("client_id") + self.server = MagicMock() + self.server.handle_request = AsyncMock(return_value={"status": 200, "body": None}) + self.credentials = MagicMock() + self.credentials.client_id = self._client_id + + @property + def id(self): + return self._client_id + + def on_message(self, func): + self._message_handler = func + return func + + def on_card_action(self, func): + self._card_action_handler = func + return func + + async def initialize(self): + pass + + async def send(self, conversation_id, activity): + result = MagicMock() + result.id = "sent-activity-id" + return result + + async def start(self, port=3978): + pass + + async def stop(self): + pass + + microsoft_teams_apps.App = MockApp + microsoft_teams_apps.ActivityContext = MagicMock + + # MessageActivity mock + microsoft_teams_api.MessageActivity = MagicMock + microsoft_teams_api.ConversationReference = MagicMock + microsoft_teams_api.MessageActivityInput = MagicMock + + # TypingActivityInput mock + class MockTypingActivityInput: + pass + + microsoft_teams_api_activities_typing.TypingActivityInput = MockTypingActivityInput + + # Adaptive card invoke activity mock + microsoft_teams_api_activities_invoke_adaptive_card.AdaptiveCardInvokeActivity = MagicMock + + # Adaptive card response mocks + microsoft_teams_api_models_adaptive_card.AdaptiveCardActionCardResponse = MagicMock + microsoft_teams_api_models_adaptive_card.AdaptiveCardActionMessageResponse = MagicMock + + # Invoke response mocks + class MockInvokeResponse: + def __init__(self, status=200, body=None): + self.status = status + self.body = body + + microsoft_teams_api_models_invoke_response.InvokeResponse = MockInvokeResponse + microsoft_teams_api_models_invoke_response.AdaptiveCardInvokeResponse = MagicMock + + # Cards mocks + class MockAdaptiveCard: + def with_version(self, v): + return self + + def with_body(self, body): + return self + + def with_actions(self, actions): + return self + + microsoft_teams_cards.AdaptiveCard = MockAdaptiveCard + microsoft_teams_cards.ExecuteAction = MagicMock + microsoft_teams_cards.TextBlock = MagicMock + + # HttpRequest TypedDict mock + def HttpRequest(body=None, headers=None): + return {"body": body, "headers": headers} + + # HttpResponse TypedDict mock + HttpResponse = dict + HttpMethod = str + from typing import Callable + HttpRouteHandler = Callable + + microsoft_teams_apps_http_adapter.HttpRequest = HttpRequest + microsoft_teams_apps_http_adapter.HttpResponse = HttpResponse + microsoft_teams_apps_http_adapter.HttpMethod = HttpMethod + microsoft_teams_apps_http_adapter.HttpRouteHandler = HttpRouteHandler + + # Wire the hierarchy + for name, mod in { + "microsoft_teams": microsoft_teams, + "microsoft_teams.apps": microsoft_teams_apps, + "microsoft_teams.api": microsoft_teams_api, + "microsoft_teams.api.activities": microsoft_teams_api_activities, + "microsoft_teams.api.activities.typing": microsoft_teams_api_activities_typing, + "microsoft_teams.api.activities.invoke": microsoft_teams_api_activities_invoke, + "microsoft_teams.api.activities.invoke.adaptive_card": microsoft_teams_api_activities_invoke_adaptive_card, + "microsoft_teams.api.models": microsoft_teams_api_models, + "microsoft_teams.api.models.adaptive_card": microsoft_teams_api_models_adaptive_card, + "microsoft_teams.api.models.invoke_response": microsoft_teams_api_models_invoke_response, + "microsoft_teams.cards": microsoft_teams_cards, + "microsoft_teams.apps.http": microsoft_teams_apps_http, + "microsoft_teams.apps.http.adapter": microsoft_teams_apps_http_adapter, + }.items(): + sys.modules.setdefault(name, mod) + + +_ensure_teams_mock() + +# Now safe to import the adapter +import adapter as _teams_mod + +_teams_mod.TEAMS_SDK_AVAILABLE = True +_teams_mod.AIOHTTP_AVAILABLE = True + +from adapter import TeamsAdapter, check_requirements, check_teams_requirements, validate_config + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_config(**extra): + return PlatformConfig(enabled=True, extra=extra) + + +# --------------------------------------------------------------------------- +# Tests: Requirements +# --------------------------------------------------------------------------- + +class TestTeamsRequirements: + def test_returns_false_when_sdk_missing(self, monkeypatch): + monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) + assert check_requirements() is False + + def test_returns_false_when_aiohttp_missing(self, monkeypatch): + monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", False) + assert check_requirements() is False + + def test_returns_true_when_deps_available(self, monkeypatch): + monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True) + monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True) + assert check_requirements() is True + + def test_alias_matches(self, monkeypatch): + monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True) + monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True) + assert check_teams_requirements() is True + + def test_validate_config_with_env(self, monkeypatch): + monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id") + monkeypatch.setenv("TEAMS_CLIENT_SECRET", "test-secret") + monkeypatch.setenv("TEAMS_TENANT_ID", "test-tenant") + assert validate_config(_make_config()) is True + + def test_validate_config_from_extra(self, monkeypatch): + monkeypatch.delenv("TEAMS_CLIENT_ID", raising=False) + monkeypatch.delenv("TEAMS_CLIENT_SECRET", raising=False) + monkeypatch.delenv("TEAMS_TENANT_ID", raising=False) + cfg = _make_config(client_id="id", client_secret="secret", tenant_id="tenant") + assert validate_config(cfg) is True + + def test_validate_config_missing(self, monkeypatch): + monkeypatch.delenv("TEAMS_CLIENT_ID", raising=False) + monkeypatch.delenv("TEAMS_CLIENT_SECRET", raising=False) + monkeypatch.delenv("TEAMS_TENANT_ID", raising=False) + assert validate_config(_make_config()) is False + + def test_validate_config_missing_tenant(self, monkeypatch): + monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id") + monkeypatch.setenv("TEAMS_CLIENT_SECRET", "test-secret") + monkeypatch.delenv("TEAMS_TENANT_ID", raising=False) + assert validate_config(_make_config()) is False + + +# --------------------------------------------------------------------------- +# Tests: Adapter Init +# --------------------------------------------------------------------------- + +class TestTeamsAdapterInit: + def test_reads_config_from_extra(self): + config = _make_config( + client_id="cfg-id", + client_secret="cfg-secret", + tenant_id="cfg-tenant", + ) + adapter = TeamsAdapter(config) + assert adapter._client_id == "cfg-id" + assert adapter._client_secret == "cfg-secret" + assert adapter._tenant_id == "cfg-tenant" + + def test_falls_back_to_env_vars(self, monkeypatch): + monkeypatch.setenv("TEAMS_CLIENT_ID", "env-id") + monkeypatch.setenv("TEAMS_CLIENT_SECRET", "env-secret") + monkeypatch.setenv("TEAMS_TENANT_ID", "env-tenant") + adapter = TeamsAdapter(_make_config()) + assert adapter._client_id == "env-id" + assert adapter._client_secret == "env-secret" + assert adapter._tenant_id == "env-tenant" + + def test_default_port(self): + adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant")) + assert adapter._port == 3978 + + def test_custom_port_from_extra(self): + adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant", port=4000)) + assert adapter._port == 4000 + + def test_custom_port_from_env(self, monkeypatch): + monkeypatch.setenv("TEAMS_PORT", "5000") + adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant")) + assert adapter._port == 5000 + + def test_platform_value(self): + adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant")) + assert adapter.platform.value == "teams" + + +# --------------------------------------------------------------------------- +# Tests: Plugin registration +# --------------------------------------------------------------------------- + +class TestTeamsPluginRegistration: + + def test_register_calls_ctx(self): + from adapter import register + ctx = MagicMock() + register(ctx) + ctx.register_platform.assert_called_once() + + def test_register_name(self): + from adapter import register + ctx = MagicMock() + register(ctx) + kwargs = ctx.register_platform.call_args[1] + assert kwargs["name"] == "teams" + + def test_register_auth_env_vars(self): + from adapter import register + ctx = MagicMock() + register(ctx) + kwargs = ctx.register_platform.call_args[1] + assert kwargs["allowed_users_env"] == "TEAMS_ALLOWED_USERS" + assert kwargs["allow_all_env"] == "TEAMS_ALLOW_ALL_USERS" + + def test_register_max_message_length(self): + from adapter import register + ctx = MagicMock() + register(ctx) + kwargs = ctx.register_platform.call_args[1] + assert kwargs["max_message_length"] == 28000 + + def test_register_has_setup_fn(self): + from adapter import register + ctx = MagicMock() + register(ctx) + kwargs = ctx.register_platform.call_args[1] + assert callable(kwargs.get("setup_fn")) + + def test_register_has_platform_hint(self): + from adapter import register + ctx = MagicMock() + register(ctx) + kwargs = ctx.register_platform.call_args[1] + assert kwargs.get("platform_hint") + + +# --------------------------------------------------------------------------- +# Tests: Connect / Disconnect +# --------------------------------------------------------------------------- + +class TestTeamsConnect: + @pytest.mark.asyncio + async def test_connect_fails_without_sdk(self, monkeypatch): + monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) + adapter = TeamsAdapter(_make_config( + client_id="id", client_secret="secret", tenant_id="tenant", + )) + result = await adapter.connect() + assert result is False + + @pytest.mark.asyncio + async def test_connect_fails_without_credentials(self): + adapter = TeamsAdapter(_make_config()) + adapter._client_id = "" + adapter._client_secret = "" + adapter._tenant_id = "" + result = await adapter.connect() + assert result is False + + @pytest.mark.asyncio + async def test_disconnect_cleans_up(self): + adapter = TeamsAdapter(_make_config( + client_id="id", client_secret="secret", tenant_id="tenant", + )) + adapter._running = True + mock_runner = AsyncMock() + adapter._runner = mock_runner + adapter._app = MagicMock() + + await adapter.disconnect() + assert adapter._running is False + assert adapter._app is None + assert adapter._runner is None + mock_runner.cleanup.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Tests: Send +# --------------------------------------------------------------------------- + +class TestTeamsSend: + @pytest.mark.asyncio + async def test_send_returns_error_without_app(self): + adapter = TeamsAdapter(_make_config( + client_id="id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = None + result = await adapter.send("conv-id", "Hello") + assert result.success is False + assert "not initialized" in result.error + + @pytest.mark.asyncio + async def test_send_calls_app_send(self): + adapter = TeamsAdapter(_make_config( + client_id="id", client_secret="secret", tenant_id="tenant", + )) + mock_result = MagicMock() + mock_result.id = "msg-123" + mock_app = MagicMock() + mock_app.send = AsyncMock(return_value=mock_result) + adapter._app = mock_app + + result = await adapter.send("conv-id", "Hello") + assert result.success is True + assert result.message_id == "msg-123" + mock_app.send.assert_awaited_once_with("conv-id", "Hello") + + @pytest.mark.asyncio + async def test_send_handles_error(self): + adapter = TeamsAdapter(_make_config( + client_id="id", client_secret="secret", tenant_id="tenant", + )) + mock_app = MagicMock() + mock_app.send = AsyncMock(side_effect=Exception("Network error")) + adapter._app = mock_app + + result = await adapter.send("conv-id", "Hello") + assert result.success is False + assert "Network error" in result.error + + @pytest.mark.asyncio + async def test_send_typing(self): + adapter = TeamsAdapter(_make_config( + client_id="id", client_secret="secret", tenant_id="tenant", + )) + mock_app = MagicMock() + mock_app.send = AsyncMock() + adapter._app = mock_app + + await adapter.send_typing("conv-id") + mock_app.send.assert_awaited_once() + call_args = mock_app.send.call_args + assert call_args[0][0] == "conv-id" + + +# --------------------------------------------------------------------------- +# Tests: Message Handling +# --------------------------------------------------------------------------- + +class TestTeamsMessageHandling: + def _make_activity( + self, + *, + text="Hello", + from_id="user-123", + from_aad_id="aad-456", + from_name="Test User", + conversation_id="19:abc@thread.v2", + conversation_type="personal", + tenant_id="tenant-789", + activity_id="activity-001", + attachments=None, + ): + activity = MagicMock() + activity.text = text + activity.id = activity_id + activity.from_ = MagicMock() + activity.from_.id = from_id + activity.from_.aad_object_id = from_aad_id + activity.from_.name = from_name + activity.conversation = MagicMock() + activity.conversation.id = conversation_id + activity.conversation.conversation_type = conversation_type + activity.conversation.name = "Test Chat" + activity.conversation.tenant_id = tenant_id + activity.attachments = attachments or [] + return activity + + def _make_ctx(self, activity): + ctx = MagicMock() + ctx.activity = activity + return ctx + + @pytest.mark.asyncio + async def test_personal_message_creates_dm_event(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter.handle_message = AsyncMock() + + activity = self._make_activity(conversation_type="personal") + await adapter._on_message(self._make_ctx(activity)) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.call_args[0][0] + assert event.source.chat_type == "dm" + + @pytest.mark.asyncio + async def test_group_message_creates_group_event(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter.handle_message = AsyncMock() + + activity = self._make_activity(conversation_type="groupChat") + await adapter._on_message(self._make_ctx(activity)) + + event = adapter.handle_message.call_args[0][0] + assert event.source.chat_type == "group" + + @pytest.mark.asyncio + async def test_channel_message_creates_channel_event(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter.handle_message = AsyncMock() + + activity = self._make_activity(conversation_type="channel") + await adapter._on_message(self._make_ctx(activity)) + + event = adapter.handle_message.call_args[0][0] + assert event.source.chat_type == "channel" + + @pytest.mark.asyncio + async def test_user_id_uses_aad_object_id(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter.handle_message = AsyncMock() + + activity = self._make_activity(from_aad_id="aad-stable-id", from_id="teams-id") + await adapter._on_message(self._make_ctx(activity)) + + event = adapter.handle_message.call_args[0][0] + assert event.source.user_id == "aad-stable-id" + + @pytest.mark.asyncio + async def test_self_message_filtered(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter.handle_message = AsyncMock() + + activity = self._make_activity(from_id="bot-id") + await adapter._on_message(self._make_ctx(activity)) + + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_bot_mention_stripped_from_text(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter.handle_message = AsyncMock() + + activity = self._make_activity( + text="Hermes what is the weather?", + from_id="user-id", + ) + await adapter._on_message(self._make_ctx(activity)) + + event = adapter.handle_message.call_args[0][0] + assert event.text == "what is the weather?" + + @pytest.mark.asyncio + async def test_deduplication(self): + adapter = TeamsAdapter(_make_config( + client_id="bot-id", client_secret="secret", tenant_id="tenant", + )) + adapter._app = MagicMock() + adapter._app.id = "bot-id" + adapter.handle_message = AsyncMock() + + activity = self._make_activity(activity_id="msg-dup-001", from_id="user-id") + ctx = self._make_ctx(activity) + + await adapter._on_message(ctx) + await adapter._on_message(ctx) + + assert adapter.handle_message.await_count == 1 diff --git a/website/docs/user-guide/messaging/teams.md b/website/docs/user-guide/messaging/teams.md new file mode 100644 index 0000000000..adc97ebff2 --- /dev/null +++ b/website/docs/user-guide/messaging/teams.md @@ -0,0 +1,211 @@ +--- +sidebar_position: 5 +title: "Microsoft Teams" +description: "Set up Hermes Agent as a Microsoft Teams bot" +--- + +# Microsoft Teams Setup + +Connect Hermes Agent to Microsoft Teams as a bot. Unlike Slack's Socket Mode, Teams delivers messages by calling a **public HTTPS webhook**, so your instance needs a publicly reachable endpoint — either a dev tunnel (local dev) or a real domain (production). + +## How the Bot Responds + +| Context | Behavior | +|---------|----------| +| **Personal chat (DM)** | Bot responds to every message. No @mention needed. | +| **Group chat** | Bot responds to every message in the chat. | +| **Channel** | Bot only responds when @mentioned (Teams delivers @mentions as regular messages with `BotName` tags, which Hermes strips automatically). | + +--- + +## Step 1: Install the Teams CLI + +The `@microsoft/teams.cli` automates bot registration — no Azure portal needed. + +```bash +npm install -g @microsoft/teams.cli@preview +teams login +``` + +To verify your login and find your own AAD object ID (needed for `TEAMS_ALLOWED_USERS`): + +```bash +teams status --verbose +``` + +--- + +## Step 2: Expose Port 3978 + +Teams cannot deliver messages to `localhost`. For local development, use any tunnel tool to get a public HTTPS URL: + +```bash +# devtunnel (Microsoft) +devtunnel create hermes-bot --allow-anonymous +devtunnel port create hermes-bot -p 3978 --protocol https +devtunnel host hermes-bot + +# ngrok +ngrok http 3978 + +# cloudflared +cloudflared tunnel --url http://localhost:3978 +``` + +Copy the `https://` URL from the output — you'll use it in the next step. Leave the tunnel running while developing. + +For production, point your bot's endpoint at your server's public domain instead (see [Production Deployment](#production-deployment)). + +--- + +## Step 3: Create the Bot + +```bash +teams app create \ + --name "Hermes" \ + --endpoint "https:///api/messages" +``` + +The CLI outputs your `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Save them — you'll need all three. + +--- + +## Step 4: Configure Environment Variables + +Add to `~/.hermes/.env`: + +```bash +# Required +TEAMS_CLIENT_ID= +TEAMS_CLIENT_SECRET= +TEAMS_TENANT_ID= + +# Restrict access to specific users (recommended) +# Use AAD object IDs from `teams status --verbose` +TEAMS_ALLOWED_USERS= +``` + +--- + +## Step 5: Start the Gateway + +```bash +HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d gateway +``` + +This starts the gateway and maps port 3978 on your host to the container. Check that it's running: + +```bash +curl http://localhost:3978/health # should return: ok +docker logs -f hermes +``` + +Look for: +``` +[teams] Webhook server listening on 0.0.0.0:3978/api/messages +``` + +--- + +## Step 6: Install the App in Teams + +```bash +teams app install --id +``` + +The `teamsAppId` was printed by `teams app create` in Step 3. After installing, open Microsoft Teams and send a direct message to your bot — it's ready. + +--- + +## Configuration Reference + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `TEAMS_CLIENT_ID` | Azure AD App (client) ID | +| `TEAMS_CLIENT_SECRET` | Azure AD client secret | +| `TEAMS_TENANT_ID` | Azure AD tenant ID | +| `TEAMS_ALLOWED_USERS` | Comma-separated AAD object IDs allowed to use the bot | +| `TEAMS_HOME_CHANNEL` | Conversation ID for cron/proactive message delivery | +| `TEAMS_HOME_CHANNEL_NAME` | Display name for the home channel | +| `TEAMS_PORT` | Webhook port (default: `3978`) | + +### config.yaml + +Alternatively, configure via `~/.hermes/config.yaml`: + +```yaml +platforms: + teams: + enabled: true + extra: + client_id: "your-client-id" + client_secret: "your-secret" + tenant_id: "your-tenant-id" + port: 3978 +``` + +--- + +## Features + +### Interactive Approval Cards + +When the agent needs to run a potentially dangerous command, it sends an Adaptive Card with four buttons instead of asking you to type `/approve`: + +- **Allow Once** — approve this specific command +- **Allow Session** — approve this pattern for the rest of the session +- **Always Allow** — permanently approve this pattern +- **Deny** — reject the command + +Clicking a button resolves the approval inline and replaces the card with the decision. + +--- + +## Production Deployment + +For a permanent server, skip devtunnel and register your bot with your server's public HTTPS endpoint: + +```bash +teams app create \ + --name "Hermes" \ + --endpoint "https://your-domain.com/api/messages" +``` + +If you've already created the bot and just need to update the endpoint: + +```bash +teams app update --id --endpoint "https://your-domain.com/api/messages" +``` + +Make sure port 3978 (or your configured `TEAMS_PORT`) is reachable from the internet and that your TLS certificate is valid — Teams rejects self-signed certificates. + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `health` endpoint works but bot doesn't respond | Check that your tunnel is still running and the bot's messaging endpoint matches the tunnel URL | +| `KeyError: 'teams'` in logs | Restart the container — this is fixed in the current version | +| Bot responds with auth errors | Verify `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are all set correctly | +| `No inference provider configured` | Check that `ANTHROPIC_API_KEY` (or another provider key) is set in `~/.hermes/.env` | +| Bot receives messages but ignores them | Your AAD object ID may not be in `TEAMS_ALLOWED_USERS`. Run `teams status --verbose` to find it | +| Tunnel URL changes on restart | devtunnel URLs are persistent if you use a named tunnel (`devtunnel create hermes-bot`). ngrok and cloudflared generate a new URL each run unless you have a paid plan — update the bot endpoint with `teams app update` when it changes | +| Teams shows "This bot is not responding" | The webhook returned an error. Check `docker logs hermes` for tracebacks | +| `[teams] Failed to connect` in logs | The SDK failed to authenticate. Double-check your credentials and that the tenant ID matches the account you used in `teams login` | + +--- + +## Security + +:::warning +**Always set `TEAMS_ALLOWED_USERS`** with the AAD object IDs of authorized users. Without this, anyone who can find or install your bot can interact with it. + +Treat `TEAMS_CLIENT_SECRET` like a password — rotate it periodically via the Azure portal or Teams CLI. +::: + +- Store credentials in `~/.hermes/.env` with permissions `600` (`chmod 600 ~/.hermes/.env`) +- The bot only accepts messages from users in `TEAMS_ALLOWED_USERS`; unauthorized messages are silently dropped +- Your public endpoint (`/api/messages`) is authenticated by the Teams Bot Framework — requests without valid JWTs are rejected