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