From 580c94d9c1052df6d9e0e799cfc6cc8a5bb4398f Mon Sep 17 00:00:00 2001
From: Gianfranco Piana <52470719+gianfrancopiana@users.noreply.github.com>
Date: Mon, 27 Apr 2026 13:36:53 -0300
Subject: [PATCH] feat(gateway): add text-only Microsoft Teams adapter
---
agent/prompt_builder.py | 6 +
gateway/config.py | 109 +++
gateway/platforms/msteams/__init__.py | 15 +
gateway/platforms/msteams/adapter.py | 722 ++++++++++++++++++
gateway/platforms/msteams/auth.py | 239 ++++++
gateway/run.py | 11 +
hermes_cli/config.py | 31 +
hermes_cli/platforms.py | 1 +
hermes_cli/status.py | 1 +
hermes_cli/tools_config.py | 2 +
tests/gateway/test_msteams_adapter.py | 257 +++++++
tests/gateway/test_msteams_auth.py | 152 ++++
tests/gateway/test_msteams_config.py | 125 +++
toolsets.py | 8 +-
website/docs/integrations/index.md | 2 +-
.../docs/reference/environment-variables.md | 10 +
website/docs/user-guide/messaging/index.md | 4 +-
website/docs/user-guide/messaging/msteams.md | 82 ++
website/sidebars.ts | 1 +
19 files changed, 1775 insertions(+), 3 deletions(-)
create mode 100644 gateway/platforms/msteams/__init__.py
create mode 100644 gateway/platforms/msteams/adapter.py
create mode 100644 gateway/platforms/msteams/auth.py
create mode 100644 tests/gateway/test_msteams_adapter.py
create mode 100644 tests/gateway/test_msteams_auth.py
create mode 100644 tests/gateway/test_msteams_config.py
create mode 100644 website/docs/user-guide/messaging/msteams.md
diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py
index 25a4daf527a3..303a68a0be13 100644
--- a/agent/prompt_builder.py
+++ b/agent/prompt_builder.py
@@ -330,6 +330,12 @@ def _strip_yaml_frontmatter(content: str) -> str:
"attachments, audio as file attachments. You can also include image URLs "
"in markdown format  and they will be uploaded as attachments."
),
+ "msteams": (
+ "You are in Microsoft Teams communicating through a text-only Bot Framework "
+ "adapter. Keep responses suitable for Teams chat. This adapter sends plain "
+ "text replies only in this version: do not promise file uploads, images, "
+ "Adaptive Cards, or Microsoft Graph actions."
+ ),
"signal": (
"You are on a text messaging communication platform, Signal. "
"Please do not use markdown as it does not render. "
diff --git a/gateway/config.py b/gateway/config.py
index 128bfa61ca0c..614b36278b90 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -61,6 +61,7 @@ class Platform(Enum):
DINGTALK = "dingtalk"
API_SERVER = "api_server"
WEBHOOK = "webhook"
+ MSTEAMS = "msteams"
FEISHU = "feishu"
WECOM = "wecom"
WECOM_CALLBACK = "wecom_callback"
@@ -289,6 +290,13 @@ def get_connected_platforms(self) -> List[Platform]:
if config.extra.get("account_id") and (config.token or config.extra.get("token")):
connected.append(platform)
continue
+ # Microsoft Teams uses Bot Framework app credentials in extra
+ if platform == Platform.MSTEAMS:
+ if (config.extra.get("app_id") or config.token) and (
+ config.extra.get("app_password") or config.api_key
+ ):
+ connected.append(platform)
+ continue
# Platforms that use token/api_key auth
if config.token or config.api_key:
connected.append(platform)
@@ -592,6 +600,8 @@ def load_gateway_config() -> GatewayConfig:
bridged["require_mention"] = platform_cfg["require_mention"]
if "free_response_channels" in platform_cfg:
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
+ if "free_response_conversations" in platform_cfg:
+ bridged["free_response_conversations"] = platform_cfg["free_response_conversations"]
if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if "dm_policy" in platform_cfg:
@@ -872,6 +882,22 @@ def _validate_gateway_config(config: "GatewayConfig") -> None:
def _apply_env_overrides(config: GatewayConfig) -> None:
"""Apply environment variable overrides to config."""
+
+ def _split_csv_env(value: str) -> List[str]:
+ return [part.strip() for part in str(value or "").split(",") if part.strip()]
+
+ def _parse_patterns_env(value: str) -> Any:
+ raw = str(value or "").strip()
+ if not raw:
+ return []
+ try:
+ parsed = json.loads(raw)
+ if isinstance(parsed, (list, str)):
+ return parsed
+ except Exception:
+ pass
+ lines = [part.strip() for part in raw.splitlines() if part.strip()]
+ return lines or _split_csv_env(raw)
# Telegram
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
@@ -1114,6 +1140,89 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
if webhook_secret:
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
+ # Microsoft Teams (Bot Framework webhook)
+ msteams_app_id = os.getenv("MSTEAMS_APP_ID")
+ msteams_app_password = os.getenv("MSTEAMS_APP_PASSWORD")
+ msteams_env_present = any(
+ os.getenv(key) is not None
+ for key in (
+ "MSTEAMS_APP_ID",
+ "MSTEAMS_APP_PASSWORD",
+ "MSTEAMS_TENANT_ID",
+ "MSTEAMS_BOT_DISPLAY_NAME",
+ "MSTEAMS_HOST",
+ "MSTEAMS_PORT",
+ "MSTEAMS_PATH",
+ "MSTEAMS_REQUIRE_MENTION",
+ "MSTEAMS_MENTION_PATTERNS",
+ "MSTEAMS_FREE_RESPONSE_CONVERSATIONS",
+ "MSTEAMS_FREE_RESPONSE_CHANNELS",
+ "MSTEAMS_MAX_BODY_BYTES",
+ )
+ )
+ if msteams_env_present or Platform.MSTEAMS in config.platforms:
+ if Platform.MSTEAMS not in config.platforms:
+ config.platforms[Platform.MSTEAMS] = PlatformConfig()
+ platform_config = config.platforms[Platform.MSTEAMS]
+ extra = platform_config.extra
+ if msteams_app_id:
+ platform_config.enabled = True
+ platform_config.token = msteams_app_id
+ extra["app_id"] = msteams_app_id
+ if msteams_app_password:
+ platform_config.api_key = msteams_app_password
+ extra["app_password"] = msteams_app_password
+ tenant_id = os.getenv("MSTEAMS_TENANT_ID")
+ if tenant_id:
+ extra["tenant_id"] = tenant_id
+ bot_display_name = os.getenv("MSTEAMS_BOT_DISPLAY_NAME")
+ if bot_display_name:
+ extra["bot_display_name"] = bot_display_name
+ host = os.getenv("MSTEAMS_HOST")
+ if host:
+ extra["host"] = host
+ port = os.getenv("MSTEAMS_PORT")
+ if port:
+ try:
+ extra["port"] = int(port)
+ except ValueError:
+ extra["port"] = 3978
+ else:
+ extra.setdefault("port", 3978)
+ path = os.getenv("MSTEAMS_PATH")
+ if path:
+ extra["path"] = path
+ else:
+ extra.setdefault("path", "/api/messages")
+ require_mention = os.getenv("MSTEAMS_REQUIRE_MENTION")
+ if require_mention is not None:
+ extra["require_mention"] = _coerce_bool(require_mention, True)
+ else:
+ extra.setdefault("require_mention", True)
+ mention_patterns = os.getenv("MSTEAMS_MENTION_PATTERNS")
+ if mention_patterns:
+ extra["mention_patterns"] = _parse_patterns_env(mention_patterns)
+ free_response = (
+ os.getenv("MSTEAMS_FREE_RESPONSE_CONVERSATIONS")
+ or os.getenv("MSTEAMS_FREE_RESPONSE_CHANNELS")
+ )
+ if free_response:
+ extra["free_response_conversations"] = _split_csv_env(free_response)
+ max_body_bytes = os.getenv("MSTEAMS_MAX_BODY_BYTES")
+ if max_body_bytes:
+ try:
+ extra["max_body_bytes"] = int(max_body_bytes)
+ except ValueError:
+ pass
+
+ msteams_home = os.getenv("MSTEAMS_HOME_CHANNEL")
+ if msteams_home and Platform.MSTEAMS in config.platforms:
+ config.platforms[Platform.MSTEAMS].home_channel = HomeChannel(
+ platform=Platform.MSTEAMS,
+ chat_id=msteams_home,
+ name=os.getenv("MSTEAMS_HOME_CHANNEL_NAME", "Home"),
+ )
+
# DingTalk
dingtalk_client_id = os.getenv("DINGTALK_CLIENT_ID")
dingtalk_client_secret = os.getenv("DINGTALK_CLIENT_SECRET")
diff --git a/gateway/platforms/msteams/__init__.py b/gateway/platforms/msteams/__init__.py
new file mode 100644
index 000000000000..5698ea0f3176
--- /dev/null
+++ b/gateway/platforms/msteams/__init__.py
@@ -0,0 +1,15 @@
+"""Microsoft Teams gateway platform adapter."""
+
+from .adapter import (
+ MsTeamsAdapter,
+ check_msteams_requirements,
+ strip_bot_mention,
+ _activities_url,
+)
+
+__all__ = [
+ "MsTeamsAdapter",
+ "check_msteams_requirements",
+ "strip_bot_mention",
+ "_activities_url",
+]
diff --git a/gateway/platforms/msteams/adapter.py b/gateway/platforms/msteams/adapter.py
new file mode 100644
index 000000000000..a942c7f111f4
--- /dev/null
+++ b/gateway/platforms/msteams/adapter.py
@@ -0,0 +1,722 @@
+"""Text-only Microsoft Teams Bot Framework gateway adapter.
+
+This is intentionally a narrow first slice:
+- receive normal Bot Framework ``message`` activities over an aiohttp webhook
+- validate inbound Bot Framework JWTs
+- normalize text messages into Hermes ``MessageEvent`` / ``SessionSource``
+- require mentions in non-DM conversations by default
+- send outbound text replies through the Bot Framework conversation endpoint
+
+It does not implement Graph, files, images, Adaptive Cards, channel history, or
+standalone cron/send_message delivery.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import html
+import json
+import logging
+import re
+from typing import Any, Dict, Iterable, Optional
+from urllib.parse import quote, urlparse
+
+import httpx
+
+try:
+ from aiohttp import web
+
+ AIOHTTP_AVAILABLE = True
+except ImportError: # pragma: no cover - exercised by requirement probe
+ web = None # type: ignore[assignment]
+ AIOHTTP_AVAILABLE = False
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
+from gateway.platforms.msteams.auth import (
+ BOT_FRAMEWORK_SCOPE,
+ AuthError,
+ BotFrameworkJWTValidator,
+ BotFrameworkTokenProvider,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_HOST = "0.0.0.0"
+DEFAULT_PORT = 3978
+DEFAULT_PATH = "/api/messages"
+DEFAULT_MAX_BODY_BYTES = 1_048_576
+MAX_MESSAGE_LENGTH = 28_000
+
+_AT_TAG_RE = re.compile(r"(.*?)\s*", re.IGNORECASE | re.DOTALL)
+_TRUSTED_SERVICE_URL_SUFFIXES = (
+ ".trafficmanager.net",
+ ".botframework.com",
+ ".botframework.us",
+ ".cloud.microsoft",
+)
+
+
+def check_msteams_requirements() -> bool:
+ """Return True when the webhook dependency needed by Teams is present."""
+ return AIOHTTP_AVAILABLE
+
+
+def _coerce_bool(value: Any, default: bool) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in {"1", "true", "yes", "on"}:
+ return True
+ if lowered in {"0", "false", "no", "off"}:
+ return False
+ return default
+ return bool(value)
+
+
+def _coerce_str_list(value: Any) -> list[str]:
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return [part.strip() for part in value.split(",") if part.strip()]
+ if isinstance(value, (list, tuple, set)):
+ return [str(part).strip() for part in value if str(part).strip()]
+ return []
+
+
+def _get_case_insensitive(mapping: dict[str, Any], *names: str) -> Any:
+ for name in names:
+ if name in mapping:
+ return mapping[name]
+ lowered = {str(key).lower(): value for key, value in mapping.items()}
+ for name in names:
+ if name.lower() in lowered:
+ return lowered[name.lower()]
+ return None
+
+
+def _entity_value(entity: Any, key: str) -> Any:
+ if isinstance(entity, dict):
+ return entity.get(key)
+ return getattr(entity, key, None)
+
+
+def _identity_value(identity: Any, key: str) -> Any:
+ if isinstance(identity, dict):
+ return _get_case_insensitive(identity, key)
+ return getattr(identity, key, None)
+
+
+def _normalize_token(value: str) -> str:
+ return str(value or "").strip().lower()
+
+
+def _matches_any(candidate: str, values: Iterable[str]) -> bool:
+ normalized = _normalize_token(candidate)
+ return bool(normalized and normalized in {_normalize_token(value) for value in values if value})
+
+
+def strip_bot_mention(
+ text: str,
+ *,
+ bot_ids: Iterable[str] = (),
+ bot_names: Iterable[str] = (),
+ entities: Iterable[Any] = (),
+) -> tuple[str, bool]:
+ """Strip Teams bot mentions from activity text.
+
+ Teams usually sends bot mentions as ``Bot Name`` tags and also
+ includes a ``mention`` entity. The entity is authoritative when present;
+ the tag/name fallback keeps local emulator and simplified tests usable.
+ """
+ if not text:
+ return "", False
+
+ bot_ids_set = {str(value) for value in bot_ids if value}
+ bot_names_set = {str(value) for value in bot_names if value}
+ mention_texts: set[str] = set()
+ mentioned = False
+
+ for entity in entities or []:
+ entity_type = str(_entity_value(entity, "type") or "").lower()
+ if entity_type != "mention":
+ continue
+ mentioned_identity = _entity_value(entity, "mentioned") or {}
+ mentioned_id = str(_identity_value(mentioned_identity, "id") or "")
+ mentioned_name = str(_identity_value(mentioned_identity, "name") or "")
+ entity_text = str(_entity_value(entity, "text") or "")
+ if _matches_any(mentioned_id, bot_ids_set) or _matches_any(mentioned_name, bot_names_set):
+ mentioned = True
+ if entity_text:
+ mention_texts.add(entity_text)
+
+ cleaned = str(text)
+ for mention_text in sorted(mention_texts, key=len, reverse=True):
+ cleaned = cleaned.replace(mention_text, "")
+
+ def _strip_at(match: re.Match) -> str:
+ nonlocal mentioned
+ inner = html.unescape(match.group(1) or "").strip()
+ if _matches_any(inner, bot_names_set) or _matches_any(inner, bot_ids_set):
+ mentioned = True
+ return ""
+ # Preserve non-bot mentions as readable plain text.
+ return f"@{inner} " if inner else ""
+
+ cleaned = _AT_TAG_RE.sub(_strip_at, cleaned)
+ cleaned = html.unescape(cleaned).strip()
+
+ for bot_name in sorted(bot_names_set, key=len, reverse=True):
+ if not bot_name:
+ continue
+ pattern = re.compile(rf"^@{re.escape(bot_name)}\b[\s,:-]*", re.IGNORECASE)
+ cleaned, count = pattern.subn("", cleaned, count=1)
+ if count:
+ mentioned = True
+ break
+
+ return cleaned.strip(), mentioned
+
+
+def _activities_url(service_url: str, conversation_id: str) -> str:
+ """Build the Bot Framework activities endpoint for a conversation."""
+ base = str(service_url or "").rstrip("/")
+ parsed = urlparse(base)
+ segments = [segment for segment in parsed.path.split("/") if segment]
+ if "v3" not in segments:
+ base = f"{base}/v3"
+ return f"{base}/conversations/{quote(str(conversation_id), safe='')}/activities"
+
+
+def _is_trusted_service_url(
+ service_url: str,
+ *,
+ extra_trusted_hosts: Iterable[str] = (),
+ allow_untrusted: bool = False,
+) -> bool:
+ if allow_untrusted:
+ return True
+ parsed = urlparse(str(service_url or ""))
+ if parsed.scheme != "https":
+ return False
+ hostname = (parsed.hostname or "").lower()
+ if not hostname:
+ return False
+ trusted = tuple(_TRUSTED_SERVICE_URL_SUFFIXES) + tuple(
+ str(host).lower() for host in extra_trusted_hosts if str(host).strip()
+ )
+ return any(hostname == suffix.lstrip(".") or hostname.endswith(suffix) for suffix in trusted)
+
+
+class MsTeamsAdapter(BasePlatformAdapter):
+ """Microsoft Teams text adapter backed by Bot Framework activities."""
+
+ MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.MSTEAMS)
+ extra = dict(config.extra or {})
+
+ self._app_id = str(extra.get("app_id") or config.token or "").strip()
+ self._app_password = str(extra.get("app_password") or config.api_key or "")
+ self._tenant_id = str(extra.get("tenant_id") or "botframework.com").strip()
+ self._bot_display_name = str(extra.get("bot_display_name") or "").strip()
+
+ self._host = str(extra.get("host") or DEFAULT_HOST)
+ self._port = int(extra.get("port") or DEFAULT_PORT)
+ self._path = str(extra.get("path") or DEFAULT_PATH)
+ if not self._path.startswith("/"):
+ self._path = f"/{self._path}"
+ self._max_body_bytes = int(extra.get("max_body_bytes") or DEFAULT_MAX_BODY_BYTES)
+
+ self._require_mention = self._config_bool("require_mention", True)
+ self._free_response_conversations = self._load_free_response_conversations()
+ self._mention_patterns = self._compile_mention_patterns()
+ self._trusted_service_url_hosts = _coerce_str_list(
+ extra.get("trusted_service_url_hosts")
+ )
+ self._allow_untrusted_service_urls = _coerce_bool(
+ extra.get("allow_untrusted_service_urls"), False
+ )
+ self._insecure_skip_auth = _coerce_bool(extra.get("insecure_skip_auth"), False)
+
+ self._http_client: Optional[httpx.AsyncClient] = None
+ self._token_provider: Optional[BotFrameworkTokenProvider] = None
+ self._jwt_validator: Optional[BotFrameworkJWTValidator] = None
+ self._runner = None
+ self._site = None
+ self._service_urls: dict[str, str] = {}
+ self._save_lock = asyncio.Lock()
+
+ @property
+ def name(self) -> str:
+ return "msteams"
+
+ def _config_bool(self, key: str, default: bool) -> bool:
+ if key in self.config.extra:
+ return _coerce_bool(self.config.extra.get(key), default)
+ env_key = f"MSTEAMS_{key.upper()}"
+ import os
+
+ return _coerce_bool(os.getenv(env_key), default)
+
+ def _load_free_response_conversations(self) -> set[str]:
+ import os
+
+ raw = (
+ self.config.extra.get("free_response_conversations")
+ or self.config.extra.get("free_response_channels")
+ or self.config.extra.get("free_response_chats")
+ or os.getenv("MSTEAMS_FREE_RESPONSE_CONVERSATIONS")
+ or os.getenv("MSTEAMS_FREE_RESPONSE_CHANNELS")
+ or ""
+ )
+ return set(_coerce_str_list(raw))
+
+ def _compile_mention_patterns(self) -> list[re.Pattern]:
+ import os
+
+ patterns = self.config.extra.get("mention_patterns")
+ if patterns is None:
+ raw = os.getenv("MSTEAMS_MENTION_PATTERNS", "").strip()
+ if raw:
+ try:
+ patterns = json.loads(raw)
+ except Exception:
+ patterns = [part.strip() for part in raw.splitlines() if part.strip()]
+ if not patterns:
+ patterns = [part.strip() for part in raw.split(",") if part.strip()]
+ if patterns is None:
+ return []
+ if isinstance(patterns, str):
+ patterns = [patterns]
+ if not isinstance(patterns, list):
+ logger.warning(
+ "[msteams] mention_patterns must be a list or string; got %s",
+ type(patterns).__name__,
+ )
+ return []
+
+ compiled: list[re.Pattern] = []
+ for pattern in patterns:
+ if not isinstance(pattern, str) or not pattern.strip():
+ continue
+ try:
+ compiled.append(re.compile(pattern, re.IGNORECASE))
+ except re.error as exc:
+ logger.warning("[msteams] invalid mention pattern %r: %s", pattern, exc)
+ return compiled
+
+ async def connect(self) -> bool:
+ if not AIOHTTP_AVAILABLE:
+ self._set_fatal_error("msteams_aiohttp", "aiohttp is required for Microsoft Teams", retryable=False)
+ return False
+ if not self._app_id:
+ self._set_fatal_error("msteams_config", "MSTEAMS_APP_ID is required", retryable=False)
+ return False
+ if not self._app_password:
+ self._set_fatal_error("msteams_config", "MSTEAMS_APP_PASSWORD is required", retryable=False)
+ return False
+
+ self._http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True, trust_env=True)
+ try:
+ self._token_provider = BotFrameworkTokenProvider(
+ app_id=self._app_id,
+ app_password=self._app_password,
+ tenant_id=self._tenant_id,
+ http_client=self._http_client,
+ )
+ self._jwt_validator = BotFrameworkJWTValidator(
+ self._app_id,
+ self._http_client,
+ cache_ttl_seconds=int(
+ self.config.extra.get("auth_cache_ttl_seconds")
+ or self.config.extra.get("auth_cache_ttl")
+ or 3600
+ ),
+ )
+ except AuthError as exc:
+ self._set_fatal_error("msteams_auth", str(exc), retryable=False)
+ await self._close_http_client()
+ return False
+
+ if not self._acquire_platform_lock(
+ "msteams-endpoint",
+ f"{self._host}:{self._port}",
+ f"Microsoft Teams endpoint {self._host}:{self._port}",
+ ):
+ await self._close_http_client()
+ return False
+
+ app = web.Application(client_max_size=self._max_body_bytes)
+ app.router.add_get("/health", self._handle_health)
+ app.router.add_post(self._path, self._handle_activity)
+
+ self._runner = web.AppRunner(app)
+ await self._runner.setup()
+ self._site = web.TCPSite(self._runner, self._host, self._port)
+ try:
+ await self._site.start()
+ except OSError as exc:
+ self._set_fatal_error(
+ "msteams_bind",
+ f"Cannot bind {self._host}:{self._port}: {exc}",
+ retryable=False,
+ )
+ await self.disconnect()
+ return False
+
+ self._mark_connected()
+ logger.info("[msteams] listening on %s:%d%s", self._host, self._port, self._path)
+ return True
+
+ async def disconnect(self) -> None:
+ if self._site is not None:
+ with contextlib.suppress(Exception):
+ await self._site.stop()
+ self._site = None
+ if self._runner is not None:
+ with contextlib.suppress(Exception):
+ await self._runner.cleanup()
+ self._runner = None
+ await self._close_http_client()
+ self._release_platform_lock()
+ self._mark_disconnected()
+
+ async def _close_http_client(self) -> None:
+ if self._http_client is not None:
+ with contextlib.suppress(Exception):
+ await self._http_client.aclose()
+ self._http_client = None
+
+ async def _handle_health(self, request: "web.Request") -> "web.Response":
+ return web.json_response(
+ {
+ "status": "ok" if self._running else "starting",
+ "platform": "msteams",
+ "path": self._path,
+ }
+ )
+
+ async def _handle_activity(self, request: "web.Request") -> "web.Response":
+ content_length = request.content_length or 0
+ if content_length > self._max_body_bytes:
+ return web.json_response({"error": "Payload too large"}, status=413)
+
+ try:
+ raw = await request.read()
+ body = json.loads(raw.decode("utf-8") or "{}")
+ except json.JSONDecodeError:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+ except Exception:
+ logger.warning("[msteams] failed to read request body", exc_info=True)
+ return web.json_response({"error": "Bad request"}, status=400)
+
+ if not isinstance(body, dict):
+ return web.json_response({"error": "Activity must be a JSON object"}, status=400)
+
+ service_url = str(body.get("serviceUrl") or "")
+ if service_url and not self._service_url_is_trusted(service_url):
+ return web.json_response({"error": "Untrusted serviceUrl"}, status=400)
+
+ if not self._insecure_skip_auth:
+ if self._jwt_validator is None:
+ return web.json_response({"error": "Auth not initialized"}, status=500)
+ auth_header = request.headers.get("Authorization", "")
+ ok = await self._jwt_validator.validate_authorization_header(
+ auth_header,
+ service_url=service_url or None,
+ )
+ if not ok:
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ activity_type = str(body.get("type") or "").lower()
+ if activity_type != "message":
+ return web.json_response({"status": "ignored", "type": activity_type or "unknown"})
+
+ chat_id = str((body.get("conversation") or {}).get("id") or "")
+ if chat_id and service_url:
+ await self._remember_service_url(chat_id, service_url)
+
+ event = self._build_event(body)
+ if event is None:
+ return web.json_response({"status": "ignored"})
+
+ try:
+ await self.handle_message(event)
+ except Exception:
+ logger.exception("[msteams] handle_message failed")
+ return web.json_response({"error": "Dispatch failed"}, status=500)
+
+ return web.json_response({"status": "accepted"})
+
+ def _service_url_is_trusted(self, service_url: str) -> bool:
+ return _is_trusted_service_url(
+ service_url,
+ extra_trusted_hosts=self._trusted_service_url_hosts,
+ allow_untrusted=self._allow_untrusted_service_urls or self._insecure_skip_auth,
+ )
+
+ async def _remember_service_url(self, chat_id: str, service_url: str) -> None:
+ if not chat_id or not service_url:
+ return
+ if not self._service_url_is_trusted(service_url):
+ logger.warning("[msteams] refusing to store untrusted serviceUrl for %s", chat_id)
+ return
+ if self._service_urls.get(chat_id) == service_url:
+ return
+ async with self._save_lock:
+ self._service_urls[chat_id] = service_url
+
+ def _build_event(self, activity: dict[str, Any]) -> Optional[MessageEvent]:
+ conversation = activity.get("conversation") or {}
+ sender = activity.get("from") or activity.get("from_property") or {}
+ recipient = activity.get("recipient") or {}
+ if not isinstance(conversation, dict) or not isinstance(sender, dict):
+ return None
+
+ chat_id = str(_get_case_insensitive(conversation, "id") or "")
+ if not chat_id:
+ return None
+ raw_conversation_type = str(
+ _get_case_insensitive(conversation, "conversationType", "conversation_type")
+ or "personal"
+ )
+ chat_type = self._conversation_type_to_chat_type(raw_conversation_type)
+
+ sender_id = str(
+ _get_case_insensitive(sender, "aadObjectId", "aad_object_id")
+ or _get_case_insensitive(sender, "id")
+ or ""
+ )
+ sender_name = str(_get_case_insensitive(sender, "name") or "") or None
+ if self._is_self_message(sender, recipient):
+ return None
+
+ channel_data = activity.get("channelData") or activity.get("channel_data") or {}
+ if not isinstance(channel_data, dict):
+ channel_data = {}
+ team = channel_data.get("team") if isinstance(channel_data.get("team"), dict) else {}
+ channel = (
+ channel_data.get("channel")
+ if isinstance(channel_data.get("channel"), dict)
+ else {}
+ )
+ tenant = (
+ channel_data.get("tenant")
+ if isinstance(channel_data.get("tenant"), dict)
+ else {}
+ )
+ team_id = str(team.get("id") or "") or None
+ channel_id = str(channel.get("id") or "") or None
+ tenant_id = str(tenant.get("id") or conversation.get("tenantId") or "") or None
+
+ raw_text = str(activity.get("text") or "")
+ bot_ids, bot_names = self._bot_identity(activity)
+ cleaned_text, mentioned = strip_bot_mention(
+ raw_text,
+ bot_ids=bot_ids,
+ bot_names=bot_names,
+ entities=activity.get("entities") or [],
+ )
+
+ if not self._should_dispatch(
+ chat_type=chat_type,
+ chat_id=chat_id,
+ channel_id=channel_id,
+ team_id=team_id,
+ text=cleaned_text,
+ mentioned=mentioned,
+ ):
+ return None
+ if not cleaned_text:
+ return None
+
+ chat_name = (
+ str(channel.get("name") or "")
+ or str(conversation.get("name") or "")
+ or str(team.get("name") or "")
+ or None
+ )
+ message_id = str(activity.get("id") or "")
+ thread_id = channel_id if chat_type == "channel" and channel_id else None
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=chat_name,
+ chat_type=chat_type,
+ user_id=sender_id or None,
+ user_name=sender_name,
+ thread_id=thread_id,
+ chat_id_alt=team_id,
+ guild_id=team_id or tenant_id,
+ parent_chat_id=team_id,
+ message_id=message_id or None,
+ )
+
+ return MessageEvent(
+ text=cleaned_text,
+ message_type=MessageType.TEXT,
+ source=source,
+ raw_message=activity,
+ message_id=message_id or None,
+ reply_to_message_id=str(activity.get("replyToId") or "") or None,
+ )
+
+ @staticmethod
+ def _conversation_type_to_chat_type(value: str) -> str:
+ normalized = str(value or "").lower()
+ if normalized == "personal":
+ return "dm"
+ if normalized == "groupchat":
+ return "group"
+ if normalized == "channel":
+ return "channel"
+ return "dm"
+
+ def _bot_identity(self, activity: dict[str, Any]) -> tuple[set[str], set[str]]:
+ recipient = activity.get("recipient") or {}
+ bot_ids = {self._app_id, f"28:{self._app_id}" if self._app_id else ""}
+ bot_names = {self._bot_display_name}
+ if isinstance(recipient, dict):
+ bot_ids.add(str(_get_case_insensitive(recipient, "id") or ""))
+ bot_names.add(str(_get_case_insensitive(recipient, "name") or ""))
+ return ({value for value in bot_ids if value}, {value for value in bot_names if value})
+
+ def _is_self_message(self, sender: dict[str, Any], recipient: dict[str, Any]) -> bool:
+ sender_id = str(_get_case_insensitive(sender, "id") or "")
+ recipient_id = str(_get_case_insensitive(recipient, "id") or "")
+ if sender_id and recipient_id and sender_id == recipient_id:
+ return True
+ return bool(
+ self._app_id
+ and sender_id
+ and sender_id.lower() in {self._app_id.lower(), f"28:{self._app_id}".lower()}
+ )
+
+ def _should_dispatch(
+ self,
+ *,
+ chat_type: str,
+ chat_id: str,
+ channel_id: str | None,
+ team_id: str | None,
+ text: str,
+ mentioned: bool,
+ ) -> bool:
+ if chat_type == "dm":
+ return True
+ free_response_ids = {chat_id}
+ if channel_id:
+ free_response_ids.add(channel_id)
+ if team_id:
+ free_response_ids.add(team_id)
+ if free_response_ids & self._free_response_conversations:
+ return True
+ if not self._require_mention:
+ return True
+ if mentioned:
+ return True
+ if text and any(pattern.search(text) for pattern in self._mention_patterns):
+ return True
+ return False
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ chunks = self.truncate_message(content, self.MAX_MESSAGE_LENGTH)
+ last_result = SendResult(success=True)
+ for index, chunk in enumerate(chunks):
+ payload: dict[str, Any] = {"type": "message", "text": chunk}
+ if reply_to and index == 0:
+ payload["replyToId"] = reply_to
+ last_result = await self._post_activity(chat_id, payload, metadata=metadata)
+ if not last_result.success:
+ return last_result
+ return last_result
+
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
+ await self._post_activity(chat_id, {"type": "typing"}, metadata=metadata)
+
+ async def _post_activity(
+ self,
+ chat_id: str,
+ payload: dict[str, Any],
+ *,
+ metadata: Optional[dict[str, Any]] = None,
+ ) -> SendResult:
+ service_url = (
+ (metadata or {}).get("service_url")
+ or self._service_urls.get(str(chat_id))
+ )
+ if not service_url:
+ return SendResult(
+ success=False,
+ error="unknown Teams serviceUrl for conversation",
+ retryable=False,
+ )
+ if not self._service_url_is_trusted(str(service_url)):
+ return SendResult(
+ success=False,
+ error="refusing to send to untrusted Teams serviceUrl",
+ retryable=False,
+ )
+ if self._token_provider is None:
+ return SendResult(
+ success=False,
+ error="Bot Framework token provider is not initialized",
+ retryable=False,
+ )
+ if self._http_client is None:
+ return SendResult(
+ success=False,
+ error="HTTP client is not initialized",
+ retryable=True,
+ )
+
+ try:
+ token = await self._token_provider.get_token(BOT_FRAMEWORK_SCOPE)
+ response = await self._http_client.post(
+ _activities_url(str(service_url), str(chat_id)),
+ json=payload,
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ },
+ )
+ except AuthError as exc:
+ return SendResult(success=False, error=str(exc), retryable=False)
+ except httpx.HTTPError as exc:
+ return SendResult(success=False, error=str(exc), retryable=True)
+
+ try:
+ data = response.json()
+ except Exception:
+ data = {}
+ if 200 <= response.status_code < 300:
+ message_id = None
+ if isinstance(data, dict):
+ message_id = str(data.get("id") or "") or None
+ return SendResult(success=True, message_id=message_id, raw_response=data)
+
+ error_text = ""
+ try:
+ error_text = response.text[:500]
+ except Exception:
+ error_text = f"HTTP {response.status_code}"
+ return SendResult(
+ success=False,
+ error=f"Bot Framework send failed ({response.status_code}): {error_text}",
+ raw_response=data,
+ retryable=response.status_code in {408, 429} or response.status_code >= 500,
+ )
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ return {"name": str(chat_id), "type": "msteams", "chat_id": str(chat_id)}
diff --git a/gateway/platforms/msteams/auth.py b/gateway/platforms/msteams/auth.py
new file mode 100644
index 000000000000..78bea2dbf5fe
--- /dev/null
+++ b/gateway/platforms/msteams/auth.py
@@ -0,0 +1,239 @@
+"""Bot Framework auth helpers for the Microsoft Teams adapter.
+
+This first Teams slice intentionally keeps auth small and dependency-light:
+inbound activities are validated against Microsoft's Bot Framework OpenID
+metadata with PyJWT, and outbound replies use the OAuth2 client-credentials
+flow through httpx.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from typing import Any, Optional
+
+import httpx
+import jwt
+
+logger = logging.getLogger(__name__)
+
+BOT_FRAMEWORK_SCOPE = "https://api.botframework.com/.default"
+BOT_FRAMEWORK_OPENID_METADATA_URL = (
+ "https://login.botframework.com/v1/.well-known/openidconfiguration"
+)
+BOT_FRAMEWORK_VALID_ISSUERS = (
+ "https://api.botframework.com",
+ "https://api.botframework.com/",
+)
+DEFAULT_AUTH_CACHE_TTL_SECONDS = 3600
+_TOKEN_REFRESH_LEEWAY_SECONDS = 300
+
+
+class AuthError(Exception):
+ """Raised when Bot Framework auth setup or token acquisition fails."""
+
+
+class BotFrameworkJWTValidator:
+ """Validate Bot Framework bearer tokens on inbound activities."""
+
+ def __init__(
+ self,
+ app_id: str,
+ http_client: httpx.AsyncClient,
+ *,
+ metadata_url: str = BOT_FRAMEWORK_OPENID_METADATA_URL,
+ cache_ttl_seconds: int = DEFAULT_AUTH_CACHE_TTL_SECONDS,
+ ):
+ self._app_id = str(app_id or "").strip()
+ self._http_client = http_client
+ self._metadata_url = metadata_url
+ self._cache_ttl_seconds = max(300, int(cache_ttl_seconds))
+ self._openid_config: Optional[dict[str, Any]] = None
+ self._openid_config_expiry = 0.0
+ self._jwks: Optional[dict[str, Any]] = None
+ self._jwks_expiry = 0.0
+ self._lock = asyncio.Lock()
+
+ async def validate_authorization_header(
+ self,
+ authorization: str,
+ *,
+ service_url: str | None = None,
+ ) -> bool:
+ """Return True when *authorization* contains a valid Bot Framework JWT."""
+ scheme, _, token = str(authorization or "").partition(" ")
+ if scheme.lower() != "bearer" or not token.strip():
+ return False
+ try:
+ await self.validate(token.strip(), service_url=service_url)
+ return True
+ except Exception as exc:
+ logger.warning("msteams: Bot Framework JWT validation failed: %s", exc)
+ return False
+
+ async def validate(
+ self,
+ token: str,
+ *,
+ service_url: str | None = None,
+ ) -> dict[str, Any]:
+ if not self._app_id:
+ raise AuthError("MSTEAMS_APP_ID is required for JWT validation")
+
+ header = jwt.get_unverified_header(token)
+ algorithm = str(header.get("alg") or "")
+ if algorithm != "RS256":
+ raise jwt.InvalidAlgorithmError(f"unsupported JWT alg: {algorithm!r}")
+
+ kid = str(header.get("kid") or "").strip()
+ jwks = await self._get_jwks()
+ signing_key = self._resolve_signing_key(kid, jwks)
+ payload = jwt.decode(
+ token,
+ signing_key,
+ algorithms=["RS256"],
+ audience=self._app_id,
+ issuer=BOT_FRAMEWORK_VALID_ISSUERS,
+ options={"require": ["exp", "iss", "aud"]},
+ )
+
+ if service_url:
+ token_service_url = str(
+ payload.get("serviceurl") or payload.get("serviceUrl") or ""
+ ).strip()
+ if (
+ token_service_url
+ and token_service_url.rstrip("/") != service_url.rstrip("/")
+ ):
+ raise jwt.InvalidTokenError("Bot Framework token serviceUrl mismatch")
+
+ return payload
+
+ async def _get_openid_config(self) -> dict[str, Any]:
+ now = time.time()
+ async with self._lock:
+ if self._openid_config and now < self._openid_config_expiry:
+ return self._openid_config
+
+ response = await self._http_client.get(self._metadata_url)
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, dict):
+ raise AuthError("Bot Framework OpenID metadata was not a JSON object")
+ self._openid_config = payload
+ self._openid_config_expiry = now + self._cache_ttl_seconds
+ return payload
+
+ async def _get_jwks(self) -> dict[str, Any]:
+ now = time.time()
+ async with self._lock:
+ if self._jwks and now < self._jwks_expiry:
+ return self._jwks
+
+ openid_config = await self._get_openid_config()
+ jwks_uri = str(openid_config.get("jwks_uri") or "").strip()
+ if not jwks_uri:
+ raise AuthError("Bot Framework OpenID metadata missing jwks_uri")
+
+ async with self._lock:
+ now = time.time()
+ if self._jwks and now < self._jwks_expiry:
+ return self._jwks
+
+ response = await self._http_client.get(jwks_uri)
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, dict) or not isinstance(payload.get("keys"), list):
+ raise AuthError("Bot Framework JWKS payload missing keys")
+ self._jwks = payload
+ self._jwks_expiry = now + self._cache_ttl_seconds
+ return payload
+
+ @staticmethod
+ def _resolve_signing_key(kid: str, jwks: dict[str, Any]) -> Any:
+ keys = jwks.get("keys") or []
+ for key_payload in keys:
+ if not isinstance(key_payload, dict):
+ continue
+ if kid and str(key_payload.get("kid") or "") != kid:
+ continue
+ return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_payload))
+ raise AuthError(f"Bot Framework signing key not found for kid={kid!r}")
+
+
+class BotFrameworkTokenProvider:
+ """Acquire Bot Framework access tokens for outbound activity posts."""
+
+ def __init__(
+ self,
+ *,
+ app_id: str,
+ app_password: str,
+ tenant_id: str = "botframework.com",
+ http_client: httpx.AsyncClient,
+ authority_host: str = "https://login.microsoftonline.com",
+ ):
+ self.app_id = str(app_id or "").strip()
+ self.tenant_id = str(tenant_id or "botframework.com").strip()
+ self._app_password = str(app_password or "")
+ self._http_client = http_client
+ self._authority_host = authority_host.rstrip("/")
+ self._cache: dict[str, tuple[str, float]] = {}
+ self._locks: dict[str, asyncio.Lock] = {}
+
+ if not self.app_id:
+ raise AuthError("MSTEAMS_APP_ID is required")
+ if not self._app_password:
+ raise AuthError("MSTEAMS_APP_PASSWORD is required")
+
+ def _lock_for(self, scope: str) -> asyncio.Lock:
+ lock = self._locks.get(scope)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._locks[scope] = lock
+ return lock
+
+ async def get_token(self, scope: str = BOT_FRAMEWORK_SCOPE) -> str:
+ now = time.time()
+ cached = self._cache.get(scope)
+ if cached and cached[1] - _TOKEN_REFRESH_LEEWAY_SECONDS > now:
+ return cached[0]
+
+ async with self._lock_for(scope):
+ cached = self._cache.get(scope)
+ now = time.time()
+ if cached and cached[1] - _TOKEN_REFRESH_LEEWAY_SECONDS > now:
+ return cached[0]
+
+ token_url = (
+ f"{self._authority_host}/{self.tenant_id}/oauth2/v2.0/token"
+ )
+ response = await self._http_client.post(
+ token_url,
+ data={
+ "grant_type": "client_credentials",
+ "client_id": self.app_id,
+ "client_secret": self._app_password,
+ "scope": scope,
+ },
+ )
+ try:
+ payload = response.json()
+ except Exception:
+ payload = {}
+ if response.status_code >= 400 or "access_token" not in payload:
+ detail = (
+ payload.get("error_description")
+ or payload.get("error")
+ or getattr(response, "text", "")
+ or f"HTTP {response.status_code}"
+ )
+ raise AuthError(f"Bot Framework token acquisition failed: {detail}")
+
+ expires_in = int(payload.get("expires_in") or 3600)
+ expires_at = time.time() + expires_in
+ access_token = str(payload["access_token"])
+ self._cache[scope] = (access_token, expires_at)
+ return access_token
diff --git a/gateway/run.py b/gateway/run.py
index b50bbc5851ff..0142c76524a7 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -3154,6 +3154,13 @@ def _create_adapter(
adapter.gateway_runner = self # For cross-platform delivery
return adapter
+ elif platform == Platform.MSTEAMS:
+ from gateway.platforms.msteams import MsTeamsAdapter, check_msteams_requirements
+ if not check_msteams_requirements():
+ logger.warning("Microsoft Teams: aiohttp not installed")
+ return None
+ return MsTeamsAdapter(config)
+
elif platform == Platform.BLUEBUBBLES:
from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements
if not check_bluebubbles_requirements():
@@ -3214,12 +3221,14 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
+ Platform.MSTEAMS: "MSTEAMS_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
Platform.YUANBAO: "YUANBAO_ALLOWED_USERS",
}
platform_group_env_map = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS",
+ Platform.MSTEAMS: "MSTEAMS_GROUP_ALLOWED_USERS",
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
}
platform_allow_all_map = {
@@ -3237,6 +3246,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
+ Platform.MSTEAMS: "MSTEAMS_ALLOW_ALL_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
@@ -3369,6 +3379,7 @@ def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
+ Platform.MSTEAMS: "MSTEAMS_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
}
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 49ee1e4730d3..7a32cc4c4501 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -38,6 +38,10 @@
"DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL",
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
+ "MSTEAMS_BOT_DISPLAY_NAME", "MSTEAMS_ALLOW_ALL_USERS",
+ "MSTEAMS_GROUP_ALLOWED_USERS", "MSTEAMS_HOME_CHANNEL", "MSTEAMS_HOME_CHANNEL_NAME",
+ "MSTEAMS_HOST", "MSTEAMS_PORT", "MSTEAMS_PATH", "MSTEAMS_REQUIRE_MENTION",
+ "MSTEAMS_MENTION_PATTERNS", "MSTEAMS_FREE_RESPONSE_CONVERSATIONS",
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
"WECOM_BOT_ID", "WECOM_SECRET",
@@ -1718,6 +1722,33 @@ def _ensure_hermes_home_managed(home: Path):
"password": False,
"category": "messaging",
},
+ "MSTEAMS_APP_ID": {
+ "description": "Microsoft Teams Bot Framework app (client) ID",
+ "prompt": "Microsoft Teams app ID",
+ "url": "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "password": False,
+ "category": "messaging",
+ },
+ "MSTEAMS_APP_PASSWORD": {
+ "description": "Microsoft Teams Bot Framework app client secret",
+ "prompt": "Microsoft Teams app password",
+ "url": "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
+ "password": True,
+ "category": "messaging",
+ },
+ "MSTEAMS_TENANT_ID": {
+ "description": "Microsoft Entra tenant ID for the Teams bot. Defaults to botframework.com when unset.",
+ "prompt": "Microsoft Teams tenant ID (optional)",
+ "password": False,
+ "category": "messaging",
+ "advanced": True,
+ },
+ "MSTEAMS_ALLOWED_USERS": {
+ "description": "Comma-separated Microsoft Teams AAD object IDs allowed to use the bot",
+ "prompt": "Allowed Microsoft Teams user IDs",
+ "password": False,
+ "category": "messaging",
+ },
"SLACK_BOT_TOKEN": {
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
diff --git a/hermes_cli/platforms.py b/hermes_cli/platforms.py
index bc609277c464..34644f2b4e0b 100644
--- a/hermes_cli/platforms.py
+++ b/hermes_cli/platforms.py
@@ -31,6 +31,7 @@ class PlatformInfo(NamedTuple):
("mattermost", PlatformInfo(label="💬 Mattermost", default_toolset="hermes-mattermost")),
("matrix", PlatformInfo(label="💬 Matrix", default_toolset="hermes-matrix")),
("dingtalk", PlatformInfo(label="💬 DingTalk", default_toolset="hermes-dingtalk")),
+ ("msteams", PlatformInfo(label="💬 Microsoft Teams", default_toolset="hermes-msteams")),
("feishu", PlatformInfo(label="🪽 Feishu", default_toolset="hermes-feishu")),
("wecom", PlatformInfo(label="💬 WeCom", default_toolset="hermes-wecom")),
("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")),
diff --git a/hermes_cli/status.py b/hermes_cli/status.py
index 028575268187..1c44e1eadf17 100644
--- a/hermes_cli/status.py
+++ b/hermes_cli/status.py
@@ -321,6 +321,7 @@ def show_status(args):
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
"DingTalk": ("DINGTALK_CLIENT_ID", None),
+ "Microsoft Teams": ("MSTEAMS_APP_ID", "MSTEAMS_HOME_CHANNEL"),
"Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"),
"WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 0423cf01b3e0..bae0728a47c3 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -581,6 +581,8 @@ def _get_enabled_platforms() -> List[str]:
enabled.append("slack")
if get_env_value("WHATSAPP_ENABLED"):
enabled.append("whatsapp")
+ if get_env_value("MSTEAMS_APP_ID"):
+ enabled.append("msteams")
if get_env_value("QQ_APP_ID"):
enabled.append("qqbot")
return enabled
diff --git a/tests/gateway/test_msteams_adapter.py b/tests/gateway/test_msteams_adapter.py
new file mode 100644
index 000000000000..a203c1ba6e5c
--- /dev/null
+++ b/tests/gateway/test_msteams_adapter.py
@@ -0,0 +1,257 @@
+"""Text-only Microsoft Teams adapter tests."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import MessageType
+from gateway.platforms.msteams import MsTeamsAdapter, _activities_url, strip_bot_mention
+
+
+def _config(**extra):
+ base = {
+ "app_id": "app-id",
+ "app_password": "secret",
+ "bot_display_name": "Hermes",
+ }
+ base.update(extra)
+ return PlatformConfig(enabled=True, extra=base)
+
+
+def _activity(**overrides):
+ conversation_type = overrides.get("conversation_type", "personal")
+ activity = {
+ "type": "message",
+ "id": overrides.get("id", "act-1"),
+ "serviceUrl": overrides.get("service_url", "https://smba.trafficmanager.net/amer/"),
+ "channelId": "msteams",
+ "conversation": {
+ "id": overrides.get("conversation_id", "a:dm-conversation"),
+ "conversationType": conversation_type,
+ "name": overrides.get("conversation_name", "Chat"),
+ },
+ "from": {
+ "id": overrides.get("from_id", "29:user"),
+ "aadObjectId": overrides.get("aad_id", "aad-user"),
+ "name": overrides.get("from_name", "Alice"),
+ },
+ "recipient": {
+ "id": overrides.get("recipient_id", "28:app-id"),
+ "name": overrides.get("recipient_name", "Hermes"),
+ },
+ "text": overrides.get("text", "hello"),
+ "entities": overrides.get("entities", []),
+ "channelData": overrides.get("channel_data", {}),
+ }
+ if "reply_to" in overrides:
+ activity["replyToId"] = overrides["reply_to"]
+ return activity
+
+
+def test_strip_bot_mention_from_at_tag():
+ cleaned, mentioned = strip_bot_mention(
+ "Hermes please summarize this",
+ bot_ids={"28:app-id"},
+ bot_names={"Hermes"},
+ )
+ assert mentioned is True
+ assert cleaned == "please summarize this"
+
+
+def test_build_event_dm_normalizes_source_without_mention():
+ adapter = MsTeamsAdapter(_config())
+ event = adapter._build_event(_activity(text="hello from a DM"))
+
+ assert event is not None
+ assert event.text == "hello from a DM"
+ assert event.message_type == MessageType.TEXT
+ assert event.source.platform is Platform.MSTEAMS
+ assert event.source.chat_type == "dm"
+ assert event.source.chat_id == "a:dm-conversation"
+ assert event.source.user_id == "aad-user"
+ assert event.source.user_name == "Alice"
+ assert event.message_id == "act-1"
+
+
+def test_channel_requires_mention_by_default():
+ adapter = MsTeamsAdapter(_config())
+ activity = _activity(
+ conversation_type="channel",
+ conversation_id="19:channel@thread.tacv2",
+ text="hello without mention",
+ channel_data={
+ "team": {"id": "team-1", "name": "Engineering"},
+ "channel": {"id": "19:channel@thread.tacv2", "name": "General"},
+ "tenant": {"id": "tenant-1"},
+ },
+ )
+
+ assert adapter._build_event(activity) is None
+
+
+def test_channel_mention_dispatches_and_normalizes_channel_source():
+ adapter = MsTeamsAdapter(_config())
+ activity = _activity(
+ conversation_type="channel",
+ conversation_id="19:channel@thread.tacv2",
+ text="Hermes please help",
+ channel_data={
+ "team": {"id": "team-1", "name": "Engineering"},
+ "channel": {"id": "19:channel@thread.tacv2", "name": "General"},
+ "tenant": {"id": "tenant-1"},
+ },
+ )
+
+ event = adapter._build_event(activity)
+
+ assert event is not None
+ assert event.text == "please help"
+ assert event.source.chat_type == "channel"
+ assert event.source.chat_id == "19:channel@thread.tacv2"
+ assert event.source.thread_id == "19:channel@thread.tacv2"
+ assert event.source.chat_id_alt == "team-1"
+ assert event.source.guild_id == "team-1"
+ assert event.source.parent_chat_id == "team-1"
+
+
+def test_group_chat_can_use_mention_patterns():
+ adapter = MsTeamsAdapter(_config(mention_patterns=[r"^hermes[:, ]"]))
+ activity = _activity(
+ conversation_type="groupChat",
+ conversation_id="19:groupchat@unq.gbl.spaces",
+ text="Hermes, status please",
+ )
+
+ event = adapter._build_event(activity)
+
+ assert event is not None
+ assert event.source.chat_type == "group"
+ assert event.text == "Hermes, status please"
+
+
+def test_free_response_conversation_bypasses_mention_gate():
+ adapter = MsTeamsAdapter(
+ _config(free_response_conversations=["19:channel@thread.tacv2"])
+ )
+ activity = _activity(
+ conversation_type="channel",
+ conversation_id="19:channel@thread.tacv2",
+ text="no mention needed here",
+ channel_data={
+ "team": {"id": "team-1"},
+ "channel": {"id": "19:channel@thread.tacv2"},
+ },
+ )
+
+ event = adapter._build_event(activity)
+
+ assert event is not None
+ assert event.text == "no mention needed here"
+
+
+def test_activities_url_includes_v3_and_encodes_conversation_id():
+ assert _activities_url(
+ "https://smba.trafficmanager.net/amer/",
+ "19:abc@thread.tacv2",
+ ) == (
+ "https://smba.trafficmanager.net/amer/v3/conversations/"
+ "19%3Aabc%40thread.tacv2/activities"
+ )
+
+
+class _FakeResponse:
+ def __init__(self, status_code=201, payload=None, text=""):
+ self.status_code = status_code
+ self._payload = payload if payload is not None else {"id": "reply-1"}
+ self.text = text
+
+ def json(self):
+ return self._payload
+
+
+class _FakeHTTPClient:
+ def __init__(self):
+ self.posts = []
+
+ async def post(self, url, **kwargs):
+ self.posts.append((url, kwargs))
+ return _FakeResponse()
+
+
+class _FakeTokenProvider:
+ async def get_token(self, scope):
+ return "bf-token"
+
+
+@pytest.mark.asyncio
+async def test_send_posts_text_payload_to_bot_framework_endpoint():
+ adapter = MsTeamsAdapter(_config())
+ adapter._service_urls["19:abc@thread.tacv2"] = "https://smba.trafficmanager.net/amer/"
+ adapter._token_provider = _FakeTokenProvider()
+ adapter._http_client = _FakeHTTPClient()
+
+ result = await adapter.send(
+ "19:abc@thread.tacv2",
+ "hello teams",
+ reply_to="activity-1",
+ )
+
+ assert result.success is True
+ assert result.message_id == "reply-1"
+ url, kwargs = adapter._http_client.posts[0]
+ assert url.endswith("/v3/conversations/19%3Aabc%40thread.tacv2/activities")
+ assert kwargs["json"] == {
+ "type": "message",
+ "text": "hello teams",
+ "replyToId": "activity-1",
+ }
+ assert kwargs["headers"]["Authorization"] == "Bearer bf-token"
+
+
+class _FakeRequest:
+ def __init__(self, body, *, authorization="Bearer token"):
+ self._body = json.dumps(body).encode("utf-8")
+ self.headers = {"Authorization": authorization}
+ self.content_length = len(self._body)
+
+ async def read(self):
+ return self._body
+
+
+@pytest.mark.asyncio
+async def test_webhook_rejects_invalid_auth_before_dispatch():
+ adapter = MsTeamsAdapter(_config())
+ adapter._jwt_validator = type(
+ "Validator",
+ (),
+ {"validate_authorization_header": AsyncMock(return_value=False)},
+ )()
+ adapter.handle_message = AsyncMock()
+
+ response = await adapter._handle_activity(_FakeRequest(_activity()))
+
+ assert response.status == 401
+ adapter.handle_message.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_webhook_accepts_valid_text_activity_and_dispatches():
+ adapter = MsTeamsAdapter(_config())
+ adapter._jwt_validator = type(
+ "Validator",
+ (),
+ {"validate_authorization_header": AsyncMock(return_value=True)},
+ )()
+ adapter.handle_message = AsyncMock()
+
+ response = await adapter._handle_activity(_FakeRequest(_activity(text="hello")))
+
+ assert response.status == 200
+ adapter.handle_message.assert_awaited_once()
+ event = adapter.handle_message.await_args.args[0]
+ assert event.text == "hello"
+ assert event.source.chat_type == "dm"
diff --git a/tests/gateway/test_msteams_auth.py b/tests/gateway/test_msteams_auth.py
new file mode 100644
index 000000000000..0c570ffbfd0b
--- /dev/null
+++ b/tests/gateway/test_msteams_auth.py
@@ -0,0 +1,152 @@
+"""Bot Framework auth tests for the Microsoft Teams adapter."""
+
+from __future__ import annotations
+
+import json
+import time
+
+import jwt
+import pytest
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+from gateway.platforms.msteams import auth
+
+
+class _FakeResponse:
+ def __init__(self, payload, status_code=200):
+ self._payload = payload
+ self.status_code = status_code
+ self.text = json.dumps(payload)
+
+ def json(self):
+ return self._payload
+
+ def raise_for_status(self):
+ if self.status_code >= 400:
+ raise RuntimeError(f"HTTP {self.status_code}")
+
+
+class _FakeHTTPClient:
+ def __init__(self, get_payloads=None, post_payloads=None):
+ self.get_payloads = list(get_payloads or [])
+ self.post_payloads = list(post_payloads or [])
+ self.gets = []
+ self.posts = []
+
+ async def get(self, url):
+ self.gets.append(url)
+ payload = self.get_payloads.pop(0)
+ return _FakeResponse(payload)
+
+ async def post(self, url, **kwargs):
+ self.posts.append((url, kwargs))
+ payload = self.post_payloads.pop(0)
+ return _FakeResponse(payload)
+
+
+def _rsa_key_and_jwk(kid="kid-1"):
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(private_key.public_key()))
+ jwk["kid"] = kid
+ jwk["alg"] = "RS256"
+ jwk["use"] = "sig"
+ return private_key, jwk
+
+
+def _signed_token(private_key, *, app_id="app-id", kid="kid-1", service_url=None):
+ payload = {
+ "iss": "https://api.botframework.com",
+ "aud": app_id,
+ "exp": int(time.time()) + 300,
+ }
+ if service_url:
+ payload["serviceurl"] = service_url
+ return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid})
+
+
+@pytest.mark.asyncio
+async def test_jwt_validator_accepts_bot_framework_token():
+ private_key, jwk = _rsa_key_and_jwk()
+ metadata = {"jwks_uri": "https://login.botframework.com/keys"}
+ jwks = {"keys": [jwk]}
+ client = _FakeHTTPClient(get_payloads=[metadata, jwks])
+ validator = auth.BotFrameworkJWTValidator("app-id", client)
+ token = _signed_token(
+ private_key,
+ app_id="app-id",
+ service_url="https://smba.trafficmanager.net/amer/",
+ )
+
+ assert await validator.validate_authorization_header(
+ f"Bearer {token}",
+ service_url="https://smba.trafficmanager.net/amer/",
+ ) is True
+ assert client.gets == [
+ auth.BOT_FRAMEWORK_OPENID_METADATA_URL,
+ "https://login.botframework.com/keys",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_jwt_validator_rejects_missing_bearer_header():
+ private_key, jwk = _rsa_key_and_jwk()
+ client = _FakeHTTPClient(
+ get_payloads=[
+ {"jwks_uri": "https://login.botframework.com/keys"},
+ {"keys": [jwk]},
+ ]
+ )
+ validator = auth.BotFrameworkJWTValidator("app-id", client)
+ token = _signed_token(private_key)
+
+ assert await validator.validate_authorization_header(token) is False
+ assert client.gets == []
+
+
+@pytest.mark.asyncio
+async def test_jwt_validator_rejects_wrong_audience():
+ private_key, jwk = _rsa_key_and_jwk()
+ client = _FakeHTTPClient(
+ get_payloads=[
+ {"jwks_uri": "https://login.botframework.com/keys"},
+ {"keys": [jwk]},
+ ]
+ )
+ validator = auth.BotFrameworkJWTValidator("expected-app", client)
+ token = _signed_token(private_key, app_id="other-app")
+
+ assert await validator.validate_authorization_header(f"Bearer {token}") is False
+
+
+@pytest.mark.asyncio
+async def test_token_provider_posts_client_credentials_and_caches():
+ client = _FakeHTTPClient(
+ post_payloads=[{"access_token": "outbound-token", "expires_in": 3600}]
+ )
+ provider = auth.BotFrameworkTokenProvider(
+ app_id="app-id",
+ app_password="secret",
+ tenant_id="tenant-id",
+ http_client=client,
+ )
+
+ token1 = await provider.get_token(auth.BOT_FRAMEWORK_SCOPE)
+ token2 = await provider.get_token(auth.BOT_FRAMEWORK_SCOPE)
+
+ assert token1 == token2 == "outbound-token"
+ assert len(client.posts) == 1
+ url, kwargs = client.posts[0]
+ assert url == "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"
+ assert kwargs["data"]["grant_type"] == "client_credentials"
+ assert kwargs["data"]["client_id"] == "app-id"
+ assert kwargs["data"]["client_secret"] == "secret"
+ assert kwargs["data"]["scope"] == auth.BOT_FRAMEWORK_SCOPE
+
+
+def test_token_provider_requires_app_password():
+ with pytest.raises(auth.AuthError, match="MSTEAMS_APP_PASSWORD"):
+ auth.BotFrameworkTokenProvider(
+ app_id="app-id",
+ app_password="",
+ http_client=_FakeHTTPClient(),
+ )
diff --git a/tests/gateway/test_msteams_config.py b/tests/gateway/test_msteams_config.py
new file mode 100644
index 000000000000..e5fcbf835363
--- /dev/null
+++ b/tests/gateway/test_msteams_config.py
@@ -0,0 +1,125 @@
+"""Configuration and gateway wiring tests for Microsoft Teams."""
+
+from __future__ import annotations
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides, load_gateway_config
+
+
+def _clear_msteams_env(monkeypatch):
+ import os
+
+ for key in list(os.environ):
+ if key.startswith("MSTEAMS_"):
+ monkeypatch.delenv(key, raising=False)
+
+
+def test_platform_enum_has_msteams():
+ assert Platform("msteams") is Platform.MSTEAMS
+ assert Platform.MSTEAMS.value == "msteams"
+
+
+def test_get_connected_platforms_requires_app_credentials():
+ config = GatewayConfig(
+ platforms={
+ Platform.MSTEAMS: PlatformConfig(
+ enabled=True,
+ extra={"app_id": "app-id"},
+ )
+ }
+ )
+ assert Platform.MSTEAMS not in config.get_connected_platforms()
+
+ config.platforms[Platform.MSTEAMS].extra["app_password"] = "secret"
+ assert Platform.MSTEAMS in config.get_connected_platforms()
+
+
+def test_env_overrides_populate_msteams_platform_config(monkeypatch):
+ _clear_msteams_env(monkeypatch)
+
+ monkeypatch.setenv("MSTEAMS_APP_ID", "app-123")
+ monkeypatch.setenv("MSTEAMS_APP_PASSWORD", "secret-123")
+ monkeypatch.setenv("MSTEAMS_TENANT_ID", "tenant-abc")
+ monkeypatch.setenv("MSTEAMS_BOT_DISPLAY_NAME", "Hermes")
+ monkeypatch.setenv("MSTEAMS_HOST", "127.0.0.1")
+ monkeypatch.setenv("MSTEAMS_PORT", "4000")
+ monkeypatch.setenv("MSTEAMS_PATH", "/teams/messages")
+ monkeypatch.setenv("MSTEAMS_REQUIRE_MENTION", "false")
+ monkeypatch.setenv("MSTEAMS_MENTION_PATTERNS", '["hermes", "bot"]')
+ monkeypatch.setenv("MSTEAMS_FREE_RESPONSE_CONVERSATIONS", "19:free,team-1")
+ monkeypatch.setenv("MSTEAMS_HOME_CHANNEL", "19:home")
+
+ config = GatewayConfig()
+ _apply_env_overrides(config)
+
+ platform_config = config.platforms[Platform.MSTEAMS]
+ assert platform_config.enabled is True
+ assert platform_config.token == "app-123"
+ assert platform_config.api_key == "secret-123"
+ assert platform_config.home_channel.chat_id == "19:home"
+ assert platform_config.extra["app_id"] == "app-123"
+ assert platform_config.extra["app_password"] == "secret-123"
+ assert platform_config.extra["tenant_id"] == "tenant-abc"
+ assert platform_config.extra["bot_display_name"] == "Hermes"
+ assert platform_config.extra["host"] == "127.0.0.1"
+ assert platform_config.extra["port"] == 4000
+ assert platform_config.extra["path"] == "/teams/messages"
+ assert platform_config.extra["require_mention"] is False
+ assert platform_config.extra["mention_patterns"] == ["hermes", "bot"]
+ assert platform_config.extra["free_response_conversations"] == ["19:free", "team-1"]
+
+
+def test_load_gateway_config_bridges_msteams_top_level_policy(tmp_path, monkeypatch):
+ _clear_msteams_env(monkeypatch)
+ monkeypatch.setenv("MSTEAMS_APP_PASSWORD", "env-password")
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "platforms:\n"
+ " msteams:\n"
+ " enabled: true\n"
+ " extra:\n"
+ " app_id: app-yaml\n"
+ "msteams:\n"
+ " require_mention: false\n"
+ " mention_patterns:\n"
+ " - '^hermes[:, ]'\n"
+ " free_response_conversations:\n"
+ " - '19:free@thread.tacv2'\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ platform_config = config.platforms[Platform.MSTEAMS]
+ assert platform_config.enabled is True
+ assert platform_config.extra["app_id"] == "app-yaml"
+ assert platform_config.api_key == "env-password"
+ assert platform_config.extra["app_password"] == "env-password"
+ assert platform_config.extra["require_mention"] is False
+ assert platform_config.extra["mention_patterns"] == ["^hermes[:, ]"]
+ assert platform_config.extra["free_response_conversations"] == ["19:free@thread.tacv2"]
+
+
+def test_runner_factory_references_msteams():
+ import inspect
+ from gateway.run import GatewayRunner
+
+ source = inspect.getsource(GatewayRunner._create_adapter)
+ assert "Platform.MSTEAMS" in source
+ assert "MsTeamsAdapter" in source
+
+ auth_source = inspect.getsource(GatewayRunner._is_user_authorized)
+ assert "MSTEAMS_ALLOWED_USERS" in auth_source
+ assert "MSTEAMS_ALLOW_ALL_USERS" in auth_source
+
+
+def test_toolset_and_prompt_hint_include_msteams():
+ import toolsets
+ from hermes_cli.platforms import PLATFORMS
+ from agent.prompt_builder import PLATFORM_HINTS
+
+ assert PLATFORMS["msteams"].default_toolset == "hermes-msteams"
+ assert "hermes-msteams" in toolsets.TOOLSETS
+ assert "hermes-msteams" in toolsets.TOOLSETS["hermes-gateway"]["includes"]
+ assert "Microsoft Teams" in PLATFORM_HINTS["msteams"]
diff --git a/toolsets.py b/toolsets.py
index a444713f5760..135f17e50d59 100644
--- a/toolsets.py
+++ b/toolsets.py
@@ -471,10 +471,16 @@
"includes": []
},
+ "hermes-msteams": {
+ "description": "Microsoft Teams bot toolset - text-only Bot Framework messaging",
+ "tools": _HERMES_CORE_TOOLS,
+ "includes": []
+ },
+
"hermes-gateway": {
"description": "Gateway toolset - union of all messaging platform tools",
"tools": [],
- "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook", "hermes-yuanbao"]
+ "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook", "hermes-msteams", "hermes-yuanbao"]
}
}
diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md
index ccb785370236..e73171c253fc 100644
--- a/website/docs/integrations/index.md
+++ b/website/docs/integrations/index.md
@@ -82,7 +82,7 @@ Speech-to-text supports three providers: local Whisper (free, runs on-device), G
Hermes runs as a gateway bot on 15+ messaging platforms, all configured through the same `gateway` subsystem:
-- **[Telegram](/docs/user-guide/messaging/telegram)**, **[Discord](/docs/user-guide/messaging/discord)**, **[Slack](/docs/user-guide/messaging/slack)**, **[WhatsApp](/docs/user-guide/messaging/whatsapp)**, **[Signal](/docs/user-guide/messaging/signal)**, **[Matrix](/docs/user-guide/messaging/matrix)**, **[Mattermost](/docs/user-guide/messaging/mattermost)**, **[Email](/docs/user-guide/messaging/email)**, **[SMS](/docs/user-guide/messaging/sms)**, **[DingTalk](/docs/user-guide/messaging/dingtalk)**, **[Feishu/Lark](/docs/user-guide/messaging/feishu)**, **[WeCom](/docs/user-guide/messaging/wecom)**, **[WeCom Callback](/docs/user-guide/messaging/wecom-callback)**, **[Weixin](/docs/user-guide/messaging/weixin)**, **[BlueBubbles](/docs/user-guide/messaging/bluebubbles)**, **[QQ Bot](/docs/user-guide/messaging/qqbot)**, **[Home Assistant](/docs/user-guide/messaging/homeassistant)**, **[Webhooks](/docs/user-guide/messaging/webhooks)**
+- **[Telegram](/docs/user-guide/messaging/telegram)**, **[Discord](/docs/user-guide/messaging/discord)**, **[Slack](/docs/user-guide/messaging/slack)**, **[WhatsApp](/docs/user-guide/messaging/whatsapp)**, **[Signal](/docs/user-guide/messaging/signal)**, **[Matrix](/docs/user-guide/messaging/matrix)**, **[Mattermost](/docs/user-guide/messaging/mattermost)**, **[Email](/docs/user-guide/messaging/email)**, **[SMS](/docs/user-guide/messaging/sms)**, **[DingTalk](/docs/user-guide/messaging/dingtalk)**, **[Microsoft Teams](/docs/user-guide/messaging/msteams)**, **[Feishu/Lark](/docs/user-guide/messaging/feishu)**, **[WeCom](/docs/user-guide/messaging/wecom)**, **[WeCom Callback](/docs/user-guide/messaging/wecom-callback)**, **[Weixin](/docs/user-guide/messaging/weixin)**, **[BlueBubbles](/docs/user-guide/messaging/bluebubbles)**, **[QQ Bot](/docs/user-guide/messaging/qqbot)**, **[Home Assistant](/docs/user-guide/messaging/homeassistant)**, **[Webhooks](/docs/user-guide/messaging/webhooks)**
See the [Messaging Gateway overview](/docs/user-guide/messaging) for the platform comparison table and setup guide.
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index 4aff2276e15f..1c0844871f18 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -263,6 +263,16 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `DINGTALK_CLIENT_ID` | DingTalk bot AppKey from developer portal ([open.dingtalk.com](https://open.dingtalk.com)) |
| `DINGTALK_CLIENT_SECRET` | DingTalk bot AppSecret from developer portal |
| `DINGTALK_ALLOWED_USERS` | Comma-separated DingTalk user IDs allowed to message the bot |
+| `MSTEAMS_APP_ID` | Microsoft Teams Bot Framework app/client ID |
+| `MSTEAMS_APP_PASSWORD` | Microsoft Teams Bot Framework app client secret |
+| `MSTEAMS_TENANT_ID` | Optional tenant ID for Bot Framework token acquisition. Defaults to `botframework.com` |
+| `MSTEAMS_ALLOWED_USERS` | Comma-separated Microsoft Teams AAD object IDs allowed to message the bot |
+| `MSTEAMS_HOST` | Local host/interface for the Teams webhook server. Default: `0.0.0.0` |
+| `MSTEAMS_PORT` | Local port for the Teams webhook server. Default: `3978` |
+| `MSTEAMS_PATH` | Webhook path for Bot Framework activities. Default: `/api/messages` |
+| `MSTEAMS_REQUIRE_MENTION` | Require bot mentions in Teams channels/group chats. Default: `true` |
+| `MSTEAMS_MENTION_PATTERNS` | JSON list or comma-separated regex wake words for Teams channels/group chats |
+| `MSTEAMS_FREE_RESPONSE_CONVERSATIONS` | Comma-separated Teams conversation/channel/team IDs that do not require mentions |
| `FEISHU_APP_ID` | Feishu/Lark bot App ID from [open.feishu.cn](https://open.feishu.cn/) |
| `FEISHU_APP_SECRET` | Feishu/Lark bot App Secret |
| `FEISHU_DOMAIN` | `feishu` (China) or `lark` (international). Default: `feishu` |
diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md
index 126ab8184f67..068c60191c15 100644
--- a/website/docs/user-guide/messaging/index.md
+++ b/website/docs/user-guide/messaging/index.md
@@ -383,6 +383,7 @@ Each platform has its own toolset:
| Mattermost | `hermes-mattermost` | Full tools including terminal |
| Matrix | `hermes-matrix` | Full tools including terminal |
| DingTalk | `hermes-dingtalk` | Full tools including terminal |
+| Microsoft Teams | `hermes-msteams` | Text-only Bot Framework replies |
| Feishu/Lark | `hermes-feishu` | Full tools including terminal |
| WeCom | `hermes-wecom` | Full tools including terminal |
| WeCom Callback | `hermes-wecom-callback` | Full tools including terminal |
@@ -406,6 +407,7 @@ Each platform has its own toolset:
- [Mattermost Setup](mattermost.md)
- [Matrix Setup](matrix.md)
- [DingTalk Setup](dingtalk.md)
+- [Microsoft Teams Setup](msteams.md)
- [Feishu/Lark Setup](feishu.md)
- [WeCom Setup](wecom.md)
- [WeCom Callback Setup](wecom-callback.md)
@@ -414,4 +416,4 @@ Each platform has its own toolset:
- [QQBot Setup](qqbot.md)
- [Yuanbao Setup](yuanbao.md)
- [Open WebUI + API Server](open-webui.md)
-- [Webhooks](webhooks.md)
\ No newline at end of file
+- [Webhooks](webhooks.md)
diff --git a/website/docs/user-guide/messaging/msteams.md b/website/docs/user-guide/messaging/msteams.md
new file mode 100644
index 000000000000..b6d1fedace1f
--- /dev/null
+++ b/website/docs/user-guide/messaging/msteams.md
@@ -0,0 +1,82 @@
+---
+sidebar_position: 12
+title: Microsoft Teams
+---
+
+# Microsoft Teams
+
+Hermes can run as a Microsoft Teams bot through the Bot Framework webhook. This first integration slice is text-only: it supports normal text messages in DMs, channels, and group chats, plus text replies back to the same Teams conversation.
+
+Not included yet: Microsoft Graph, SharePoint files, Adaptive Cards, images, channel history, or scheduled outbound delivery through `send_message`.
+
+## Requirements
+
+- A Microsoft Teams bot registration with an app/client ID and client secret.
+- A public HTTPS URL that Microsoft can POST to.
+- Gateway dependencies installed with messaging support (`aiohttp`, `httpx`, and `PyJWT[crypto]` are used by the adapter).
+
+## Configuration
+
+Use `~/.hermes/config.yaml`:
+
+```yaml
+platforms:
+ msteams:
+ enabled: true
+ extra:
+ app_id: "00000000-0000-0000-0000-000000000000"
+ # Put app_password in ~/.hermes/.env as MSTEAMS_APP_PASSWORD.
+ tenant_id: "botframework.com" # optional; default shown
+ host: "0.0.0.0"
+ port: 3978
+ path: "/api/messages"
+
+msteams:
+ require_mention: true
+ mention_patterns:
+ - "^hermes[:, ]"
+ free_response_conversations:
+ - "19:example@thread.tacv2"
+```
+
+Put secrets in `~/.hermes/.env`:
+
+```bash
+MSTEAMS_APP_PASSWORD=
+MSTEAMS_ALLOWED_USERS=azure-ad-object-id-1,azure-ad-object-id-2
+```
+
+You can also configure the basics entirely with environment variables:
+
+```bash
+MSTEAMS_APP_ID=00000000-0000-0000-0000-000000000000
+MSTEAMS_APP_PASSWORD=
+MSTEAMS_TENANT_ID=botframework.com
+MSTEAMS_PORT=3978
+MSTEAMS_PATH=/api/messages
+MSTEAMS_REQUIRE_MENTION=true
+```
+
+## Bot Endpoint
+
+Set the bot messaging endpoint in Azure/Bot Framework to:
+
+```text
+https://your-public-host.example.com/api/messages
+```
+
+Use the configured `path` if you changed it from `/api/messages`.
+
+## Mention Behavior
+
+DMs are processed directly, subject to the normal Hermes gateway authorization flow.
+
+Channels and group chats require an explicit bot mention by default. You can disable this globally with `msteams.require_mention: false`, add regex wake words with `mention_patterns`, or allow specific conversation/team/channel IDs with `free_response_conversations`.
+
+## Start
+
+```bash
+hermes gateway run
+```
+
+The adapter listens on the configured host and port, validates Bot Framework JWTs, remembers each inbound conversation's `serviceUrl`, and sends replies to that same Bot Framework conversation endpoint.
diff --git a/website/sidebars.ts b/website/sidebars.ts
index b65429181013..b34a3720e29d 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -505,6 +505,7 @@ const sidebars: SidebarsConfig = {
'user-guide/messaging/mattermost',
'user-guide/messaging/matrix',
'user-guide/messaging/dingtalk',
+ 'user-guide/messaging/msteams',
'user-guide/messaging/feishu',
'user-guide/messaging/wecom',
'user-guide/messaging/wecom-callback',