feat(teams): add Microsoft Teams platform adapter as a plugin

Hello! I am the maintainer of the microsoft-teams-apps Python SDK and
I built this Teams adapter to integrate Microsoft Teams into Hermes.

Adds a `plugins/platforms/teams` platform plugin using the new
PlatformRegistry system from #17751. The adapter self-registers via
`register(ctx)` — no hardcoding in run.py, toolsets.py, or any
other core file.

Key features:
- Supports personal DMs, group chats, and channel posts
- Adaptive Card approval prompts with in-place button replacement
  (Allow Once / Allow Session / Always Allow / Deny)
- aiohttp webhook server bridged from the Teams SDK to avoid
  the fastapi/uvicorn dependency
- ConversationReference caching for correct proactive sends in
  non-DM chats
- `interactive_setup()` for `hermes gateway setup` integration
- `platform_hint` for LLM context (Teams markdown subset)
- 34 tests covering adapter init, send, message handling, and
  plugin registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aamir Jawaid 2026-04-30 05:03:38 +00:00 committed by Teknium
parent 21e695fcb6
commit b3137d758c
8 changed files with 1456 additions and 1 deletions

View File

@ -398,3 +398,19 @@ IMAGE_TOOLS_DEBUG=false
# Override STT provider endpoints (for proxies or self-hosted instances) # Override STT provider endpoints (for proxies or self-hosted instances)
# GROQ_BASE_URL=https://api.groq.com/openai/v1 # GROQ_BASE_URL=https://api.groq.com/openai/v1
# STT_OPENAI_BASE_URL=https://api.openai.com/v1 # STT_OPENAI_BASE_URL=https://api.openai.com/v1
# =============================================================================
# MICROSOFT TEAMS INTEGRATION
# =============================================================================
# Register a Bot in Azure: https://dev.botframework.com/ → "Register a bot"
# Or use Azure Portal: Azure Active Directory → App registrations → New registration
# Then add the bot to Teams via the Bot Framework or App Studio.
#
# TEAMS_CLIENT_ID= # Azure AD App (client) ID
# TEAMS_CLIENT_SECRET= # Azure AD client secret value
# TEAMS_TENANT_ID= # Azure AD tenant ID (or "common" for multi-tenant)
# TEAMS_ALLOWED_USERS= # Comma-separated AAD object IDs or UPNs
# TEAMS_ALLOW_ALL_USERS=false # Set true to skip the allowlist
# TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery
# TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel
# TEAMS_PORT=3978 # Webhook listen port (Bot Framework default)

View File

@ -570,7 +570,7 @@ agent:
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set) # - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
# - A list of individual toolsets to compose your own (see list below) # - A list of individual toolsets to compose your own (see list below)
# #
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot # Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams
# #
# Examples: # Examples:
# #
@ -600,6 +600,7 @@ agent:
# signal: hermes-signal (same as telegram) # signal: hermes-signal (same as telegram)
# homeassistant: hermes-homeassistant (same as telegram) # homeassistant: hermes-homeassistant (same as telegram)
# qqbot: hermes-qqbot (same as telegram) # qqbot: hermes-qqbot (same as telegram)
# teams: hermes-teams (same as telegram)
# #
platform_toolsets: platform_toolsets:
cli: [hermes-cli] cli: [hermes-cli]
@ -611,6 +612,7 @@ platform_toolsets:
homeassistant: [hermes-homeassistant] homeassistant: [hermes-homeassistant]
qqbot: [hermes-qqbot] qqbot: [hermes-qqbot]
yuanbao: [hermes-yuanbao] yuanbao: [hermes-yuanbao]
teams: [hermes-teams]
# ============================================================================= # =============================================================================
# Gateway Platform Settings # Gateway Platform Settings

View File

@ -34,6 +34,13 @@ services:
# uncomment BOTH lines (API_SERVER_KEY is mandatory for auth): # uncomment BOTH lines (API_SERVER_KEY is mandatory for auth):
# - API_SERVER_HOST=0.0.0.0 # - API_SERVER_HOST=0.0.0.0
# - API_SERVER_KEY=${API_SERVER_KEY} # - API_SERVER_KEY=${API_SERVER_KEY}
# Microsoft Teams — uncomment and fill in to enable Teams gateway.
# Register your bot at https://dev.botframework.com/ to get these values.
# - TEAMS_CLIENT_ID=${TEAMS_CLIENT_ID}
# - TEAMS_CLIENT_SECRET=${TEAMS_CLIENT_SECRET}
# - TEAMS_TENANT_ID=${TEAMS_TENANT_ID}
# - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS}
# - TEAMS_PORT=3978
command: ["gateway", "run"] command: ["gateway", "run"]
dashboard: dashboard:

View File

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View File

@ -0,0 +1,637 @@
"""
Microsoft Teams platform adapter for Hermes Agent.
Uses the microsoft-teams-apps SDK for authentication and activity processing.
Runs an aiohttp webhook server to receive messages from Teams.
Proactive messaging (send, typing) uses the SDK's App.send() method.
Requires:
pip install microsoft-teams-apps aiohttp
TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID env vars
Configuration in config.yaml:
platforms:
teams:
enabled: true
extra:
client_id: "your-client-id" # or TEAMS_CLIENT_ID env var
client_secret: "your-secret" # or TEAMS_CLIENT_SECRET env var
tenant_id: "your-tenant-id" # or TEAMS_TENANT_ID env var
port: 3978 # or TEAMS_PORT env var
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any, Dict, Optional
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None # type: ignore[assignment]
try:
from microsoft_teams.apps import App, ActivityContext
from microsoft_teams.api import MessageActivity, ConversationReference
from microsoft_teams.api.activities.typing import TypingActivityInput
from microsoft_teams.api.activities.invoke.adaptive_card import AdaptiveCardInvokeActivity
from microsoft_teams.api.models.adaptive_card import (
AdaptiveCardActionCardResponse,
AdaptiveCardActionMessageResponse,
)
from microsoft_teams.api.models.invoke_response import InvokeResponse, AdaptiveCardInvokeResponse
from microsoft_teams.apps.http.adapter import (
HttpMethod,
HttpRequest,
HttpResponse,
HttpRouteHandler,
)
from microsoft_teams.cards import AdaptiveCard, ExecuteAction, TextBlock
TEAMS_SDK_AVAILABLE = True
except ImportError:
TEAMS_SDK_AVAILABLE = False
App = None # type: ignore[assignment,misc]
ActivityContext = None # type: ignore[assignment,misc]
MessageActivity = None # type: ignore[assignment,misc]
ConversationReference = None # type: ignore[assignment,misc]
TypingActivityInput = None # type: ignore[assignment,misc]
AdaptiveCardInvokeActivity = None # type: ignore[assignment,misc]
AdaptiveCardActionCardResponse = None # type: ignore[assignment,misc]
AdaptiveCardActionMessageResponse = None # type: ignore[assignment,misc]
AdaptiveCardInvokeResponse = None # type: ignore[assignment,misc,union-attr]
InvokeResponse = None # type: ignore[assignment,misc]
HttpMethod = str # type: ignore[assignment,misc]
HttpRequest = None # type: ignore[assignment,misc]
HttpResponse = None # type: ignore[assignment,misc]
HttpRouteHandler = None # type: ignore[assignment,misc]
AdaptiveCard = None # type: ignore[assignment,misc]
ExecuteAction = None # type: ignore[assignment,misc]
TextBlock = None # type: ignore[assignment,misc]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
cache_image_from_url,
)
logger = logging.getLogger(__name__)
_DEFAULT_PORT = 3978
_WEBHOOK_PATH = "/api/messages"
class _AiohttpBridgeAdapter:
"""HttpServerAdapter that bridges the Teams SDK into an aiohttp server.
Without a custom adapter, ``App()`` unconditionally imports fastapi/uvicorn
and allocates a ``FastAPI()`` instance. This bridge captures the SDK's
route registrations and wires them into our own aiohttp ``Application``.
"""
def __init__(self, aiohttp_app: "web.Application"):
self._aiohttp_app = aiohttp_app
def register_route(self, method: "HttpMethod", path: str, handler: "HttpRouteHandler") -> None:
"""Register an SDK route handler as an aiohttp route."""
async def _aiohttp_handler(request: "web.Request") -> "web.Response":
body = await request.json()
headers = dict(request.headers)
result: "HttpResponse" = await handler(HttpRequest(body=body, headers=headers))
status = result.get("status", 200)
resp_body = result.get("body")
if resp_body is not None:
return web.Response(
status=status,
body=json.dumps(resp_body),
content_type="application/json",
)
return web.Response(status=status)
self._aiohttp_app.router.add_route(method, path, _aiohttp_handler)
def serve_static(self, path: str, directory: str) -> None:
pass
async def start(self, port: int) -> None:
raise NotImplementedError("aiohttp server is managed by the adapter")
async def stop(self) -> None:
pass
def check_requirements() -> bool:
"""Return True when all Teams dependencies and credentials are present."""
return TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE
def validate_config(config) -> bool:
"""Return True when the config has the minimum required credentials."""
extra = getattr(config, "extra", {}) or {}
client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "")
client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "")
return bool(client_id and client_secret and tenant_id)
def is_connected(config) -> bool:
"""Check whether Teams is configured (env or config.yaml)."""
return validate_config(config)
# Keep the old name as an alias so existing test imports don't break.
check_teams_requirements = check_requirements
class TeamsAdapter(BasePlatformAdapter):
"""Microsoft Teams adapter using the microsoft-teams-apps SDK."""
MAX_MESSAGE_LENGTH = 28000 # Teams text message limit (~28 KB)
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("teams"))
extra = config.extra or {}
self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "")
self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "")
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")
self._port = int(extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)))
self._app: Optional["App"] = None
self._runner: Optional["web.AppRunner"] = None
self._dedup = MessageDeduplicator(max_size=1000)
# Maps chat_id → ConversationReference captured from incoming messages.
# Used to send cards with the correct conversation type (personal/group/channel).
self._conv_refs: Dict[str, Any] = {}
async def connect(self) -> bool:
if not TEAMS_SDK_AVAILABLE:
self._set_fatal_error(
"MISSING_SDK",
"microsoft-teams-apps not installed. Run: pip install microsoft-teams-apps",
retryable=False,
)
return False
if not AIOHTTP_AVAILABLE:
self._set_fatal_error(
"MISSING_SDK",
"aiohttp not installed. Run: pip install aiohttp",
retryable=False,
)
return False
if not self._client_id or not self._client_secret or not self._tenant_id:
self._set_fatal_error(
"MISSING_CREDENTIALS",
"TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required",
retryable=False,
)
return False
try:
# Set up aiohttp app first — the bridge adapter wires SDK routes into it
aiohttp_app = web.Application()
aiohttp_app.router.add_get("/health", lambda _: web.Response(text="ok"))
self._app = App(
client_id=self._client_id,
client_secret=self._client_secret,
tenant_id=self._tenant_id,
http_server_adapter=_AiohttpBridgeAdapter(aiohttp_app),
)
# Register message handler before initialize()
@self._app.on_message
async def _handle_message(ctx: ActivityContext[MessageActivity]):
await self._on_message(ctx)
@self._app.on_card_action
async def _handle_card_action(
ctx: ActivityContext[AdaptiveCardInvokeActivity],
) -> InvokeResponse[AdaptiveCardActionMessageResponse]:
return await self._on_card_action(ctx)
# initialize() calls register_route() on the bridge, which adds
# POST /api/messages to aiohttp_app automatically
await self._app.initialize()
self._runner = web.AppRunner(aiohttp_app)
await self._runner.setup()
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
await site.start()
self._running = True
self._mark_connected()
logger.info(
"[teams] Webhook server listening on 0.0.0.0:%d%s",
self._port,
_WEBHOOK_PATH,
)
return True
except Exception as e:
self._set_fatal_error(
"CONNECT_FAILED",
f"Teams connection failed: {e}",
retryable=True,
)
logger.error("[teams] Failed to connect: %s", e)
return False
async def disconnect(self) -> None:
self._running = False
if self._runner:
await self._runner.cleanup()
self._runner = None
self._app = None
self._mark_disconnected()
logger.info("[teams] Disconnected")
async def _on_message(self, ctx: ActivityContext[MessageActivity]) -> None:
"""Process an incoming Teams message and dispatch to the gateway."""
activity = ctx.activity
# Self-message filter
bot_id = self._app.id if self._app else None
if bot_id and getattr(activity.from_, "id", None) == bot_id:
return
# Deduplication
msg_id = getattr(activity, "id", None)
if msg_id and self._dedup.is_duplicate(msg_id):
return
# Cache the conversation reference for proactive sends (approval cards, etc.)
conv_id = getattr(activity.conversation, "id", None)
if conv_id:
self._conv_refs[conv_id] = ctx.conversation_ref
# Extract text — strip bot @mentions
text = ""
if hasattr(activity, "text") and activity.text:
text = activity.text
# Strip <at>BotName</at> HTML tags that Teams prepends for @mentions
if "<at>" in text:
import re
text = re.sub(r"<at>[^<]*</at>\s*", "", text).strip()
# Determine chat type from conversation
conv = activity.conversation
conv_type = getattr(conv, "conversation_type", None) or ""
if conv_type == "personal":
chat_type = "dm"
elif conv_type == "groupChat":
chat_type = "group"
elif conv_type == "channel":
chat_type = "channel"
else:
chat_type = "dm"
# Build source
from_account = activity.from_
user_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "")
user_name = getattr(from_account, "name", None) or ""
source = self.build_source(
chat_id=conv.id,
chat_name=getattr(conv, "name", None) or "",
chat_type=chat_type,
user_id=str(user_id),
user_name=user_name,
guild_id=getattr(conv, "tenant_id", None) or self._tenant_id,
)
# Handle image attachments
media_urls = []
media_types = []
for att in getattr(activity, "attachments", None) or []:
content_url = getattr(att, "content_url", None)
content_type = getattr(att, "content_type", None) or ""
if content_url and content_type.startswith("image/"):
try:
cached = await cache_image_from_url(content_url)
if cached:
media_urls.append(cached)
media_types.append(content_type)
except Exception as e:
logger.warning("[teams] Failed to cache image attachment: %s", e)
msg_type = MessageType.PHOTO if media_urls else MessageType.TEXT
event = MessageEvent(
text=text,
source=source,
message_type=msg_type,
media_urls=media_urls,
media_types=media_types,
message_id=msg_id,
)
await self.handle_message(event)
async def _send_card(self, chat_id: str, card: "AdaptiveCard") -> "Any":
"""Send an AdaptiveCard, using a stored ConversationReference when available."""
from microsoft_teams.api import MessageActivityInput
conv_ref = self._conv_refs.get(chat_id)
if conv_ref and self._app:
activity = MessageActivityInput().add_card(card)
return await self._app.activity_sender.send(activity, conv_ref)
elif self._app:
return await self._app.send(chat_id, card)
return None
async def _on_card_action(
self, ctx: "ActivityContext[AdaptiveCardInvokeActivity]"
) -> "InvokeResponse[AdaptiveCardActionMessageResponse]":
"""Handle an Adaptive Card Action.Execute button click."""
from tools.approval import resolve_gateway_approval, has_blocking_approval
action = ctx.activity.value.action
data = action.data or {}
hermes_action = data.get("hermes_action", "")
session_key = data.get("session_key", "")
if not hermes_action or not session_key:
return InvokeResponse(
status=200,
body=AdaptiveCardActionMessageResponse(value="Unknown action."),
)
# Only authorized users may click approval buttons.
allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip()
if allowed_csv:
from_account = ctx.activity.from_
clicker_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "")
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
if "*" not in allowed_ids and clicker_id not in allowed_ids:
logger.warning("[teams] Unauthorized card action by %s — ignoring", clicker_id)
return InvokeResponse(
status=200,
body=AdaptiveCardActionMessageResponse(value="⛔ Not authorized."),
)
choice_map = {
"approve_once": "once",
"approve_session": "session",
"approve_always": "always",
"deny": "deny",
}
choice = choice_map.get(hermes_action)
if not choice:
return InvokeResponse(
status=200,
body=AdaptiveCardActionMessageResponse(value="Unknown action."),
)
if not has_blocking_approval(session_key):
return InvokeResponse(
status=200,
body=AdaptiveCardActionCardResponse(
value=AdaptiveCard()
.with_version("1.4")
.with_body([TextBlock(text="⚠️ Approval already resolved or expired.", wrap=True)])
),
)
resolve_gateway_approval(session_key, choice)
label_map = {
"once": "✅ Allowed (once)",
"session": "✅ Allowed (session)",
"always": "✅ Always allowed",
"deny": "❌ Denied",
}
return InvokeResponse(
status=200,
body=AdaptiveCardActionCardResponse(
value=AdaptiveCard()
.with_version("1.4")
.with_body([TextBlock(text=label_map[choice], wrap=True, weight="Bolder")])
),
)
async def send_exec_approval(
self,
chat_id: str,
command: str,
session_key: str,
description: str = "dangerous command",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an Adaptive Card approval prompt with Allow/Deny buttons."""
if not self._app:
return SendResult(success=False, error="Teams app not initialized")
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
card = (
AdaptiveCard()
.with_version("1.4")
.with_body([
TextBlock(text="⚠️ Command Approval Required", wrap=True, weight="Bolder"),
TextBlock(text=f"```\n{cmd_preview}\n```", wrap=True),
TextBlock(text=f"Reason: {description}", wrap=True, isSubtle=True),
])
.with_actions([
ExecuteAction(
title="Allow Once",
verb="hermes_approve",
data={"hermes_action": "approve_once", "session_key": session_key},
style="positive",
),
ExecuteAction(
title="Allow Session",
verb="hermes_approve",
data={"hermes_action": "approve_session", "session_key": session_key},
),
ExecuteAction(
title="Always Allow",
verb="hermes_approve",
data={"hermes_action": "approve_always", "session_key": session_key},
),
ExecuteAction(
title="Deny",
verb="hermes_approve",
data={"hermes_action": "deny", "session_key": session_key},
style="destructive",
),
])
)
try:
result = await self._send_card(chat_id, card)
message_id = getattr(result, "id", None) if result else None
return SendResult(success=True, message_id=message_id)
except Exception as e:
logger.error("[teams] send_exec_approval failed: %s", e, exc_info=True)
return SendResult(success=False, error=str(e), retryable=True)
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
if not self._app:
return SendResult(success=False, error="Teams app not initialized")
formatted = self.format_message(content)
chunks = self.truncate_message(formatted)
last_message_id = None
for chunk in chunks:
try:
result = await self._app.send(chat_id, chunk)
last_message_id = getattr(result, "id", None)
except Exception as e:
return SendResult(success=False, error=str(e), retryable=True)
return SendResult(success=True, message_id=last_message_id)
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
if not self._app:
return
try:
await self._app.send(chat_id, TypingActivityInput())
except Exception:
pass
async def send_image(
self,
chat_id: str,
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
# Teams: embed image as markdown
text = f"![image]({image_url})"
if caption:
text = f"{caption}\n\n{text}"
return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
async def get_chat_info(self, chat_id: str) -> dict:
return {"name": chat_id, "type": "unknown", "chat_id": chat_id}
# ── Interactive setup ─────────────────────────────────────────────────────────
def interactive_setup() -> None:
"""Prompt the user for Teams credentials and save them to ~/.hermes/.env."""
from hermes_cli.config import (
get_env_value,
save_env_value,
prompt,
prompt_yes_no,
print_info,
print_success,
print_warning,
)
existing_id = get_env_value("TEAMS_CLIENT_ID")
if existing_id:
print_info(f"Teams: already configured (app ID: {existing_id})")
if not prompt_yes_no("Reconfigure Teams?", False):
return
print_info("Connect Hermes to Microsoft Teams via the Bot Framework.")
print_info("You'll need an Azure Bot registration with a client secret.")
print_info("See: https://learn.microsoft.com/azure/bot-service/")
print()
client_id = prompt(
"Azure App (client) ID",
default=existing_id or get_env_value("TEAMS_CLIENT_ID") or "",
)
if not client_id:
print_warning("Client ID is required — skipping Teams setup")
return
save_env_value("TEAMS_CLIENT_ID", client_id.strip())
client_secret = prompt(
"Client secret",
default=get_env_value("TEAMS_CLIENT_SECRET") or "",
password=True,
)
if not client_secret:
print_warning("Client secret is required — skipping Teams setup")
return
save_env_value("TEAMS_CLIENT_SECRET", client_secret.strip())
tenant_id = prompt(
"Tenant ID (or 'common' for multi-tenant)",
default=get_env_value("TEAMS_TENANT_ID") or "",
)
if not tenant_id:
print_warning("Tenant ID is required — skipping Teams setup")
return
save_env_value("TEAMS_TENANT_ID", tenant_id.strip())
port = prompt(
"Webhook listen port",
default=get_env_value("TEAMS_PORT") or "3978",
)
save_env_value("TEAMS_PORT", port.strip() or "3978")
print()
if prompt_yes_no("Restrict access to specific users? (recommended)", True):
allowed = prompt(
"Allowed Azure AD object IDs (comma-separated)",
default=get_env_value("TEAMS_ALLOWED_USERS") or "",
)
if allowed:
save_env_value("TEAMS_ALLOWED_USERS", allowed.replace(" ", ""))
print_success("Allowlist configured")
else:
save_env_value("TEAMS_ALLOWED_USERS", "")
else:
save_env_value("TEAMS_ALLOW_ALL_USERS", "true")
print_warning("⚠️ Open access — anyone who can message the bot can command it.")
print()
print_success("Teams configuration saved to ~/.hermes/.env")
print_info("Set your bot's messaging endpoint to: https://<your-tunnel>/api/messages")
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
# ── Plugin entry point ────────────────────────────────────────────────────────
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="teams",
label="Microsoft Teams",
adapter_factory=lambda cfg: TeamsAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"],
install_hint="pip install microsoft-teams-apps aiohttp",
setup_fn=interactive_setup,
# Auth env vars for _is_user_authorized() integration
allowed_users_env="TEAMS_ALLOWED_USERS",
allow_all_env="TEAMS_ALLOW_ALL_USERS",
# Teams supports up to ~28 KB per message
max_message_length=28000,
# Display
emoji="💼",
allow_update_command=True,
# LLM guidance
platform_hint=(
"You are chatting via Microsoft Teams. Teams renders a subset of "
"markdown — bold (**text**), italic (*text*), and inline code "
"(`code`) work, but complex tables or raw HTML do not. Keep "
"responses clear and professional."
),
)

View File

@ -0,0 +1,13 @@
name: teams-platform
kind: platform
version: 1.0.0
description: >
Microsoft Teams gateway adapter for Hermes Agent.
Connects to Microsoft Teams via the Bot Framework and relays messages
between Teams chats (personal DMs, group chats, channel posts) and
the Hermes agent. Supports Adaptive Card approval prompts.
author: Aamir Jawaid
requires_env:
- TEAMS_CLIENT_ID
- TEAMS_CLIENT_SECRET
- TEAMS_TENANT_ID

566
tests/gateway/test_teams.py Normal file
View File

@ -0,0 +1,566 @@
"""Tests for the Microsoft Teams platform adapter plugin."""
import asyncio
import os
import sys
import types
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform, PlatformConfig, HomeChannel
# Ensure the plugin directory is on sys.path for direct import (mirrors IRC pattern)
_REPO_ROOT = Path(__file__).resolve().parents[2]
_TEAMS_PLUGIN_DIR = _REPO_ROOT / "plugins" / "platforms" / "teams"
if str(_TEAMS_PLUGIN_DIR) not in sys.path:
sys.path.insert(0, str(_TEAMS_PLUGIN_DIR))
# ---------------------------------------------------------------------------
# SDK Mock — install in sys.modules before importing the adapter
# ---------------------------------------------------------------------------
def _ensure_teams_mock():
"""Install a teams SDK mock in sys.modules if the real package isn't present."""
if "microsoft_teams" in sys.modules and hasattr(sys.modules["microsoft_teams"], "__file__"):
return
# Build the module hierarchy
microsoft_teams = types.ModuleType("microsoft_teams")
microsoft_teams_apps = types.ModuleType("microsoft_teams.apps")
microsoft_teams_api = types.ModuleType("microsoft_teams.api")
microsoft_teams_api_activities = types.ModuleType("microsoft_teams.api.activities")
microsoft_teams_api_activities_typing = types.ModuleType("microsoft_teams.api.activities.typing")
microsoft_teams_api_activities_invoke = types.ModuleType("microsoft_teams.api.activities.invoke")
microsoft_teams_api_activities_invoke_adaptive_card = types.ModuleType(
"microsoft_teams.api.activities.invoke.adaptive_card"
)
microsoft_teams_api_models = types.ModuleType("microsoft_teams.api.models")
microsoft_teams_api_models_adaptive_card = types.ModuleType("microsoft_teams.api.models.adaptive_card")
microsoft_teams_api_models_invoke_response = types.ModuleType("microsoft_teams.api.models.invoke_response")
microsoft_teams_cards = types.ModuleType("microsoft_teams.cards")
microsoft_teams_apps_http = types.ModuleType("microsoft_teams.apps.http")
microsoft_teams_apps_http_adapter = types.ModuleType("microsoft_teams.apps.http.adapter")
# App class mock
class MockApp:
def __init__(self, **kwargs):
self._client_id = kwargs.get("client_id")
self.server = MagicMock()
self.server.handle_request = AsyncMock(return_value={"status": 200, "body": None})
self.credentials = MagicMock()
self.credentials.client_id = self._client_id
@property
def id(self):
return self._client_id
def on_message(self, func):
self._message_handler = func
return func
def on_card_action(self, func):
self._card_action_handler = func
return func
async def initialize(self):
pass
async def send(self, conversation_id, activity):
result = MagicMock()
result.id = "sent-activity-id"
return result
async def start(self, port=3978):
pass
async def stop(self):
pass
microsoft_teams_apps.App = MockApp
microsoft_teams_apps.ActivityContext = MagicMock
# MessageActivity mock
microsoft_teams_api.MessageActivity = MagicMock
microsoft_teams_api.ConversationReference = MagicMock
microsoft_teams_api.MessageActivityInput = MagicMock
# TypingActivityInput mock
class MockTypingActivityInput:
pass
microsoft_teams_api_activities_typing.TypingActivityInput = MockTypingActivityInput
# Adaptive card invoke activity mock
microsoft_teams_api_activities_invoke_adaptive_card.AdaptiveCardInvokeActivity = MagicMock
# Adaptive card response mocks
microsoft_teams_api_models_adaptive_card.AdaptiveCardActionCardResponse = MagicMock
microsoft_teams_api_models_adaptive_card.AdaptiveCardActionMessageResponse = MagicMock
# Invoke response mocks
class MockInvokeResponse:
def __init__(self, status=200, body=None):
self.status = status
self.body = body
microsoft_teams_api_models_invoke_response.InvokeResponse = MockInvokeResponse
microsoft_teams_api_models_invoke_response.AdaptiveCardInvokeResponse = MagicMock
# Cards mocks
class MockAdaptiveCard:
def with_version(self, v):
return self
def with_body(self, body):
return self
def with_actions(self, actions):
return self
microsoft_teams_cards.AdaptiveCard = MockAdaptiveCard
microsoft_teams_cards.ExecuteAction = MagicMock
microsoft_teams_cards.TextBlock = MagicMock
# HttpRequest TypedDict mock
def HttpRequest(body=None, headers=None):
return {"body": body, "headers": headers}
# HttpResponse TypedDict mock
HttpResponse = dict
HttpMethod = str
from typing import Callable
HttpRouteHandler = Callable
microsoft_teams_apps_http_adapter.HttpRequest = HttpRequest
microsoft_teams_apps_http_adapter.HttpResponse = HttpResponse
microsoft_teams_apps_http_adapter.HttpMethod = HttpMethod
microsoft_teams_apps_http_adapter.HttpRouteHandler = HttpRouteHandler
# Wire the hierarchy
for name, mod in {
"microsoft_teams": microsoft_teams,
"microsoft_teams.apps": microsoft_teams_apps,
"microsoft_teams.api": microsoft_teams_api,
"microsoft_teams.api.activities": microsoft_teams_api_activities,
"microsoft_teams.api.activities.typing": microsoft_teams_api_activities_typing,
"microsoft_teams.api.activities.invoke": microsoft_teams_api_activities_invoke,
"microsoft_teams.api.activities.invoke.adaptive_card": microsoft_teams_api_activities_invoke_adaptive_card,
"microsoft_teams.api.models": microsoft_teams_api_models,
"microsoft_teams.api.models.adaptive_card": microsoft_teams_api_models_adaptive_card,
"microsoft_teams.api.models.invoke_response": microsoft_teams_api_models_invoke_response,
"microsoft_teams.cards": microsoft_teams_cards,
"microsoft_teams.apps.http": microsoft_teams_apps_http,
"microsoft_teams.apps.http.adapter": microsoft_teams_apps_http_adapter,
}.items():
sys.modules.setdefault(name, mod)
_ensure_teams_mock()
# Now safe to import the adapter
import adapter as _teams_mod
_teams_mod.TEAMS_SDK_AVAILABLE = True
_teams_mod.AIOHTTP_AVAILABLE = True
from adapter import TeamsAdapter, check_requirements, check_teams_requirements, validate_config
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_config(**extra):
return PlatformConfig(enabled=True, extra=extra)
# ---------------------------------------------------------------------------
# Tests: Requirements
# ---------------------------------------------------------------------------
class TestTeamsRequirements:
def test_returns_false_when_sdk_missing(self, monkeypatch):
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False)
assert check_requirements() is False
def test_returns_false_when_aiohttp_missing(self, monkeypatch):
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", False)
assert check_requirements() is False
def test_returns_true_when_deps_available(self, monkeypatch):
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True)
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True)
assert check_requirements() is True
def test_alias_matches(self, monkeypatch):
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True)
monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True)
assert check_teams_requirements() is True
def test_validate_config_with_env(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id")
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "test-secret")
monkeypatch.setenv("TEAMS_TENANT_ID", "test-tenant")
assert validate_config(_make_config()) is True
def test_validate_config_from_extra(self, monkeypatch):
monkeypatch.delenv("TEAMS_CLIENT_ID", raising=False)
monkeypatch.delenv("TEAMS_CLIENT_SECRET", raising=False)
monkeypatch.delenv("TEAMS_TENANT_ID", raising=False)
cfg = _make_config(client_id="id", client_secret="secret", tenant_id="tenant")
assert validate_config(cfg) is True
def test_validate_config_missing(self, monkeypatch):
monkeypatch.delenv("TEAMS_CLIENT_ID", raising=False)
monkeypatch.delenv("TEAMS_CLIENT_SECRET", raising=False)
monkeypatch.delenv("TEAMS_TENANT_ID", raising=False)
assert validate_config(_make_config()) is False
def test_validate_config_missing_tenant(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id")
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "test-secret")
monkeypatch.delenv("TEAMS_TENANT_ID", raising=False)
assert validate_config(_make_config()) is False
# ---------------------------------------------------------------------------
# Tests: Adapter Init
# ---------------------------------------------------------------------------
class TestTeamsAdapterInit:
def test_reads_config_from_extra(self):
config = _make_config(
client_id="cfg-id",
client_secret="cfg-secret",
tenant_id="cfg-tenant",
)
adapter = TeamsAdapter(config)
assert adapter._client_id == "cfg-id"
assert adapter._client_secret == "cfg-secret"
assert adapter._tenant_id == "cfg-tenant"
def test_falls_back_to_env_vars(self, monkeypatch):
monkeypatch.setenv("TEAMS_CLIENT_ID", "env-id")
monkeypatch.setenv("TEAMS_CLIENT_SECRET", "env-secret")
monkeypatch.setenv("TEAMS_TENANT_ID", "env-tenant")
adapter = TeamsAdapter(_make_config())
assert adapter._client_id == "env-id"
assert adapter._client_secret == "env-secret"
assert adapter._tenant_id == "env-tenant"
def test_default_port(self):
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter._port == 3978
def test_custom_port_from_extra(self):
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant", port=4000))
assert adapter._port == 4000
def test_custom_port_from_env(self, monkeypatch):
monkeypatch.setenv("TEAMS_PORT", "5000")
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter._port == 5000
def test_platform_value(self):
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter.platform.value == "teams"
# ---------------------------------------------------------------------------
# Tests: Plugin registration
# ---------------------------------------------------------------------------
class TestTeamsPluginRegistration:
def test_register_calls_ctx(self):
from adapter import register
ctx = MagicMock()
register(ctx)
ctx.register_platform.assert_called_once()
def test_register_name(self):
from adapter import register
ctx = MagicMock()
register(ctx)
kwargs = ctx.register_platform.call_args[1]
assert kwargs["name"] == "teams"
def test_register_auth_env_vars(self):
from adapter import register
ctx = MagicMock()
register(ctx)
kwargs = ctx.register_platform.call_args[1]
assert kwargs["allowed_users_env"] == "TEAMS_ALLOWED_USERS"
assert kwargs["allow_all_env"] == "TEAMS_ALLOW_ALL_USERS"
def test_register_max_message_length(self):
from adapter import register
ctx = MagicMock()
register(ctx)
kwargs = ctx.register_platform.call_args[1]
assert kwargs["max_message_length"] == 28000
def test_register_has_setup_fn(self):
from adapter import register
ctx = MagicMock()
register(ctx)
kwargs = ctx.register_platform.call_args[1]
assert callable(kwargs.get("setup_fn"))
def test_register_has_platform_hint(self):
from adapter import register
ctx = MagicMock()
register(ctx)
kwargs = ctx.register_platform.call_args[1]
assert kwargs.get("platform_hint")
# ---------------------------------------------------------------------------
# Tests: Connect / Disconnect
# ---------------------------------------------------------------------------
class TestTeamsConnect:
@pytest.mark.asyncio
async def test_connect_fails_without_sdk(self, monkeypatch):
monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False)
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
result = await adapter.connect()
assert result is False
@pytest.mark.asyncio
async def test_connect_fails_without_credentials(self):
adapter = TeamsAdapter(_make_config())
adapter._client_id = ""
adapter._client_secret = ""
adapter._tenant_id = ""
result = await adapter.connect()
assert result is False
@pytest.mark.asyncio
async def test_disconnect_cleans_up(self):
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
adapter._running = True
mock_runner = AsyncMock()
adapter._runner = mock_runner
adapter._app = MagicMock()
await adapter.disconnect()
assert adapter._running is False
assert adapter._app is None
assert adapter._runner is None
mock_runner.cleanup.assert_awaited_once()
# ---------------------------------------------------------------------------
# Tests: Send
# ---------------------------------------------------------------------------
class TestTeamsSend:
@pytest.mark.asyncio
async def test_send_returns_error_without_app(self):
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
adapter._app = None
result = await adapter.send("conv-id", "Hello")
assert result.success is False
assert "not initialized" in result.error
@pytest.mark.asyncio
async def test_send_calls_app_send(self):
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
mock_result = MagicMock()
mock_result.id = "msg-123"
mock_app = MagicMock()
mock_app.send = AsyncMock(return_value=mock_result)
adapter._app = mock_app
result = await adapter.send("conv-id", "Hello")
assert result.success is True
assert result.message_id == "msg-123"
mock_app.send.assert_awaited_once_with("conv-id", "Hello")
@pytest.mark.asyncio
async def test_send_handles_error(self):
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
mock_app = MagicMock()
mock_app.send = AsyncMock(side_effect=Exception("Network error"))
adapter._app = mock_app
result = await adapter.send("conv-id", "Hello")
assert result.success is False
assert "Network error" in result.error
@pytest.mark.asyncio
async def test_send_typing(self):
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
mock_app = MagicMock()
mock_app.send = AsyncMock()
adapter._app = mock_app
await adapter.send_typing("conv-id")
mock_app.send.assert_awaited_once()
call_args = mock_app.send.call_args
assert call_args[0][0] == "conv-id"
# ---------------------------------------------------------------------------
# Tests: Message Handling
# ---------------------------------------------------------------------------
class TestTeamsMessageHandling:
def _make_activity(
self,
*,
text="Hello",
from_id="user-123",
from_aad_id="aad-456",
from_name="Test User",
conversation_id="19:abc@thread.v2",
conversation_type="personal",
tenant_id="tenant-789",
activity_id="activity-001",
attachments=None,
):
activity = MagicMock()
activity.text = text
activity.id = activity_id
activity.from_ = MagicMock()
activity.from_.id = from_id
activity.from_.aad_object_id = from_aad_id
activity.from_.name = from_name
activity.conversation = MagicMock()
activity.conversation.id = conversation_id
activity.conversation.conversation_type = conversation_type
activity.conversation.name = "Test Chat"
activity.conversation.tenant_id = tenant_id
activity.attachments = attachments or []
return activity
def _make_ctx(self, activity):
ctx = MagicMock()
ctx.activity = activity
return ctx
@pytest.mark.asyncio
async def test_personal_message_creates_dm_event(self):
adapter = TeamsAdapter(_make_config(
client_id="bot-id", client_secret="secret", tenant_id="tenant",
))
adapter._app = MagicMock()
adapter._app.id = "bot-id"
adapter.handle_message = AsyncMock()
activity = self._make_activity(conversation_type="personal")
await adapter._on_message(self._make_ctx(activity))
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.call_args[0][0]
assert event.source.chat_type == "dm"
@pytest.mark.asyncio
async def test_group_message_creates_group_event(self):
adapter = TeamsAdapter(_make_config(
client_id="bot-id", client_secret="secret", tenant_id="tenant",
))
adapter._app = MagicMock()
adapter._app.id = "bot-id"
adapter.handle_message = AsyncMock()
activity = self._make_activity(conversation_type="groupChat")
await adapter._on_message(self._make_ctx(activity))
event = adapter.handle_message.call_args[0][0]
assert event.source.chat_type == "group"
@pytest.mark.asyncio
async def test_channel_message_creates_channel_event(self):
adapter = TeamsAdapter(_make_config(
client_id="bot-id", client_secret="secret", tenant_id="tenant",
))
adapter._app = MagicMock()
adapter._app.id = "bot-id"
adapter.handle_message = AsyncMock()
activity = self._make_activity(conversation_type="channel")
await adapter._on_message(self._make_ctx(activity))
event = adapter.handle_message.call_args[0][0]
assert event.source.chat_type == "channel"
@pytest.mark.asyncio
async def test_user_id_uses_aad_object_id(self):
adapter = TeamsAdapter(_make_config(
client_id="bot-id", client_secret="secret", tenant_id="tenant",
))
adapter._app = MagicMock()
adapter._app.id = "bot-id"
adapter.handle_message = AsyncMock()
activity = self._make_activity(from_aad_id="aad-stable-id", from_id="teams-id")
await adapter._on_message(self._make_ctx(activity))
event = adapter.handle_message.call_args[0][0]
assert event.source.user_id == "aad-stable-id"
@pytest.mark.asyncio
async def test_self_message_filtered(self):
adapter = TeamsAdapter(_make_config(
client_id="bot-id", client_secret="secret", tenant_id="tenant",
))
adapter._app = MagicMock()
adapter._app.id = "bot-id"
adapter.handle_message = AsyncMock()
activity = self._make_activity(from_id="bot-id")
await adapter._on_message(self._make_ctx(activity))
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_bot_mention_stripped_from_text(self):
adapter = TeamsAdapter(_make_config(
client_id="bot-id", client_secret="secret", tenant_id="tenant",
))
adapter._app = MagicMock()
adapter._app.id = "bot-id"
adapter.handle_message = AsyncMock()
activity = self._make_activity(
text="<at>Hermes</at> what is the weather?",
from_id="user-id",
)
await adapter._on_message(self._make_ctx(activity))
event = adapter.handle_message.call_args[0][0]
assert event.text == "what is the weather?"
@pytest.mark.asyncio
async def test_deduplication(self):
adapter = TeamsAdapter(_make_config(
client_id="bot-id", client_secret="secret", tenant_id="tenant",
))
adapter._app = MagicMock()
adapter._app.id = "bot-id"
adapter.handle_message = AsyncMock()
activity = self._make_activity(activity_id="msg-dup-001", from_id="user-id")
ctx = self._make_ctx(activity)
await adapter._on_message(ctx)
await adapter._on_message(ctx)
assert adapter.handle_message.await_count == 1

View File

@ -0,0 +1,211 @@
---
sidebar_position: 5
title: "Microsoft Teams"
description: "Set up Hermes Agent as a Microsoft Teams bot"
---
# Microsoft Teams Setup
Connect Hermes Agent to Microsoft Teams as a bot. Unlike Slack's Socket Mode, Teams delivers messages by calling a **public HTTPS webhook**, so your instance needs a publicly reachable endpoint — either a dev tunnel (local dev) or a real domain (production).
## How the Bot Responds
| Context | Behavior |
|---------|----------|
| **Personal chat (DM)** | Bot responds to every message. No @mention needed. |
| **Group chat** | Bot responds to every message in the chat. |
| **Channel** | Bot only responds when @mentioned (Teams delivers @mentions as regular messages with `<at>BotName</at>` tags, which Hermes strips automatically). |
---
## Step 1: Install the Teams CLI
The `@microsoft/teams.cli` automates bot registration — no Azure portal needed.
```bash
npm install -g @microsoft/teams.cli@preview
teams login
```
To verify your login and find your own AAD object ID (needed for `TEAMS_ALLOWED_USERS`):
```bash
teams status --verbose
```
---
## Step 2: Expose Port 3978
Teams cannot deliver messages to `localhost`. For local development, use any tunnel tool to get a public HTTPS URL:
```bash
# devtunnel (Microsoft)
devtunnel create hermes-bot --allow-anonymous
devtunnel port create hermes-bot -p 3978 --protocol https
devtunnel host hermes-bot
# ngrok
ngrok http 3978
# cloudflared
cloudflared tunnel --url http://localhost:3978
```
Copy the `https://` URL from the output — you'll use it in the next step. Leave the tunnel running while developing.
For production, point your bot's endpoint at your server's public domain instead (see [Production Deployment](#production-deployment)).
---
## Step 3: Create the Bot
```bash
teams app create \
--name "Hermes" \
--endpoint "https://<your-tunnel-url>/api/messages"
```
The CLI outputs your `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Save them — you'll need all three.
---
## Step 4: Configure Environment Variables
Add to `~/.hermes/.env`:
```bash
# Required
TEAMS_CLIENT_ID=<your-client-id>
TEAMS_CLIENT_SECRET=<your-client-secret>
TEAMS_TENANT_ID=<your-tenant-id>
# Restrict access to specific users (recommended)
# Use AAD object IDs from `teams status --verbose`
TEAMS_ALLOWED_USERS=<your-aad-object-id>
```
---
## Step 5: Start the Gateway
```bash
HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d gateway
```
This starts the gateway and maps port 3978 on your host to the container. Check that it's running:
```bash
curl http://localhost:3978/health # should return: ok
docker logs -f hermes
```
Look for:
```
[teams] Webhook server listening on 0.0.0.0:3978/api/messages
```
---
## Step 6: Install the App in Teams
```bash
teams app install --id <teamsAppId>
```
The `teamsAppId` was printed by `teams app create` in Step 3. After installing, open Microsoft Teams and send a direct message to your bot — it's ready.
---
## Configuration Reference
### Environment Variables
| Variable | Description |
|----------|-------------|
| `TEAMS_CLIENT_ID` | Azure AD App (client) ID |
| `TEAMS_CLIENT_SECRET` | Azure AD client secret |
| `TEAMS_TENANT_ID` | Azure AD tenant ID |
| `TEAMS_ALLOWED_USERS` | Comma-separated AAD object IDs allowed to use the bot |
| `TEAMS_HOME_CHANNEL` | Conversation ID for cron/proactive message delivery |
| `TEAMS_HOME_CHANNEL_NAME` | Display name for the home channel |
| `TEAMS_PORT` | Webhook port (default: `3978`) |
### config.yaml
Alternatively, configure via `~/.hermes/config.yaml`:
```yaml
platforms:
teams:
enabled: true
extra:
client_id: "your-client-id"
client_secret: "your-secret"
tenant_id: "your-tenant-id"
port: 3978
```
---
## Features
### Interactive Approval Cards
When the agent needs to run a potentially dangerous command, it sends an Adaptive Card with four buttons instead of asking you to type `/approve`:
- **Allow Once** — approve this specific command
- **Allow Session** — approve this pattern for the rest of the session
- **Always Allow** — permanently approve this pattern
- **Deny** — reject the command
Clicking a button resolves the approval inline and replaces the card with the decision.
---
## Production Deployment
For a permanent server, skip devtunnel and register your bot with your server's public HTTPS endpoint:
```bash
teams app create \
--name "Hermes" \
--endpoint "https://your-domain.com/api/messages"
```
If you've already created the bot and just need to update the endpoint:
```bash
teams app update --id <teamsAppId> --endpoint "https://your-domain.com/api/messages"
```
Make sure port 3978 (or your configured `TEAMS_PORT`) is reachable from the internet and that your TLS certificate is valid — Teams rejects self-signed certificates.
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `health` endpoint works but bot doesn't respond | Check that your tunnel is still running and the bot's messaging endpoint matches the tunnel URL |
| `KeyError: 'teams'` in logs | Restart the container — this is fixed in the current version |
| Bot responds with auth errors | Verify `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are all set correctly |
| `No inference provider configured` | Check that `ANTHROPIC_API_KEY` (or another provider key) is set in `~/.hermes/.env` |
| Bot receives messages but ignores them | Your AAD object ID may not be in `TEAMS_ALLOWED_USERS`. Run `teams status --verbose` to find it |
| Tunnel URL changes on restart | devtunnel URLs are persistent if you use a named tunnel (`devtunnel create hermes-bot`). ngrok and cloudflared generate a new URL each run unless you have a paid plan — update the bot endpoint with `teams app update` when it changes |
| Teams shows "This bot is not responding" | The webhook returned an error. Check `docker logs hermes` for tracebacks |
| `[teams] Failed to connect` in logs | The SDK failed to authenticate. Double-check your credentials and that the tenant ID matches the account you used in `teams login` |
---
## Security
:::warning
**Always set `TEAMS_ALLOWED_USERS`** with the AAD object IDs of authorized users. Without this, anyone who can find or install your bot can interact with it.
Treat `TEAMS_CLIENT_SECRET` like a password — rotate it periodically via the Azure portal or Teams CLI.
:::
- Store credentials in `~/.hermes/.env` with permissions `600` (`chmod 600 ~/.hermes/.env`)
- The bot only accepts messages from users in `TEAMS_ALLOWED_USERS`; unauthorized messages are silently dropped
- Your public endpoint (`/api/messages`) is authenticated by the Teams Bot Framework — requests without valid JWTs are rejected