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:
parent
21e695fcb6
commit
b3137d758c
16
.env.example
16
.env.example
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
3
plugins/platforms/teams/__init__.py
Normal file
3
plugins/platforms/teams/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .adapter import register
|
||||||
|
|
||||||
|
__all__ = ["register"]
|
||||||
637
plugins/platforms/teams/adapter.py
Normal file
637
plugins/platforms/teams/adapter.py
Normal 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""
|
||||||
|
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."
|
||||||
|
),
|
||||||
|
)
|
||||||
13
plugins/platforms/teams/plugin.yaml
Normal file
13
plugins/platforms/teams/plugin.yaml
Normal 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
566
tests/gateway/test_teams.py
Normal 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
|
||||||
211
website/docs/user-guide/messaging/teams.md
Normal file
211
website/docs/user-guide/messaging/teams.md
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user