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

@@ -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