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 ![alt](url) 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',